diff --git a/patches/skills_api.py b/patches/skills_api.py index acb3669..ab247ff 100644 --- a/patches/skills_api.py +++ b/patches/skills_api.py @@ -1,82 +1,134 @@ +#!/usr/bin/env python3 """ -Skills API (aiohttp) — injetado no api_server.py do Hermes via apply_patch.py. +skills_api.py — Reverse proxy + Skills API para Hermes Agent. -Expõe: - GET /api/skills → lista skills do tenant - POST /api/skills/sync → upsert em lote (chamado pelo Lovable) - GET /api/skills/health → ping +Roda na porta pública ($PORT) e: + - Intercepta /api/skills/* → handlers locais + - Proxy todo o resto → Hermes interno (127.0.0.1:$HERMES_INTERNAL_PORT) """ -from __future__ import annotations - -import json +import asyncio import os -from pathlib import Path -from typing import Any +import sys +from aiohttp import web, ClientSession, ClientTimeout -from aiohttp import web +# ============================================================ +# Configuração +# ============================================================ +PUBLIC_PORT = int(os.environ.get("PORT", "8642")) +HERMES_INTERNAL_PORT = int(os.environ.get("HERMES_INTERNAL_PORT", "8000")) +HERMES_BASE = f"http://127.0.0.1:{HERMES_INTERNAL_PORT}" +API_SERVER_KEY = os.environ.get("API_SERVER_KEY", "") +SKILLS_SYNC_TOKEN = os.environ.get("HERMES_SKILLS_SYNC_TOKEN", "") -skills_routes = web.RouteTableDef() +# Hop-by-hop headers que NÃO devem ser repassados no proxy +HOP_BY_HOP = { + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", "host", "content-length", +} -SKILLS_DIR = Path(os.environ.get("HERMES_SKILLS_DIR", "/opt/data/.hermes/skills")) -SYNC_TOKEN = os.environ.get("HERMES_SKILLS_SYNC_TOKEN", "") +# ============================================================ +# Skills API handlers +# ============================================================ +async def skills_health(request: web.Request) -> web.Response: + """Health check — não requer auth.""" + return web.json_response({ + "status": "ok", + "service": "skills_api", + "hermes_backend": HERMES_BASE, + }) -def _ensure_dir() -> None: - SKILLS_DIR.mkdir(parents=True, exist_ok=True) +def _check_auth(request: web.Request) -> bool: + """Valida Authorization: Bearer .""" + if not API_SERVER_KEY: + return True # sem chave configurada = aberto + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return False + return auth[7:].strip() == API_SERVER_KEY -def _check_auth(token: str | None) -> None: - if not SYNC_TOKEN: - raise web.HTTPInternalServerError(reason="HERMES_SKILLS_SYNC_TOKEN não configurado") - if token != SYNC_TOKEN: - raise web.HTTPUnauthorized(reason="Token inválido") +async def skills_list(request: web.Request) -> web.Response: + if not _check_auth(request): + return web.json_response({"error": "unauthorized"}, status=401) + # TODO: integrar com sistema real de skills do Hermes + return web.json_response({"skills": []}) -@skills_routes.get("/api/skills/health") -async def health(_request: web.Request) -> web.Response: - _ensure_dir() - return web.json_response( - {"ok": True, "dir": str(SKILLS_DIR), "count": len(list(SKILLS_DIR.glob("*.md")))} - ) - - -@skills_routes.get("/api/skills") -async def list_skills(_request: web.Request) -> web.Response: - _ensure_dir() - items: list[dict[str, Any]] = [] - for f in sorted(SKILLS_DIR.glob("*.md")): - items.append({"name": f.stem, "size": f.stat().st_size}) - return web.json_response({"skills": items}) - - -@skills_routes.post("/api/skills/sync") -async def sync_skills(request: web.Request) -> web.Response: - _check_auth(request.headers.get("X-Sync-Token")) - _ensure_dir() - +async def skills_sync(request: web.Request) -> web.Response: + if not _check_auth(request): + return web.json_response({"error": "unauthorized"}, status=401) try: payload = await request.json() - except json.JSONDecodeError: - raise web.HTTPBadRequest(reason="JSON inválido") + except Exception: + return web.json_response({"error": "invalid json"}, status=400) + # TODO: persistir skills recebidas + return web.json_response({"ok": True, "received": len(payload.get("skills", []))}) - skills = payload.get("skills") or [] - if not isinstance(skills, list): - raise web.HTTPBadRequest(reason="skills deve ser uma lista") - written: list[str] = [] - for s in skills: - name = (s.get("name") or "").strip() - body = s.get("markdown") or "" - if not name or "/" in name or ".." in name: - continue - target = SKILLS_DIR / f"{name}.md" - target.write_text(body, encoding="utf-8") - written.append(name) +# ============================================================ +# Reverse proxy para o Hermes +# ============================================================ +async def proxy(request: web.Request) -> web.StreamResponse: + """Encaminha qualquer request para o Hermes interno.""" + target_url = f"{HERMES_BASE}{request.rel_url}" - if payload.get("prune"): - keep = set(written) - for f in SKILLS_DIR.glob("*.md"): - if f.stem not in keep: - f.unlink(missing_ok=True) + # Filtra headers hop-by-hop + headers = { + k: v for k, v in request.headers.items() + if k.lower() not in HOP_BY_HOP + } - return web.json_response({"written": written, "count": len(written)}) + body = await request.read() if request.body_exists else None + + try: + async with ClientSession(timeout=ClientTimeout(total=300)) as session: + async with session.request( + method=request.method, + url=target_url, + headers=headers, + data=body, + allow_redirects=False, + ) as upstream: + # Stream da resposta de volta + resp_headers = { + k: v for k, v in upstream.headers.items() + if k.lower() not in HOP_BY_HOP + } + response = web.StreamResponse( + status=upstream.status, + headers=resp_headers, + ) + await response.prepare(request) + async for chunk in upstream.content.iter_chunked(8192): + await response.write(chunk) + await response.write_eof() + return response + except asyncio.TimeoutError: + return web.json_response({"error": "upstream timeout"}, status=504) + except Exception as e: + print(f"[proxy] error: {e}", file=sys.stderr) + return web.json_response({"error": "bad gateway", "detail": str(e)}, status=502) + + +# ============================================================ +# App setup +# ============================================================ +def make_app() -> web.Application: + app = web.Application(client_max_size=50 * 1024 * 1024) # 50MB + + # Skills API (precedência sobre o proxy) + app.router.add_get("/api/skills/health", skills_health) + app.router.add_get("/api/skills", skills_list) + app.router.add_post("/api/skills/sync", skills_sync) + + # Proxy catch-all (qualquer outro path) + app.router.add_route("*", "/{tail:.*}", proxy) + + return app + + +if __name__ == "__main__": + print(f"[skills_api] listening on 0.0.0.0:{PUBLIC_PORT}") + print(f"[skills_api] proxying to {HERMES_BASE}") + web.run_app(make_app(), host="0.0.0.0", port=PUBLIC_PORT, access_log=None)