diff --git "a/adam/remote_access.py" "b/adam/remote_access.py"
new file mode 100644--- /dev/null
+++ "b/adam/remote_access.py"
@@ -0,0 +1,2751 @@
+from __future__ import annotations
+
+import json
+import mimetypes
+import secrets
+import shutil
+import socket
+import subprocess
+import threading
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from hmac import compare_digest
+from ipaddress import ip_address
+from pathlib import Path
+from typing import Any
+from urllib.parse import parse_qs, urlencode, urlparse
+
+from adam.generations import (
+ ChatGenerationRequest,
+ build_generation_plan,
+ generation_model_match_score,
+ generation_tools,
+ load_generation_history,
+ parse_chat_generation_request,
+)
+from adam.remote_dispatcher import RemoteCommandDispatcher
+from adam.remote_media import OpaqueIdCodec, RemoteMediaStore
+from adam.remote_v1 import RemoteV1Service
+
+
+REMOTE_MODE_DISABLED = "disabled"
+REMOTE_MODE_LOCAL = "local_wifi"
+REMOTE_MODE_TAILSCALE = "tailscale"
+
+
+class RemoteHTTPServer(ThreadingHTTPServer):
+ """Bound concurrent connections so slow clients cannot spawn unlimited threads."""
+
+ allow_reuse_address = True
+ daemon_threads = True
+
+ def __init__(self, *args, **kwargs):
+ self._slots = threading.BoundedSemaphore(16)
+ super().__init__(*args, **kwargs)
+
+ def process_request(self, request, client_address):
+ if not self._slots.acquire(blocking=False):
+ self.shutdown_request(request)
+ return
+ try:
+ super().process_request(request, client_address)
+ except BaseException:
+ self._slots.release()
+ raise
+
+ def process_request_thread(self, request, client_address):
+ try:
+ super().process_request_thread(request, client_address)
+ finally:
+ self._slots.release()
+
+
+@dataclass(frozen=True, slots=True)
+class TailscaleStatus:
+ installed: bool = False
+ connected: bool = False
+ device_name: str = ""
+ dns_name: str = ""
+ tailscale_ip: str = ""
+ backend_state: str = ""
+ serve_available: bool = False
+ serve_running: bool = False
+ message: str = "Tailscale is not installed."
+
+
+def default_remote_settings() -> dict[str, Any]:
+ return {
+ "enabled": False,
+ "remote_mode": REMOTE_MODE_LOCAL,
+ "bind_address": "127.0.0.1",
+ "port": 8765,
+ "token": secrets.token_urlsafe(24),
+ "allow_job_control": False,
+ "auto_approve_training": False,
+ }
+
+
+def remote_scope(bind_address: str) -> str:
+ bind = bind_address.strip().casefold()
+ if bind == "localhost":
+ return "local-device only"
+ if bind in {"0.0.0.0", "::"}:
+ return "all network interfaces"
+ try:
+ address = ip_address(bind.strip("[]"))
+ except ValueError:
+ return "custom bind address"
+ if address.is_loopback:
+ return "local-device only"
+ if address.is_private or address.is_link_local:
+ return "local network"
+ return "custom bind address"
+
+
+def local_network_host() -> str:
+ """Best-effort address other devices on the same network can use."""
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
+ sock.connect(("8.8.8.8", 80))
+ host = str(sock.getsockname()[0])
+ except OSError:
+ try:
+ host = socket.gethostbyname(socket.gethostname())
+ except OSError:
+ return ""
+ try:
+ address = ip_address(host)
+ except ValueError:
+ return ""
+ if address.is_loopback or address.is_unspecified:
+ return ""
+ return host if address.is_private or address.is_link_local else ""
+
+
+def inspect_tailscale(
+ runner: Any | None = None,
+ which: Any | None = None,
+) -> TailscaleStatus:
+ which = which or shutil.which
+ executable = which("tailscale")
+ if not executable:
+ return TailscaleStatus()
+ runner = runner or _run_tailscale
+ try:
+ status = runner([executable, "status", "--json"])
+ except OSError as exc:
+ return TailscaleStatus(installed=True, message=f"Tailscale could not be checked: {exc}")
+ if getattr(status, "returncode", 1) != 0:
+ error = _command_text(getattr(status, "stderr", "")) or "Tailscale is installed but not connected."
+ return TailscaleStatus(installed=True, message=error)
+ try:
+ payload = json.loads(_command_text(getattr(status, "stdout", "")) or "{}")
+ except json.JSONDecodeError:
+ return TailscaleStatus(installed=True, message="Tailscale returned an unreadable status response.")
+ self_node = payload.get("Self") if isinstance(payload, dict) else {}
+ self_node = self_node if isinstance(self_node, dict) else {}
+ ips = [str(item) for item in self_node.get("TailscaleIPs", []) if str(item)]
+ backend = str(payload.get("BackendState", "") or "")
+ connected = backend.casefold() == "running" or bool(ips)
+ serve_status = _tailscale_serve_running(runner, executable)
+ return TailscaleStatus(
+ installed=True,
+ connected=connected,
+ device_name=str(self_node.get("HostName", "") or ""),
+ dns_name=str(self_node.get("DNSName", "") or "").rstrip("."),
+ tailscale_ip=next((ip for ip in ips if "." in ip), ips[0] if ips else ""),
+ backend_state=backend,
+ serve_available=serve_status is not None,
+ serve_running=bool(serve_status),
+ message="Tailscale is connected." if connected else "Tailscale is installed but disconnected.",
+ )
+
+
+def _tailscale_serve_running(runner: Any, executable: str) -> bool | None:
+ try:
+ result = runner([executable, "serve", "status", "--json"])
+ except OSError:
+ return None
+ if getattr(result, "returncode", 1) != 0:
+ return None
+ text = _command_text(getattr(result, "stdout", "")).strip()
+ return bool(text and text not in {"{}", "null"})
+
+
+def _run_tailscale(command: list[str]) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(command, capture_output=True, text=True, timeout=8, check=False)
+
+
+def _command_text(value: Any) -> str:
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="replace")
+ return str(value or "")
+
+
+def _remote_prompt_from_payload(payload: dict[str, Any]) -> str:
+ for key in ("prompt", "message", "text", "request", "input"):
+ value = payload.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return ""
+
+
+def _remote_dashboard_html() -> str:
+ return _remote_dashboard_app_html()
+ return """
+
+
+
+
+ADAM Remote
+
+
+
+
+
+
+ ADAM
+ AI Development and Automation Manager
+
+ Connecting
+
+
+
+
+ Prompt ADAM
+
+
+
+ Generate
+
+
+
+ Active Job
+
+
+
Checking ADAM...
+
Waiting for status.
+
+
...
+
+
+
+
--
+
--
+
--
+
--
+
+
+
+
+ Live Preview
+
+
![Latest ADAM preview]()
+
The latest training or generation preview will appear here.
+
+ Waiting for preview output.
+
+
+ Latest Generation
+
+
![Latest generated ADAM image]()
+
Finished generated images will appear here.
+
+
+
+ 1 of 1
+
+
+ Waiting for a completed generation.
+
+
+
+ Recent Queue
+
+
+
+ Completed Jobs
+
+
+
+ Failed Jobs
+
+
+
+
+
+
+"""
+
+
+def _remote_dashboard_app_html() -> str:
+ from adam.remote_dashboard import remote_dashboard_app_html
+
+ return remote_dashboard_app_html()
+ return """
+
+
+
+
+ADAM Remote
+
+
+
+
+
+
+
+
Active Job
No active job.
+
Live Preview
![Live Preview]()
Waiting for a preview.
+
+
Prompt ADAM
+
+
+
+
+
Generate Image
+
+
+
+
+
+
+
+
+
+
+
+
+
Datasets
+
+
![Dataset image]()
+
Models
+
+
+
+
+
+
+"""
+
+
+class RemoteAccessService:
+ """Small authenticated local API foundation for browser/device clients."""
+
+ def __init__(self, config: Any, jobs: Any, monitor: Any, planner: Any = None) -> None:
+ self.config = config
+ self.jobs = jobs
+ self.monitor = monitor
+ self.planner = planner
+ self.root = Path(getattr(config, "root", None) or getattr(planner, "root", None) or Path.cwd()).resolve()
+ self.dispatcher = RemoteCommandDispatcher()
+ token = ""
+ try:
+ token = str(self.settings().get("token", ""))
+ except Exception:
+ token = ""
+ self.codec = OpaqueIdCodec(f"{self.root}|{token}")
+ self.media = RemoteMediaStore(self.root, self.codec)
+ self.api_v1 = RemoteV1Service(
+ root=self.root,
+ config=config,
+ jobs=jobs,
+ planner=planner,
+ dispatcher=self.dispatcher,
+ codec=self.codec,
+ media=self.media,
+ auto_approve_training=self._should_auto_approve_training,
+ )
+ self._server: ThreadingHTTPServer | None = None
+ self._thread: threading.Thread | None = None
+
+ @property
+ def running(self) -> bool:
+ return self._server is not None
+
+ def settings(self) -> dict[str, Any]:
+ values = default_remote_settings()
+ stored = self.config.get("remote_access", {})
+ if isinstance(stored, dict):
+ values.update(stored)
+ if not isinstance(stored, dict) or not stored.get("token"):
+ values["token"] = secrets.token_urlsafe(24)
+ self.config.update({"remote_access": values})
+ return values
+
+ def save_settings(self, values: dict[str, Any]) -> None:
+ clean = self.settings()
+ port = clean["port"]
+ try:
+ port = int(values.get("port", clean["port"]))
+ except (TypeError, ValueError):
+ port = clean["port"]
+ clean.update(
+ {
+ "enabled": bool(values.get("enabled", clean["enabled"])),
+ "remote_mode": self._clean_mode(str(values.get("remote_mode", clean["remote_mode"]))),
+ "bind_address": str(values.get("bind_address", clean["bind_address"])).strip() or "127.0.0.1",
+ "port": max(1024, min(port, 65535)),
+ "token": str(values.get("token", clean["token"])).strip() or secrets.token_urlsafe(24),
+ "allow_job_control": bool(values.get("allow_job_control", clean["allow_job_control"])),
+ "auto_approve_training": bool(values.get("auto_approve_training", clean["auto_approve_training"])),
+ }
+ )
+ self.config.update({"remote_access": clean})
+
+ def start(self) -> str:
+ if self.running:
+ return self.url()
+ settings = self.settings()
+ mode = self._clean_mode(str(settings.get("remote_mode", REMOTE_MODE_LOCAL)))
+ if mode == REMOTE_MODE_DISABLED:
+ raise RuntimeError("Remote access is disabled by Remote Mode.")
+ if not settings.get("enabled"):
+ raise RuntimeError("Remote access is disabled.")
+ bind = "127.0.0.1" if mode == REMOTE_MODE_TAILSCALE else str(settings["bind_address"])
+ port = int(settings["port"])
+ jobs = self.jobs
+ monitor = self.monitor
+ service = self
+ api_v1 = self.api_v1
+
+ class Handler(BaseHTTPRequestHandler):
+ def setup(self) -> None:
+ self.request.settimeout(10)
+ super().setup()
+
+ def _authorized(self) -> bool:
+ current = service.settings()
+ if not current.get("enabled") or current.get("remote_mode") == REMOTE_MODE_DISABLED:
+ return False
+ token = str(current["token"]).encode("utf-8")
+ header = self.headers.get("Authorization", "")
+ query_token = ""
+ parsed = urlparse(self.path)
+ values = parse_qs(parsed.query)
+ if values.get("token"):
+ query_token = values["token"][0]
+ bearer = header.removeprefix("Bearer ").strip()
+ return compare_digest(bearer.encode("utf-8"), token) or (
+ bool(query_token) and self._browser_token_allowed() and compare_digest(query_token.encode("utf-8"), token)
+ )
+
+ def _security_headers(self) -> None:
+ self.send_header("X-Content-Type-Options", "nosniff")
+ self.send_header("Referrer-Policy", "no-referrer")
+ self.send_header("X-Frame-Options", "DENY")
+ self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
+
+ def _valid_post(self) -> bool:
+ origin = self.headers.get("Origin")
+ if self.headers.get("Sec-Fetch-Site") == "cross-site" or (origin and (
+ urlparse(origin).scheme not in {"http", "https"}
+ or urlparse(origin).netloc.casefold() != self.headers.get("Host", "").casefold()
+ )):
+ self._send(403, {"error": "Cross-site requests are not allowed."})
+ return False
+ if self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() != "application/json":
+ self._send(415, {"error": "Use application/json for remote commands."})
+ return False
+ lengths = self.headers.get_all("Content-Length", [])
+ try:
+ length = int(lengths[0]) if len(lengths) == 1 else -1
+ except ValueError:
+ length = -1
+ if self.headers.get("Transfer-Encoding") or length < 0 or length > 20_000:
+ self._send(413 if length > 20_000 else 400, {"error": "Invalid request size (maximum 20000 bytes)."})
+ return False
+ return True
+
+ def _browser_token_allowed(self) -> bool:
+ try:
+ client = ip_address(str(self.client_address[0]).strip("[]"))
+ except ValueError:
+ return False
+ if client.is_loopback:
+ return True
+ return remote_scope(bind) != "local-device only" and (client.is_private or client.is_link_local)
+
+ def _send(self, status: int, payload: dict[str, Any]) -> None:
+ body = json.dumps(payload).encode("utf-8")
+ self._send_bytes(status, body, "application/json")
+
+ def _send_html(self, status: int, html: str) -> None:
+ self._send_bytes(status, html.encode("utf-8"), "text/html; charset=utf-8")
+
+ def _send_bytes(self, status: int, body: bytes, content_type: str) -> None:
+ if status >= 400 and self.command == "POST" and not getattr(self, "_body_read", False):
+ # Drain a small, already-sent body before closing; Windows can
+ # otherwise reset the connection before the error is delivered.
+ try:
+ length = int(self.headers.get("Content-Length", "0"))
+ if 0 < length <= 20_000 and not self.headers.get("Transfer-Encoding"):
+ self.connection.settimeout(0.25)
+ self.rfile.read(length)
+ except (ValueError, OSError):
+ pass
+ finally:
+ self.connection.settimeout(10)
+ self._body_read = True
+ try:
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Cache-Control", "no-store")
+ self._security_headers()
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
+ return
+
+ def do_GET(self) -> None:
+ if not self._authorized():
+ self._send(401, {"error": "Missing or invalid remote access token."})
+ return
+ path = urlparse(self.path).path
+ response = api_v1.route("GET", path, urlparse(self.path).query)
+ if response is not None:
+ self._send_response(response)
+ return
+ if path == "/":
+ self._send_html(200, _remote_dashboard_html())
+ return
+ if path == "/api/preview":
+ self._send_preview()
+ return
+ if path == "/api/generation-image":
+ self._send_generation_image()
+ return
+ if path != "/api/status":
+ self._send(404, {"error": "Unknown endpoint."})
+ return
+ self._send(200, service._status_payload(bind, service.settings()))
+
+ def do_POST(self) -> None:
+ if not self._authorized():
+ self._send(401, {"error": "Missing or invalid remote access token."})
+ return
+ if not self._valid_post():
+ return
+ path = urlparse(self.path).path
+ payload = None
+ if path.startswith("/api/v1/"):
+ payload = self._read_json_body()
+ if payload is None:
+ self._send(400, {"error": "Send a valid JSON object."})
+ return
+ response = api_v1.route("POST", path, urlparse(self.path).query, payload)
+ if response is not None:
+ self._send_response(response)
+ return
+ if path == "/api/job":
+ self._handle_job_action()
+ return
+ if path == "/api/remote-settings":
+ self._handle_remote_settings()
+ return
+ if path != "/api/prompt":
+ self._send(404, {"error": "Unknown endpoint."})
+ return
+ payload = self._read_json_body()
+ if payload is None:
+ self._send(400, {"error": "Send a valid prompt."})
+ return
+ prompt = _remote_prompt_from_payload(payload)
+ if not prompt:
+ self._send(400, {"error": "Type a prompt for ADAM first."})
+ return
+ if len(prompt) > 2_000:
+ self._send(400, {"error": "Keep remote prompts under 2000 characters."})
+ return
+ result = service.submit_prompt(prompt)
+ self._send(200 if result.get("ok") else 400, result)
+
+ def _send_response(self, response: Any) -> None:
+ try:
+ self.send_response(int(response.status))
+ self.send_header("Content-Type", str(response.content_type))
+ headers = response.headers or {}
+ if "Cache-Control" in headers:
+ self.send_header("Cache-Control", headers["Cache-Control"])
+ else:
+ self.send_header("Cache-Control", "no-store")
+ self._security_headers()
+ self.send_header("Content-Length", str(len(response.body)))
+ self.end_headers()
+ self.wfile.write(response.body)
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
+ return
+
+ def _read_json_body(self) -> dict[str, Any] | None:
+ self._body_read = True
+ try:
+ length = int(self.headers.get("Content-Length", "0") or "0")
+ except ValueError:
+ length = 0
+ try:
+ payload = json.loads(self.rfile.read(length).decode("utf-8")) if length else {}
+ except (UnicodeDecodeError, json.JSONDecodeError, OSError):
+ return None
+ return payload if isinstance(payload, dict) else None
+
+ def _handle_job_action(self) -> None:
+ payload = self._read_json_body()
+ if payload is None:
+ self._send(400, {"error": "Send a valid job action."})
+ return
+ action = str(payload.get("action", "")).strip().casefold()
+ job_id = str(payload.get("job_id", "")).strip()
+ result = service.job_action(job_id, action, bool(service.settings().get("allow_job_control")))
+ self._send(200 if result.get("ok") else 400, result)
+
+ def _handle_remote_settings(self) -> None:
+ payload = self._read_json_body()
+ if payload is None:
+ self._send(400, {"error": "Send valid remote settings."})
+ return
+ current = service.settings()
+ enabled = payload.get("auto_approve_training", False)
+ if not isinstance(enabled, bool):
+ self._send(400, {"error": "Auto-approval must be true or false."})
+ return
+ if enabled and not current.get("allow_job_control"):
+ self._send(403, {"error": "Enable remote job controls in the desktop app before changing approval permissions."})
+ return
+ def update_approval():
+ # Recheck on the owning thread and only change this permission.
+ # A queued request must not restore an older token or settings.
+ if enabled and not service.settings().get("allow_job_control"):
+ return False
+ service.save_settings({"auto_approve_training": enabled})
+ return True
+
+ if not service.dispatcher.call_ui(update_approval):
+ self._send(403, {"error": "Remote job controls have been disabled."})
+ return
+ state = "on" if enabled else "off"
+ self._send(200, {"ok": True, "message": f"Auto-approval is {state}."})
+
+ def _send_preview(self) -> None:
+ path = service.preview_path()
+ if path is None or not path.is_file():
+ self._send(404, {"error": "No preview image is available yet."})
+ return
+ content_type = mimetypes.guess_type(str(path))[0] or "image/png"
+ try:
+ body = path.read_bytes()
+ except OSError:
+ self._send(404, {"error": "Preview image is no longer available."})
+ return
+ self._send_bytes(200, body, content_type)
+
+ def _send_generation_image(self) -> None:
+ parsed = urlparse(self.path)
+ values = parse_qs(parsed.query)
+ try:
+ record_index = int(values.get("record", ["0"])[0])
+ image_index = int(values.get("image", ["0"])[0])
+ except (TypeError, ValueError):
+ self._send(400, {"error": "Choose a valid generation image."})
+ return
+ path = service.generation_image_path(record_index, image_index)
+ if path is None or not path.is_file():
+ self._send(404, {"error": "Generated image is no longer available."})
+ return
+ content_type = mimetypes.guess_type(str(path))[0] or "image/png"
+ try:
+ body = path.read_bytes()
+ except OSError:
+ self._send(404, {"error": "Generated image is no longer available."})
+ return
+ self._send_bytes(200, body, content_type)
+
+ def log_message(self, _format: str, *_args: Any) -> None:
+ return
+
+ self._server = RemoteHTTPServer((bind, port), Handler)
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
+ self._thread.start()
+ return self.url()
+
+ def _status_payload(self, bind: str, settings: dict[str, Any]) -> dict[str, Any]:
+ snapshot = self.monitor.snapshot() if self.monitor is not None else None
+ active = self.jobs.active_job if self.jobs is not None else None
+ return {
+ "app": "ADAM",
+ "scope": remote_scope(bind),
+ "permissions": {
+ "status": True,
+ "system": True,
+ "queue_view": True,
+ "prompt": self.planner is not None and self.jobs is not None,
+ "job_control": bool(settings.get("allow_job_control")),
+ "auto_approve_training": bool(settings.get("auto_approve_training")),
+ "dangerous_actions": False,
+ },
+ "active_job": None
+ if active is None
+ else {
+ "id": active.id,
+ "project": active.plan.project_name,
+ "status": active.status.value,
+ "progress": active.progress,
+ "timing": self._job_timing(active),
+ "preview": self._job_preview(active),
+ },
+ "preview": self.preview_payload(),
+ "latest_generation": self.latest_generation_payload(),
+ "queue": [
+ self._job_summary(job)
+ for job in (self.jobs.jobs[:20] if self.jobs is not None else [])
+ if job.status.value not in {"Finished", "Failed", "Cancelled"}
+ ],
+ "completed_jobs": [
+ self._job_summary(job)
+ for job in (self.jobs.jobs[:30] if self.jobs is not None else [])
+ if job.status.value == "Finished"
+ ],
+ "failed_jobs": [
+ self._job_summary(job)
+ for job in (self.jobs.jobs[:30] if self.jobs is not None else [])
+ if job.status.value in {"Failed", "Cancelled", "Interrupted"}
+ ],
+ "system": {
+ "cpu_percent": snapshot.cpu_percent if snapshot else None,
+ "memory_percent": snapshot.memory_percent if snapshot else None,
+ "gpu_name": snapshot.gpu_name if snapshot else "",
+ "gpu_percent": snapshot.gpu_percent if snapshot else None,
+ "vram_percent": snapshot.vram_percent if snapshot else None,
+ "gpu_temperature": snapshot.gpu_temperature if snapshot else None,
+ },
+ }
+
+ @staticmethod
+ def _job_summary(job: Any) -> dict[str, Any]:
+ try:
+ current_step = int(getattr(job, "current_step", -1))
+ except (TypeError, ValueError):
+ current_step = -1
+ steps = list(getattr(getattr(job, "plan", None), "steps", []) or [])
+ current_step_title = ""
+ if 0 <= current_step < len(steps):
+ current_step_title = str(getattr(steps[current_step], "title", "") or "")
+ return {
+ "id": getattr(job, "id", ""),
+ "project": getattr(getattr(job, "plan", None), "project_name", ""),
+ "status": getattr(getattr(job, "status", None), "value", str(getattr(job, "status", ""))),
+ "progress": getattr(job, "progress", 0),
+ "timing": RemoteAccessService._job_timing(job),
+ "error": getattr(job, "error", "") or "",
+ "started_at": getattr(job, "started_at", "") or "",
+ "ended_at": getattr(job, "ended_at", "") or "",
+ "scheduled_for": getattr(job, "scheduled_for", "") or "",
+ "current_step": current_step,
+ "step_count": len(steps),
+ "current_step_title": current_step_title,
+ "requires_confirmation": bool(getattr(getattr(job, "plan", None), "requires_confirmation", False)),
+ "output_folder": Path(str(getattr(job, "output_folder", "") or "")).name,
+ "progress_current": getattr(job, "progress_current", 0) or 0,
+ "progress_total": getattr(job, "progress_total", 0) or 0,
+ "progress_unit": getattr(job, "progress_unit", "") or "",
+ "logs": list(getattr(job, "logs", []) or [])[-3:],
+ }
+
+ @staticmethod
+ def _job_timing(job: Any) -> dict[str, Any]:
+ started = RemoteAccessService._parse_time(getattr(job, "started_at", "") or "")
+ ended = RemoteAccessService._parse_time(getattr(job, "ended_at", "") or "")
+ progress = max(0, min(100, int(getattr(job, "progress", 0) or 0)))
+ status = getattr(getattr(job, "status", None), "value", str(getattr(job, "status", "")))
+ review = getattr(getattr(job, "plan", None), "orion_review", {}) or {}
+ try:
+ estimate_seconds = int(float(review.get("estimated_high_minutes", 0) or 0) * 60)
+ except (TypeError, ValueError):
+ estimate_seconds = 0
+
+ now = datetime.now(timezone.utc)
+ elapsed_seconds = 0
+ if started is not None:
+ finish = ended or now
+ elapsed_seconds = max(0, int((finish - started).total_seconds()))
+
+ remaining_seconds: int | None = None
+ basis = ""
+ if status in {"Finished", "Failed", "Cancelled", "Interrupted"}:
+ remaining_seconds = 0
+ basis = "complete"
+ elif started is not None and progress > 0:
+ remaining_seconds = max(0, int(elapsed_seconds * (100 - progress) / progress))
+ basis = "progress"
+ elif estimate_seconds:
+ remaining_seconds = max(0, estimate_seconds - elapsed_seconds)
+ basis = "planning estimate"
+
+ finish_label = ""
+ if remaining_seconds is not None and remaining_seconds > 0:
+ finish_label = (now + timedelta(seconds=remaining_seconds)).astimezone().strftime("%I:%M %p").lstrip("0")
+
+ return {
+ "elapsed_seconds": elapsed_seconds if started is not None else None,
+ "remaining_seconds": remaining_seconds,
+ "estimated_total_seconds": estimate_seconds or None,
+ "elapsed_label": RemoteAccessService._format_duration(elapsed_seconds) if started is not None else "",
+ "remaining_label": (
+ "done" if remaining_seconds == 0 and basis == "complete"
+ else f"about {RemoteAccessService._format_duration(remaining_seconds)}" if remaining_seconds is not None else ""
+ ),
+ "finish_label": finish_label,
+ "estimate_label": f"up to {RemoteAccessService._format_duration(estimate_seconds)}" if estimate_seconds else "",
+ "basis": basis,
+ }
+
+ @staticmethod
+ def _parse_time(value: str) -> datetime | None:
+ if not value:
+ return None
+ try:
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.astimezone(timezone.utc)
+
+ @staticmethod
+ def _format_duration(seconds: int | float | None) -> str:
+ if seconds is None:
+ return ""
+ total = max(0, int(seconds))
+ if total < 60:
+ return f"{total}s"
+ minutes, sec = divmod(total, 60)
+ if minutes < 60:
+ return f"{minutes}m {sec}s" if sec else f"{minutes}m"
+ hours, minute = divmod(minutes, 60)
+ if hours < 24:
+ return f"{hours}h {minute}m" if minute else f"{hours}h"
+ days, hour = divmod(hours, 24)
+ return f"{days}d {hour}h" if hour else f"{days}d"
+
+ def preview_payload(self) -> dict[str, Any]:
+ if self.jobs is None:
+ return {"available": False}
+ candidates = []
+ if self.jobs.active_job is not None:
+ candidates.append(self.jobs.active_job)
+ candidates.extend(self.jobs.jobs[:20])
+ for job in candidates:
+ preview = self._job_preview(job)
+ if preview["available"]:
+ return preview
+ return {"available": False}
+
+ def preview_path(self) -> Path | None:
+ if self.jobs is None:
+ return None
+ candidates = []
+ if self.jobs.active_job is not None:
+ candidates.append(self.jobs.active_job)
+ candidates.extend(self.jobs.jobs[:20])
+ for job in candidates:
+ path = self._job_preview_path(job)
+ if path is not None:
+ return path
+ return None
+
+ @staticmethod
+ def _job_preview(job: Any) -> dict[str, Any]:
+ if RemoteAccessService._job_preview_path(job) is None:
+ return {"available": False}
+ return {
+ "available": True,
+ "url": "/api/preview",
+ "kind": getattr(job, "preview_kind", ""),
+ "epoch": getattr(job, "preview_epoch", 0),
+ "current": getattr(job, "preview_current", 0),
+ "total": getattr(job, "preview_total", 0),
+ "prompt": getattr(job, "preview_prompt", ""),
+ }
+
+ @staticmethod
+ def _job_preview_path(job: Any) -> Path | None:
+ path = str(getattr(job, "preview_path", "") or "")
+ if not path:
+ return None
+ target = Path(path)
+ return target if target.is_file() else None
+
+ def latest_generation_payload(self) -> dict[str, Any]:
+ records = self._generation_records()
+ if not records:
+ return {"available": False}
+ record = records[0]
+ return {
+ "available": True,
+ "url": "/api/generation-image?record=0&image=0",
+ "images": [
+ {
+ "index": index,
+ "url": f"/api/generation-image?record=0&image={index}",
+ }
+ for index, _path in enumerate(record.images)
+ ],
+ "model_name": record.model_name,
+ "provider_id": record.provider_id,
+ "provider_name": record.provider_name,
+ "prompt": record.prompt,
+ "seed": record.seed,
+ "steps": record.steps,
+ "sampler": record.sampler,
+ "aspect_ratio": record.aspect_ratio,
+ "created_at": record.created_at,
+ "image_count": len(record.images),
+ }
+
+ def generation_image_path(self, record_index: int, image_index: int) -> Path | None:
+ if record_index < 0 or image_index < 0:
+ return None
+ records = self._generation_records(limit=max(1, record_index + 1))
+ if record_index >= len(records):
+ return None
+ record = records[record_index]
+ if image_index >= len(record.images):
+ return None
+ path = record.images[image_index]
+ return path if path.is_file() else None
+
+ def _generation_records(self, *, limit: int = 30):
+ root = self._generation_root()
+ if root is None:
+ return []
+ return load_generation_history(root, limit=limit)
+
+ def _generation_root(self) -> Path | None:
+ for candidate in (
+ getattr(self.planner, "root", None),
+ getattr(getattr(self.planner, "registry", None), "root", None),
+ ):
+ if candidate:
+ return Path(candidate)
+ return None
+
+ def submit_prompt(self, prompt: str) -> dict[str, Any]:
+ generation_result = self._submit_generation_prompt(prompt)
+ if generation_result is not None:
+ return generation_result
+ if self.planner is None or self.jobs is None:
+ return {"ok": False, "error": "Remote prompting is not available in this ADAM session."}
+ try:
+ def prepare_plan():
+ from adam.training_assistant import append_preflight_summary
+
+ plan = self.planner.plan(prompt)
+ append_preflight_summary(plan, self.config)
+ return plan
+
+ plan = self.dispatcher.call_background(prepare_plan)
+ except Exception as exc:
+ return {"ok": False, "error": f"ADAM could not plan that request: {exc}"}
+ if not plan.steps:
+ return {"ok": True, "message": plan.summary or "ADAM received your message.", "requires_approval": False}
+ job = self.dispatcher.submit_job(self.jobs, plan)
+ if self._should_auto_approve_training(plan):
+ self.dispatcher.confirm_job(self.jobs, job.id)
+ return {
+ "ok": True,
+ "message": f"Queued {job.plan.project_name}. Remote training auto-approval is on.",
+ "job_id": job.id,
+ "requires_approval": False,
+ "auto_approved": True,
+ }
+ if plan.requires_confirmation:
+ return {
+ "ok": True,
+ "message": f"Plan created for {job.plan.project_name}. It needs approval in the desktop app before it runs.",
+ "job_id": job.id,
+ "requires_approval": True,
+ }
+ return {
+ "ok": True,
+ "message": f"Queued {job.plan.project_name}.",
+ "job_id": job.id,
+ "requires_approval": False,
+ }
+
+ def job_action(self, job_id: str, action: str, allowed: bool) -> dict[str, Any]:
+ if not allowed:
+ return {"ok": False, "error": "Remote job controls are disabled in ADAM."}
+ if self.jobs is None:
+ return {"ok": False, "error": "Job controls are not available in this ADAM session."}
+ if not job_id:
+ return {"ok": False, "error": "Choose a job first."}
+ if action not in {"cancel", "retry", "confirm", "pause", "resume", "end"}:
+ return {"ok": False, "error": "Unsupported remote job action."}
+ try:
+ result = self.dispatcher.job_action(self.jobs, job_id, action)
+ job = result.get("job")
+ if action == "cancel":
+ return {"ok": True, "message": f"Cancellation requested for {job.plan.project_name}."}
+ if action == "confirm":
+ return {"ok": True, "message": f"Approved {job.plan.project_name}."}
+ if action == "pause":
+ return {"ok": True, "message": f"Paused {job.plan.project_name}."}
+ if action == "resume":
+ return {"ok": True, "message": f"Resumed {job.plan.project_name}."}
+ if action == "end":
+ return {"ok": True, "message": f"Ended {job.plan.project_name}."}
+ retried = result.get("retried")
+ return {"ok": True, "message": f"Retry queued for {retried.plan.project_name}.", "job_id": retried.id}
+ except Exception as exc:
+ return {"ok": False, "error": f"ADAM could not update that job: {exc}"}
+
+ def _submit_generation_prompt(self, prompt: str) -> dict[str, Any] | None:
+ parsed = parse_chat_generation_request(prompt)
+ if parsed is None:
+ return None
+ if self.planner is None or self.jobs is None:
+ return {"ok": False, "error": "Remote image generation is not available in this ADAM session."}
+ if not all(hasattr(self.planner, name) for name in ("assets", "registry")):
+ return {"ok": False, "error": "Remote image generation needs the full ADAM planner session."}
+ try:
+ plan = self._generation_plan(parsed)
+ except ValueError as exc:
+ return {"ok": False, "error": str(exc)}
+ try:
+ job = self.dispatcher.submit_job(self.jobs, plan)
+ except Exception as exc:
+ return {"ok": False, "error": f"ADAM could not queue that generation: {exc}"}
+ return {
+ "ok": True,
+ "message": f"Queued {job.plan.project_name}.",
+ "job_id": job.id,
+ "requires_approval": bool(job.plan.requires_confirmation),
+ }
+
+ def _should_auto_approve_training(self, plan: Any) -> bool:
+ if getattr(plan, "orion_review", {}).get("level") == "warning":
+ return False
+ if not getattr(plan, "requires_confirmation", False):
+ return False
+ if not bool(self.settings().get("auto_approve_training")):
+ return False
+ return any(
+ str(getattr(step, "tool_id", "")).endswith("_trainer")
+ for step in getattr(plan, "steps", [])
+ )
+
+ def _generation_plan(self, parsed: ChatGenerationRequest):
+ assets = self.planner.assets
+ registry = self.planner.registry
+ if hasattr(assets, "discover"):
+ assets.discover(self.config)
+ tools = generation_tools(registry)
+ if not tools:
+ raise ValueError("No image generators are currently available in ADAM.")
+
+ stable_diffusion_request = (
+ parsed.has_positive_prompt
+ or bool(parsed.base_model_query)
+ or bool(parsed.negative_prompt)
+ or parsed.cfg_scale is not None
+ or parsed.lora_strength is not None
+ or parsed.denoise_strength is not None
+ )
+ plain_model_search = (
+ not parsed.provider_hint
+ and not stable_diffusion_request
+ and not parsed.model_query
+ )
+ base_only = (
+ stable_diffusion_request
+ and parsed.provider_hint != "lora"
+ and not parsed.model_query
+ )
+ preferred_id = {
+ "ddpm": "ddpm_generator",
+ "flow": "flow_generator",
+ "lora": "lora_generator",
+ }.get(parsed.provider_hint, "")
+ if stable_diffusion_request and parsed.provider_hint not in {"ddpm", "flow"}:
+ preferred_id = "lora_generator"
+ preferred_tool = next((item for item in tools if item.id == preferred_id), None)
+ if parsed.provider_hint and preferred_tool is None:
+ raise ValueError(f"The requested {parsed.provider_hint.upper()} image generator is not currently available.")
+
+ candidate_tools = [preferred_tool] if preferred_tool else tools
+ candidates = [
+ asset
+ for asset in getattr(assets, "assets", [])
+ if asset.kind == "model"
+ and (not plain_model_search or asset.trainer in {"ddpm", "flow"})
+ and not (plain_model_search and parsed.reference_image and asset.trainer == "flow")
+ and any(
+ item is not None and asset.trainer in item.model_trainers
+ for item in candidate_tools
+ )
+ and self._generation_model_is_ready(asset)
+ ]
+ model_query = parsed.model_query or (parsed.subject if not base_only else "")
+ scored = sorted(
+ (
+ (generation_model_match_score(model_query, asset.name), asset)
+ for asset in candidates
+ ),
+ key=lambda item: item[0],
+ reverse=True,
+ )
+ model = scored[0][1] if scored and scored[0][0] > 0 else None
+ if model is None and not model_query and len(candidates) == 1:
+ model = candidates[0]
+ if model is None and plain_model_search:
+ base_only = True
+ preferred_tool = next((item for item in tools if item.id == "lora_generator"), None)
+ candidate_tools = [preferred_tool] if preferred_tool else tools
+ model_query = ""
+ if model is None and not base_only:
+ detail = f' matching "{model_query}"' if model_query else ""
+ examples: list[str] = []
+ for asset in candidates:
+ if asset.name not in examples:
+ examples.append(asset.name)
+ if len(examples) == 4:
+ break
+ available = f" Available examples: {', '.join(examples)}." if examples else ""
+ raise ValueError(
+ f"I could not find a completed image model{detail}.{available} "
+ 'Try: Generate an image using model "Model Name".'
+ )
+
+ tool = next(
+ (
+ item
+ for item in candidate_tools
+ if item is not None and (base_only or model.trainer in item.model_trainers)
+ ),
+ None,
+ )
+ if tool is None:
+ raise ValueError("The matching model does not have an available image generator.")
+ if parsed.reference_image and "reference_image" not in tool.capabilities:
+ raise ValueError(
+ f"{tool.name} does not support reference-image conditioning. "
+ "Remove the attachment or choose LoRA/Stable Diffusion or DDPM."
+ )
+
+ options = tool.generation_options
+ saved_generation = self.config.get("generation_settings", {})
+ saved_generation = saved_generation if isinstance(saved_generation, dict) else {}
+ sampler_options = [str(value) for value in options.get("samplers", [])]
+ sampler = parsed.sampler or (
+ str(saved_generation.get("sampler", "")) if tool.id == "lora_generator" else ""
+ )
+ if sampler not in sampler_options:
+ sampler = sampler_options[0] if sampler_options else sampler or "DDIM"
+ aspect_options = [str(value) for value in options.get("aspect_ratios", [])]
+ aspect = parsed.aspect_ratio or (
+ str(saved_generation.get("aspect", "")) if tool.id == "lora_generator" else ""
+ )
+ if aspect and aspect not in aspect_options:
+ aspect = next(
+ (value for value in aspect_options if value.startswith(f"{aspect} ") or value == aspect),
+ "",
+ )
+ if not aspect:
+ aspect = aspect_options[0] if aspect_options else "1:1 (Square)"
+ step_min = int(options.get("step_min", 1) or 1)
+ step_max = int(options.get("step_max", 500) or 500)
+ default_steps = (
+ saved_generation.get("steps", options.get("step_default", 50))
+ if tool.id == "lora_generator"
+ else options.get("step_default", 50)
+ )
+ steps = parsed.steps if parsed.steps is not None else int(default_steps or 50)
+ steps = max(step_min, min(steps, step_max))
+ count_limit = 8 if tool.id == "lora_generator" else 32
+ default_count = (
+ int(saved_generation.get("images", 1) or 1)
+ if tool.id == "lora_generator"
+ else 1
+ )
+ count = max(1, min(parsed.image_count or default_count, count_limit))
+ seed = parsed.seed if parsed.seed is not None else 0
+ extra_arguments: dict[str, Any] = {}
+ if tool.id == "ddpm_generator":
+ extra_arguments = {
+ "reference_image": parsed.reference_image,
+ "reference_strength": max(
+ 0,
+ min(parsed.reference_strength if parsed.reference_strength is not None else 65, 100),
+ ),
+ "width": 0,
+ "height": 0,
+ }
+ elif tool.id == "lora_generator":
+ base_model_path = self._stable_diffusion_base_model_path(parsed, saved_generation)
+ extra_arguments = {
+ "negative_prompt": parsed.negative_prompt or str(saved_generation.get("negative_prompt", "")),
+ "base_model_path": base_model_path,
+ "width": 0,
+ "height": 0,
+ "cfg_scale": parsed.cfg_scale if parsed.cfg_scale is not None else float(saved_generation.get("cfg_scale", 0) or 0),
+ "lora_strength": 0.0 if base_only else (
+ parsed.lora_strength if parsed.lora_strength is not None else float(saved_generation.get("lora_strength", 0) or 0)
+ ),
+ "reference_image": parsed.reference_image,
+ "denoise_strength": parsed.denoise_strength if parsed.denoise_strength is not None else float(saved_generation.get("denoise_strength", 0) or 0),
+ "prompt_weighting": bool(saved_generation.get("prompt_weighting", True)),
+ }
+
+ return build_generation_plan(
+ tool,
+ model_name=(Path(extra_arguments.get("base_model_path", "")).stem if base_only else model.name),
+ model_path="" if base_only else model.path,
+ prompt=parsed.prompt,
+ image_count=count,
+ steps=steps,
+ seed=seed,
+ sampler=sampler,
+ aspect_ratio=aspect,
+ extra_arguments=extra_arguments,
+ )
+
+ def _stable_diffusion_base_model_path(
+ self,
+ parsed: ChatGenerationRequest,
+ saved_generation: dict[str, Any],
+ ) -> str:
+ base_assets = [
+ asset
+ for asset in getattr(self.planner.assets, "assets", [])
+ if asset.kind == "base_model" and Path(asset.path).exists()
+ ]
+ if parsed.base_model_query:
+ scored_bases = sorted(
+ (
+ (generation_model_match_score(parsed.base_model_query, asset.name), asset)
+ for asset in base_assets
+ ),
+ key=lambda item: item[0],
+ reverse=True,
+ )
+ if scored_bases and scored_bases[0][0] > 0:
+ return scored_bases[0][1].path
+ raise ValueError(
+ f'I could not find a Stable Diffusion base model matching "{parsed.base_model_query}".'
+ )
+ preferred_base = next(
+ (
+ asset
+ for asset in base_assets
+ if "waiillustrious" in "".join(
+ character for character in asset.name.casefold() if character.isalnum()
+ )
+ or "wallilustrious" in "".join(
+ character for character in asset.name.casefold() if character.isalnum()
+ )
+ ),
+ None,
+ )
+ if preferred_base is not None:
+ return preferred_base.path
+ selected_base = str(saved_generation.get("base_model_path", ""))
+ if selected_base and Path(selected_base).expanduser().exists():
+ return selected_base
+ trainer_root = Path(str(self.config.get("tool_folders", {}).get("lora_trainer", "")))
+ try:
+ trainer_settings = json.loads(
+ (trainer_root / "config" / "app_settings.json").read_text(encoding="utf-8")
+ )
+ configured_base = str(
+ trainer_settings.get("generate_model")
+ or trainer_settings.get("last_model")
+ or ""
+ )
+ configured_path = Path(configured_base).expanduser()
+ if configured_base and not configured_path.is_absolute():
+ configured_path = trainer_root / configured_path
+ if configured_base and configured_path.exists():
+ return str(configured_path.resolve())
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
+ pass
+ if len(base_assets) == 1:
+ return base_assets[0].path
+ names = ", ".join(asset.name for asset in base_assets[:4])
+ available = f" Available base models: {names}." if names else ""
+ raise ValueError(
+ "LoRA generation also needs a Stable Diffusion base model. Put one in "
+ '"LoRA StableDiffusionModels Here", or select one in the Generations tab.'
+ + available
+ )
+
+ @staticmethod
+ def _generation_model_is_ready(asset: Any) -> bool:
+ path = Path(asset.path)
+ if asset.trainer == "ddpm":
+ return path.is_dir() and (path / "model_index.json").is_file()
+ if asset.trainer == "flow":
+ return (
+ path.is_dir()
+ and (path / "flow_model_info.json").is_file()
+ and (path / "unet" / "config.json").is_file()
+ )
+ if asset.trainer == "lora":
+ return (
+ path.is_file()
+ and path.suffix.casefold() == ".safetensors"
+ and "_comfy" not in path.stem.casefold()
+ ) or (
+ path.is_dir()
+ and any(
+ item.is_file()
+ and item.suffix.casefold() == ".safetensors"
+ and "_comfy" not in item.stem.casefold()
+ for item in path.glob("*.safetensors")
+ )
+ )
+ return path.exists()
+
+ def stop(self) -> None:
+ if self._server is None:
+ return
+ self._server.shutdown()
+ self._server.server_close()
+ self._server = None
+ self._thread = None
+
+ def shutdown(self) -> None:
+ self.stop()
+ self.dispatcher.shutdown()
+
+ def url(self) -> str:
+ settings = self.settings()
+ host = "127.0.0.1" if self._clean_mode(str(settings.get("remote_mode"))) == REMOTE_MODE_TAILSCALE else str(settings["bind_address"])
+ return f"http://{self._url_host(host)}:{int(settings['port'])}/api/status"
+
+ def local_test_url(self) -> str:
+ settings = self.settings()
+ host = "127.0.0.1" if self._clean_mode(str(settings.get("remote_mode"))) == REMOTE_MODE_TAILSCALE else str(settings["bind_address"])
+ if host in {"0.0.0.0", "::"}:
+ host = "127.0.0.1"
+ query = urlencode({"token": str(settings["token"])})
+ return f"http://{self._url_host(host)}:{int(settings['port'])}/?{query}"
+
+ def phone_test_url(self) -> str:
+ settings = self.settings()
+ if self._clean_mode(str(settings.get("remote_mode"))) == REMOTE_MODE_TAILSCALE:
+ return self.tailscale_url()
+ host = str(settings["bind_address"])
+ scope = remote_scope(host)
+ if scope == "local-device only":
+ return ""
+ if host in {"0.0.0.0", "::"}:
+ host = local_network_host()
+ if not host:
+ return ""
+ query = urlencode({"token": str(settings["token"])})
+ return f"http://{self._url_host(host)}:{int(settings['port'])}/?{query}"
+
+ def tailscale_url(self) -> str:
+ status = inspect_tailscale()
+ if not status.installed or not status.connected:
+ return ""
+ host = status.dns_name or status.tailscale_ip
+ if not host:
+ return ""
+ settings = self.settings()
+ query = urlencode({"token": str(settings["token"])})
+ return f"https://{self._url_host(host)}/?{query}"
+
+ def tailscale_status(self) -> TailscaleStatus:
+ return inspect_tailscale()
+
+ def start_tailscale_serve(self) -> tuple[bool, str]:
+ status = inspect_tailscale()
+ if not status.installed:
+ return False, "Tailscale is not installed."
+ if not status.connected:
+ return False, "Tailscale is installed but not connected."
+ executable = shutil.which("tailscale")
+ if not executable:
+ return False, "Tailscale is not installed."
+ port = int(self.settings()["port"])
+ try:
+ result = _run_tailscale([executable, "serve", "--bg", str(port)])
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ return False, f"Tailscale Serve could not start: {exc}"
+ if result.returncode != 0:
+ return False, _command_text(result.stderr) or "Tailscale Serve could not start."
+ return True, "Tailscale Serve is forwarding private tailnet traffic to ADAM."
+
+ def stop_tailscale_serve(self) -> tuple[bool, str]:
+ executable = shutil.which("tailscale")
+ if not executable:
+ return False, "Tailscale is not installed."
+ try:
+ result = _run_tailscale([executable, "serve", "reset"])
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ return False, f"Tailscale Serve could not stop: {exc}"
+ if result.returncode != 0:
+ return False, _command_text(result.stderr) or "Tailscale Serve could not stop."
+ return True, "Tailscale Serve forwarding was reset."
+
+ @staticmethod
+ def _url_host(host: str) -> str:
+ value = host.strip() or "127.0.0.1"
+ if ":" in value and not value.startswith("["):
+ return f"[{value}]"
+ return value
+
+ @staticmethod
+ def _clean_mode(value: str) -> str:
+ mode = value.strip().casefold()
+ return mode if mode in {REMOTE_MODE_DISABLED, REMOTE_MODE_LOCAL, REMOTE_MODE_TAILSCALE} else REMOTE_MODE_LOCAL