""" 🐙 OpenClaw Service — Status, Configuration, Agents, Skills, Logs & Models. Reads data from the OpenClaw home directory (FOXY_HOME) and exposes it through a set of REST endpoints for the dashboard frontend. """ import asyncio import json import logging import os import re import socket from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional from fastapi import APIRouter, Query from app.config import settings log = logging.getLogger("foxy.api.openclaw") router = APIRouter(prefix="/api/openclaw", tags=["openclaw"]) # Secrets / tokens to mask in config output _SECRET_PATTERNS = re.compile( r"(token|key|secret|password|apiKey|api_key)", re.IGNORECASE ) def _home() -> Path: """Resolve the OpenClaw home directory.""" return Path(settings.FOXY_HOME) def _mask_secrets(obj: Any, depth: int = 0) -> Any: """Recursively mask values whose keys look like secrets.""" if depth > 20: return obj if isinstance(obj, dict): masked = {} for k, v in obj.items(): if isinstance(v, str) and _SECRET_PATTERNS.search(k) and v: masked[k] = "••••••••" else: masked[k] = _mask_secrets(v, depth + 1) return masked if isinstance(obj, list): return [_mask_secrets(item, depth + 1) for item in obj] return obj def _port_open(port: int, host: str = "127.0.0.1", timeout: float = 1.0) -> bool: """Check if a TCP port is open.""" try: with socket.create_connection((host, port), timeout=timeout): return True except (OSError, ConnectionRefusedError, TimeoutError): return False def _read_json(path: Path) -> Optional[dict]: """Read and parse a JSON file, return None on failure.""" try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return None def _dir_tree(root: Path, max_depth: int = 3, _depth: int = 0) -> list[dict]: """Build a lightweight directory tree.""" entries: list[dict] = [] if not root.is_dir() or _depth > max_depth: return entries try: for child in sorted(root.iterdir()): name = child.name if name.startswith(".") and name not in (".openclaw",): continue if name in ("node_modules", "__pycache__", ".git"): continue entry: dict = {"name": name, "path": str(child.relative_to(_home()))} if child.is_dir(): entry["type"] = "directory" entry["children"] = _dir_tree(child, max_depth, _depth + 1) else: entry["type"] = "file" try: entry["size"] = child.stat().st_size except OSError: entry["size"] = 0 entries.append(entry) except PermissionError: pass return entries # ═════════════════════════════════════════════════════════════════════════════════ # 1. Status # ═════════════════════════════════════════════════════════════════════════════════ @router.get("/status") async def get_status(): """Gateway health, process info, mode.""" home = _home() openclaw_type = os.environ.get("OPENCLAW_TYPE", "shared") # Check gateway port gateway_up = await asyncio.to_thread(_port_open, 18789) # Try reading config for extra info config_data = await asyncio.to_thread(_read_json, home / "openclaw.json") gateway_cfg = {} if config_data: gateway_cfg = config_data.get("gateway", {}) # Count agents, skills agents_dir = home / "agents" skills_dir = home / "skills" agent_count = len(list(agents_dir.iterdir())) if agents_dir.is_dir() else 0 skill_count = len(list(skills_dir.iterdir())) if skills_dir.is_dir() else 0 # Gateway log existence log_file = home / "logs" / "gateway.log" log_size = 0 if log_file.is_file(): try: log_size = log_file.stat().st_size except OSError: pass return { "gateway_online": gateway_up, "openclaw_type": openclaw_type, "foxy_home": str(home), "gateway_bind": gateway_cfg.get("bind", "unknown"), "gateway_mode": gateway_cfg.get("mode", "unknown"), "gateway_port": 18789, "agent_count": agent_count, "skill_count": skill_count, "log_file_size": log_size, "config_exists": (home / "openclaw.json").is_file(), "timestamp": datetime.now(timezone.utc).isoformat(), } # ═════════════════════════════════════════════════════════════════════════════════ # 2. Configuration # ═════════════════════════════════════════════════════════════════════════════════ @router.get("/config") async def get_openclaw_config(): """Read openclaw.json with secrets masked.""" config_path = _home() / "openclaw.json" data = await asyncio.to_thread(_read_json, config_path) if data is None: return {"error": "openclaw.json not found or unreadable", "config": None} return {"config": _mask_secrets(data)} # ═════════════════════════════════════════════════════════════════════════════════ # 3. Agents # ═════════════════════════════════════════════════════════════════════════════════ @router.get("/agents") async def list_openclaw_agents(): """List agent definitions from the agents/ directory.""" agents_dir = _home() / "agents" if not agents_dir.is_dir(): return {"agents": [], "error": "agents/ directory not found"} agents = [] for entry in sorted(agents_dir.iterdir()): agent: dict[str, Any] = {"name": entry.name} if entry.is_dir(): # Directory-based agent — look for config files agent["type"] = "directory" config_files = list(entry.glob("*.json")) + list(entry.glob("*.yaml")) + list(entry.glob("*.yml")) agent["config_files"] = [f.name for f in config_files] # Try to read agent config for cfg_file in config_files: cfg = _read_json(cfg_file) if cfg_file.suffix == ".json" else None if cfg: agent["config"] = _mask_secrets(cfg) agent["model"] = cfg.get("model", cfg.get("modelId", None)) agent["system_prompt"] = cfg.get("systemPrompt", cfg.get("system_prompt", cfg.get("instructions", None))) if agent["system_prompt"] and len(agent["system_prompt"]) > 300: agent["system_prompt_preview"] = agent["system_prompt"][:300] + "…" break # Count files try: all_files = list(entry.rglob("*")) agent["file_count"] = len([f for f in all_files if f.is_file()]) except Exception: agent["file_count"] = 0 elif entry.is_file() and entry.suffix == ".json": # File-based agent definition agent["type"] = "file" cfg = _read_json(entry) if cfg: agent["config"] = _mask_secrets(cfg) agent["model"] = cfg.get("model", cfg.get("modelId", None)) agents.append(agent) return {"agents": agents, "count": len(agents)} # ═════════════════════════════════════════════════════════════════════════════════ # 4. Skills # ═════════════════════════════════════════════════════════════════════════════════ @router.get("/skills") async def list_openclaw_skills(): """List available skills from the skills/ directory.""" # Skills can be in multiple locations possible_dirs = [ _home() / "skills", _home() / "workspace" / "skills", ] skills = [] for skills_dir in possible_dirs: if not skills_dir.is_dir(): continue for entry in sorted(skills_dir.iterdir()): skill: dict[str, Any] = { "name": entry.name, "source": str(skills_dir.relative_to(_home())), } if entry.is_dir(): skill["type"] = "directory" # Look for SKILL.md or README.md for doc_name in ("SKILL.md", "README.md", "skill.md", "readme.md"): doc = entry / doc_name if doc.is_file(): try: content = doc.read_text(encoding="utf-8", errors="replace") # Extract description from YAML frontmatter or first lines skill["description"] = content[:500] # Try extracting YAML description if content.startswith("---"): end = content.find("---", 3) if end > 0: frontmatter = content[3:end].strip() for line in frontmatter.split("\n"): if line.startswith("description:"): skill["description_short"] = line.split(":", 1)[1].strip().strip("'\"") break except Exception: pass break # Count files try: all_files = list(entry.rglob("*")) skill["file_count"] = len([f for f in all_files if f.is_file()]) skill["subdirs"] = [d.name for d in entry.iterdir() if d.is_dir()] except Exception: skill["file_count"] = 0 elif entry.is_file(): skill["type"] = "file" try: skill["size"] = entry.stat().st_size except OSError: skill["size"] = 0 skills.append(skill) return {"skills": skills, "count": len(skills)} # ═════════════════════════════════════════════════════════════════════════════════ # 5. Logs # ═════════════════════════════════════════════════════════════════════════════════ @router.get("/logs") async def get_gateway_logs( lines: int = Query(200, ge=10, le=2000), level: Optional[str] = Query(None, description="Filter by log level: INFO, WARNING, ERROR"), ): """Read the last N lines of the gateway log.""" log_file = _home() / "logs" / "gateway.log" if not log_file.is_file(): # Try alternate locations alt = _home() / "gateway.log" if alt.is_file(): log_file = alt else: return {"lines": [], "error": "Gateway log file not found", "log_path": str(log_file)} try: content = await asyncio.to_thread( log_file.read_text, encoding="utf-8", errors="replace" ) all_lines = content.strip().split("\n") # Take last N lines result_lines = all_lines[-lines:] # Filter by level if requested if level: level_upper = level.upper() result_lines = [l for l in result_lines if level_upper in l.upper()] return { "lines": result_lines, "total_lines": len(all_lines), "showing": len(result_lines), "log_path": str(log_file), } except Exception as e: return {"lines": [], "error": str(e), "log_path": str(log_file)} # ═════════════════════════════════════════════════════════════════════════════════ # 6. Models & Providers # ═════════════════════════════════════════════════════════════════════════════════ @router.get("/models") async def list_models(): """Extract configured models and providers from openclaw.json.""" config_path = _home() / "openclaw.json" data = await asyncio.to_thread(_read_json, config_path) if data is None: return {"models": [], "providers": [], "error": "openclaw.json not found"} # Extract providers providers = [] providers_cfg = data.get("mcpServers", data.get("providers", data.get("llm", {}))) if isinstance(providers_cfg, dict): for name, cfg in providers_cfg.items(): provider: dict[str, Any] = { "name": name, "type": cfg.get("type", cfg.get("provider", "unknown")) if isinstance(cfg, dict) else "unknown", } if isinstance(cfg, dict): provider["enabled"] = cfg.get("enabled", True) provider["model"] = cfg.get("model", cfg.get("modelId", None)) provider["endpoint"] = cfg.get("endpoint", cfg.get("baseUrl", cfg.get("url", None))) providers.append(provider) # Extract models list if present models = [] models_cfg = data.get("models", []) if isinstance(models_cfg, list): for m in models_cfg: if isinstance(m, dict): models.append(_mask_secrets(m)) elif isinstance(m, str): models.append({"name": m}) elif isinstance(models_cfg, dict): for name, cfg in models_cfg.items(): model_entry = {"name": name} if isinstance(cfg, dict): model_entry.update(_mask_secrets(cfg)) models.append(model_entry) return { "models": models, "providers": _mask_secrets(providers), "raw_keys": list(data.keys()), } # ═════════════════════════════════════════════════════════════════════════════════ # 7. Filesystem Tree # ═════════════════════════════════════════════════════════════════════════════════ @router.get("/filesystem") async def get_filesystem(): """Get a directory tree of the OpenClaw home directory.""" home = _home() if not home.is_dir(): return {"tree": [], "error": f"FOXY_HOME ({home}) does not exist"} tree = await asyncio.to_thread(_dir_tree, home) return {"root": str(home), "tree": tree}