- Table agent_connectors (migration 32) : colonnes plates name, url, secret_encrypted, enabled, status, detail — config_json remis (YAGNI, les scopes OAuth des phases 6-7 ajouteront le leur). - app/services/connectors.py : 1 fichier au lieu du package connectors/ — 3 natifs (gitea, github, web) servis à la volée avec statut sans réseau, CRUD des personnels (URL validée par _validate_url = garde SSRF, clé chiffrée Fernet et jamais renvoyée — seul has_secret), probe() qui persiste status/detail, connector_fetch() borné à 20 000 car. - API /api/agent/connectors : GET, POST (400 URL privée), PATCH, DELETE, POST /connectors/probe — 401 sans session, CSRF global. OpenAPI 519 chemins. - Outil LLM connector_fetch (26e outil) : un seul outil qui dispatche vers Gitea/GitHub/web/personnalisé (id ou nom ou kind + path + query) au lieu d'un outil par connecteur ; désactivé/inconnu = erreur outil, jamais de run cassé. - Menu + : section « Connecteurs » (catalogue avec badge ✓/✗/⚠/?), fiche par connecteur (Tester, Activer/Désactiver, Supprimer — masqués pour les natifs), formulaire « Ajouter un connecteur personnalisé » (clé en type=password). - Tests : tests/test_v755_connectors.py (13) — 3 natifs, roundtrip Fernet de la clé, 5 URLs refusées (localhost, 127.0.0.1, metadata cloud, ftp:, file:), toggle persisté, 401, probe OK/erreur, outil complet, câblage menu ; test_v751/v753/v754 adaptés (1 seule section « bientôt » = plugins). Suite complète 1306 verts (-n auto), ruff 0, eslint 0 problème.
256 lines
11 KiB
Python
256 lines
11 KiB
Python
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
|
|
|
|
3 connecteurs **natifs** (gitea, github, web) servis à la volée depuis la config,
|
|
plus des connecteurs **personnalisation** persistés (URL + clé) en base.
|
|
|
|
ponytail : un seul fichier (pas de package ``connectors/`` ni de classe par
|
|
provider) — 3 probes dans un dict + 1 fetch. Les adapters lourds des phases 6-7
|
|
(Google, M365, Discord/Telegram, MCP) arriveront avec leur propre module.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from app.config import settings
|
|
from app.db import get_conn
|
|
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
NATIVE_KINDS = ("gitea", "github", "web")
|
|
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
|
|
|
|
|
|
# ── Natifs (config, sans réseau) ─────────────────────────────────────────────
|
|
|
|
def native_state(kind: str) -> tuple[str, str]:
|
|
"""(status, detail) d'un connecteur natif — dérivé de la config, sans réseau."""
|
|
if kind == "gitea":
|
|
ok = bool(settings.gitea_url) and settings.gitea_token not in ("", "change-me")
|
|
return ("ok", settings.gitea_url if ok else "GITEA_TOKEN non configuré")
|
|
if kind == "github":
|
|
if settings.github_token:
|
|
return ("ok", "PAT configuré (30 req/min)")
|
|
return ("missing", "aucun GITHUB_TOKEN (10 req/min anonyme)")
|
|
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
|
|
|
|
|
|
def list_connectors() -> list[dict]:
|
|
"""Catalogue : 3 natifs (toujours présents) + les connecteurs personnels."""
|
|
out: list[dict] = []
|
|
for kind in NATIVE_KINDS:
|
|
status, detail = native_state(kind)
|
|
out.append({
|
|
"id": None, "builtin": True, "kind": kind, "name": kind.capitalize(),
|
|
"url": "", "enabled": True, "status": status, "detail": detail,
|
|
"has_secret": False,
|
|
})
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, name, url, secret_encrypted, enabled, status, detail "
|
|
"FROM agent_connectors ORDER BY id"
|
|
).fetchall()
|
|
for r in rows:
|
|
out.append({
|
|
"id": r["id"], "builtin": False, "kind": "custom", "name": r["name"],
|
|
"url": r["url"], "enabled": bool(r["enabled"]), "status": r["status"],
|
|
"detail": r["detail"] or "", "has_secret": bool(r["secret_encrypted"]),
|
|
})
|
|
return out
|
|
|
|
|
|
def get(connector_id: int) -> dict | None:
|
|
with get_conn() as conn:
|
|
r = conn.execute(
|
|
"SELECT id, name, url, secret_encrypted, enabled, status, detail "
|
|
"FROM agent_connectors WHERE id=?", (connector_id,)
|
|
).fetchone()
|
|
if not r:
|
|
return None
|
|
return {"id": r["id"], "builtin": False, "kind": "custom", "name": r["name"],
|
|
"url": r["url"], "enabled": bool(r["enabled"]), "status": r["status"],
|
|
"detail": r["detail"] or "", "has_secret": bool(r["secret_encrypted"])}
|
|
|
|
|
|
# ── CRUD (personnels) ────────────────────────────────────────────────────────
|
|
|
|
def create_connector(name: str, url: str, secret: str = "") -> dict:
|
|
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet)."""
|
|
from app.services.importers.url_fetch import _validate_url
|
|
|
|
name = (name or "").strip()
|
|
if not name:
|
|
raise ValueError("name est requis")
|
|
url = _validate_url((url or "").strip()) # lève ValueError si hôte interne
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, detail) "
|
|
"VALUES (?,?,?,?,?)",
|
|
(name, url, encrypt_secret(secret or ""), "unknown", ""),
|
|
)
|
|
conn.commit()
|
|
cid = cur.lastrowid
|
|
return get(cid)
|
|
|
|
|
|
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None) -> dict | None:
|
|
row = get(connector_id)
|
|
if row is None:
|
|
return None
|
|
sets, params = [], []
|
|
if name is not None:
|
|
name = str(name).strip()
|
|
if not name:
|
|
raise ValueError("name est requis")
|
|
sets.append("name=?")
|
|
params.append(name)
|
|
if enabled is not None:
|
|
sets.append("enabled=?")
|
|
params.append(1 if enabled else 0)
|
|
if secret is not None:
|
|
sets.append("secret_encrypted=?")
|
|
params.append(encrypt_secret(str(secret)))
|
|
if sets:
|
|
with get_conn() as conn:
|
|
params.append(connector_id)
|
|
conn.execute(f"UPDATE agent_connectors SET {', '.join(sets)} WHERE id=?", params)
|
|
conn.commit()
|
|
return get(connector_id)
|
|
|
|
|
|
def delete_connector(connector_id: int) -> bool:
|
|
with get_conn() as conn:
|
|
cur = conn.execute("DELETE FROM agent_connectors WHERE id=?", (connector_id,))
|
|
conn.commit()
|
|
return cur.rowcount > 0
|
|
|
|
|
|
# ── Réseau (toujours gardé SSRF) ─────────────────────────────────────────────
|
|
|
|
def _headers(connector_id: int | None = None) -> dict:
|
|
"""En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée."""
|
|
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
|
|
secret = ""
|
|
if connector_id:
|
|
with get_conn() as conn:
|
|
r = conn.execute(
|
|
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
|
|
(connector_id,),
|
|
).fetchone()
|
|
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
|
|
if secret:
|
|
headers["Authorization"] = f"Bearer {secret}"
|
|
headers["X-Api-Key"] = secret
|
|
return headers
|
|
|
|
|
|
async def _get(url: str, headers: dict | None = None) -> tuple[int, str]:
|
|
"""GET gardé SSRF (validation + re-vérification après redirection).
|
|
|
|
Point d'injection des tests : on monkeypatche ``connectors._get``.
|
|
"""
|
|
from app.services.http_client import shared_client
|
|
from app.services.importers.url_fetch import _is_public_host, _validate_url
|
|
|
|
safe = _validate_url(url)
|
|
async with shared_client(timeout=10, follow_redirects=True, headers=headers or {}) as client:
|
|
resp = await client.get(safe)
|
|
if resp.url.host and not _is_public_host(resp.url.host):
|
|
raise ValueError("Redirection vers un hôte non autorisé")
|
|
return resp.status_code, (resp.text or "")[:FETCH_LIMIT]
|
|
|
|
|
|
def _resolve(connector: str) -> tuple[str, str | int]:
|
|
"""« 3 », « Ma clé API » ou « gitea » → (kind, id_custom|kind)."""
|
|
token = str(connector or "").strip()
|
|
if not token:
|
|
raise ValueError("connector est requis")
|
|
if token.isdigit():
|
|
row = get(int(token))
|
|
if row is None:
|
|
raise ValueError(f"Connecteur inconnu: {token}")
|
|
return ("custom", row["id"])
|
|
low = token.lower()
|
|
if low in NATIVE_KINDS:
|
|
return (low, low)
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT id FROM agent_connectors WHERE lower(name)=?", (low,)
|
|
).fetchone()
|
|
if row:
|
|
return ("custom", row["id"])
|
|
raise ValueError(f"Connecteur inconnu: {token}")
|
|
|
|
|
|
async def probe(connector: str) -> dict:
|
|
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
|
|
kind, ref = _resolve(connector)
|
|
try:
|
|
if kind == "gitea":
|
|
code, _ = await _get(f"{settings.gitea_url.rstrip('/')}/api/v1/version",
|
|
{"Authorization": f"token {settings.gitea_token}"})
|
|
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
|
elif kind == "github":
|
|
code, _ = await _get("https://api.github.com/rate_limit", _gh_headers())
|
|
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
|
elif kind == "web":
|
|
status, detail = "ok", native_state("web")[1]
|
|
else:
|
|
row = get(int(ref))
|
|
code, _ = await _get(row["url"], _headers(int(ref)))
|
|
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
|
except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI
|
|
logger.info("Connector probe failed (%s): %s", connector, exc)
|
|
status, detail = "error", str(exc)[:200]
|
|
if kind == "custom":
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE agent_connectors SET status=?, detail=? WHERE id=?",
|
|
(status, detail, int(ref)),
|
|
)
|
|
conn.commit()
|
|
return {"connector": connector, "status": status, "detail": detail}
|
|
|
|
|
|
def _gh_headers() -> dict:
|
|
headers = {"User-Agent": "FlowDeck-Connectors/1.0",
|
|
"Accept": "application/vnd.github+json"}
|
|
if settings.github_token:
|
|
headers["Authorization"] = f"Bearer {settings.github_token}"
|
|
return headers
|
|
|
|
|
|
async def connector_fetch(connector: str, path: str = "", query: str = "") -> dict:
|
|
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
|
|
kind, ref = _resolve(connector)
|
|
path = (path or "").strip()
|
|
if kind == "gitea":
|
|
base = settings.gitea_url.rstrip("/")
|
|
url = f"{base}/{path.lstrip('/')}" if path else f"{base}/api/v1/version"
|
|
code, text = await _get(url, {"Authorization": f"token {settings.gitea_token}"})
|
|
elif kind == "github":
|
|
url = "https://api.github.com/" + path.lstrip("/") if path else "https://api.github.com/rate_limit"
|
|
code, text = await _get(url, _gh_headers())
|
|
elif kind == "web":
|
|
from app.services.web_search import search_web
|
|
if not (query or "").strip():
|
|
return {"status": "error", "text": "query est requis pour le connecteur web"}
|
|
results, provider = await search_web(query.strip(), 5)
|
|
text = "\n".join(f"- {r.get('title', '')} — {r.get('url', '')}\n {r.get('snippet', '')}"
|
|
for r in results) or "Aucun résultat."
|
|
return {"status": "ok", "text": f"provider: {provider}\n{text}"[:FETCH_LIMIT]}
|
|
else:
|
|
row = get(int(ref))
|
|
if row is None:
|
|
return {"status": "error", "text": "Connecteur supprimé"}
|
|
if not row["enabled"]:
|
|
return {"status": "error", "text": "Connecteur désactivé"}
|
|
url = row["url"].rstrip("/")
|
|
if path:
|
|
url += "/" + path.lstrip("/")
|
|
code, text = await _get(url, _headers(int(ref)))
|
|
if code >= 400:
|
|
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
|
|
return {"status": "ok", "text": text[:FETCH_LIMIT]}
|