From 18c72d77fe1e4c147d48a799717c4d42a8c16a48 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 6 Oct 2026 23:26:19 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20menu=20=EF=BC=8B=20=E2=80=94=20Connecte?= =?UTF-8?q?urs=20(socle)=20=E2=80=94=20phase=205/8=20(v7.55.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- CHANGELOG.md | 41 ++++++ ROADMAP.md | 46 ++++-- VERSION | 2 +- app/main.py | 2 +- app/migrations.py | 21 +++ app/routers/agent.py | 64 ++++++++- app/services/connectors.py | 255 +++++++++++++++++++++++++++++++++ app/services/tool_registry.py | 44 +++++- app/templates/agent_panel.html | 12 ++ docs/V74_Agent_Plus_Menu.md | 4 +- docs/openapi-v2.json | 191 +++++++++++++++++++++++- static/js/agent_panel_2.js | 139 +++++++++++++++++- tests/test_v751_plus_menu.py | 4 +- tests/test_v753_canvases.py | 4 +- tests/test_v754_memory.py | 4 +- tests/test_v755_connectors.py | 170 ++++++++++++++++++++++ 16 files changed, 974 insertions(+), 29 deletions(-) create mode 100644 app/services/connectors.py create mode 100644 tests/test_v755_connectors.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a74ac5..6ad6cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog - FlowDeck +## v7.55.0 (2026-10-06) — Menu + : Connecteurs — socle (phase 5/8) + +### Added + +- **Table `agent_connectors`** (migration **32**) : `name`, `url`, + `secret_encrypted`, `enabled`, `status`, `detail` — colonnes **plates**, pas de + `config_json` (les scopes OAuth des phases 6-7 ajouteront le leur). +- **`app/services/connectors.py`** — 1 fichier (pas de package) : + - **3 connecteurs natifs** (`gitea`, `github`, `web`) servis à la volée, statut + dérivé de la config **sans réseau** (`native_state()`) ; + - CRUD des connecteurs personnalisés : l'URL passe par **`_validate_url` + (garde SSRF)** à la création, la clé est **chiffrée Fernet** (`encrypt_secret`) + et **jamais renvoyée** (seul `has_secret` l'est) ; + - `probe()` : appel de contrôle (`_get`, timeout 10 s, re-vérification de + l'hôte après redirection) qui **persiste** `status`/`detail` ; + - `connector_fetch()` : lecture pour l'LLM — API Gitea/GitHub (native), + recherche web (native) ou GET du connecteur personnalisé, borné à 20 000 car. +- **API** (`/api/agent/connectors`) — `GET` (catalogue), `POST` (création, 400 sur + URL privée), `PATCH` (name/enabled/secret), `DELETE`, `POST /connectors/probe` + ; session requise partout (401), CSRF global. **OpenAPI : 519 chemins**. +- **Outil LLM `connector_fetch`** (`tool_registry`, 26ᵉ outil) — args + `connector` (id / nom / kind), `path`, `query` ; connecteur désactivé ou + inconnu = erreur outil, **jamais une exception qui casse le run**. +- **Menu + : section « Connecteurs »** — catalogue avec badge de statut + (✓/✗/⚠/?), fiche par connecteur (Tester, Activer/Désactiver, Supprimer — les + 2 derniers masqués pour les natifs) et formulaire « Ajouter un connecteur + personnalisé » (nom, URL, clé en `type="password"`). + +### Tests + +- `tests/test_v755_connectors.py` — **13 tests** : 3 natifs listés, clé jamais + renvoyée + chiffrée en base (roundtrip Fernet), **5 URLs refusées** (localhost, + 127.0.0.1, metadata cloud, ftp:, file:), toggle persisté + 404, 401 sans + session, probe OK (clé bien envoyée) / probe erreur persistée, outil + `connector_fetch` (schéma, succès, désactivé, inconnu), câblage du menu. +- `test_v751`/`test_v753`/`test_v754` adaptés : **1 seule section « bientôt »** + (plugins). +- **Suite complète : 1306 passed / 0 failed** (`-n auto`), `ruff` 0, + `eslint` 0 problème. + + ## v7.54.0 (2026-10-06) — Menu + : Mémoire de l'agent (phase 4/8) ### Added diff --git a/ROADMAP.md b/ROADMAP.md index ea9a19c..9ebbd7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -284,7 +284,7 @@ Propriétés custom, AI keywords, sync API, 12 tables DB --- -## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-4 ✅ 2026-10-06 · phases 5-8 planifiées) +## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-5 ✅ 2026-10-06 · phases 6-8 planifiées) > **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition) > un menu à sections, tel que demandé : @@ -416,19 +416,35 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j. - [x] **Livraison** — `VERSION` + `app/main.py` = **7.54.0**, OpenAPI régénéré (516 chemins), CHANGELOG, ce ROADMAP -### Phase 5 — v7.55.0 — Connecteurs : socle · effort L +### Phase 5 — v7.55.0 — Connecteurs : socle · effort L ✅ (livrée 2026-10-06) -- [ ] **Table `agent_connectors`** (`kind`, `name`, `config`, credentials chiffrés, - `enabled`, `status`) -- [ ] **`app/services/connectors/`** — interface commune `probe() / list_sources() / fetch()` - + 3 adapters **natifs déjà présents** : Gitea, GitHub, Web (`web_search` / `fetch_url`, v7.46) -- [ ] **Menu « Connecteurs »** — « Parcourir les connecteurs » (catalogue + statut - connecté/déconnecté) et « Ajouter un connecteur personnalisé » (URL + clé + type - HTTP / MCP) -- [ ] **Tools agent** — un tool par connecteur exposé au LLM via `tool_registry` -- [ ] **Sécurité** — garde SSRF `_is_public_host` sur toute URL, credentials chiffrés, - session requise, CSRF sur les écritures -- [ ] **Tests** — CRUD, statut, vecteurs SSRF, schéma des tools +- [x] **Table `agent_connectors`** (migration **32**) — colonnes **plates** + (`name`, `url`, `secret_encrypted`, `enabled`, `status`, `detail`) ; le + `config_json` prévu est **remis** (YAGNI : rien ne le remplissait — les scopes + OAuth des phases 6-7 ajouteront le leur) +- [x] **`app/services/connectors.py`** — **1 fichier au lieu du package + `connectors/`** promis (3 probes + 1 fetch ne justifient pas une arborescence) ; + 3 natifs `gitea`/`github`/`web` servis à la volée, statut **sans réseau** + (`native_state()`) ; `probe()` et `connector_fetch()` passent par `_get()` + (gardé SSRF + re-vérification après redirection, timeout 10 s, borne 20 000 car.) +- [x] **Menu « Connecteurs »** — « Parcourir » = catalogue avec badge + (✓ ok / ✗ error / ⚠ missing / ? inconnu) + fiche (Tester, Activer/Désactiver, + Supprimer — masqués pour les natifs) ; « Ajouter un connecteur personnalisé » = + formulaire nom + URL + clé (`type="password"`) +- [x] **Tool agent** — **un seul `connector_fetch`** (id/nom/kind + `path` + + `query`) au lieu d'un outil par connecteur : même capacité, moins de bruit de + schéma — l'outil dispatche vers Gitea/GitHub/web/personnalisé. Connecteur + désactivé ou inconnu = erreur outil, jamais une exception de run +- [x] **Sécurité** — `_validate_url` (SSRF) à la création **et** à chaque appel, + clé **chiffrée Fernet** et jamais renvoyée (`has_secret` seulement), 401 sans + session sur les 5 routes, CSRF global +- [x] **Tests** — `tests/test_v755_connectors.py` : **13 tests** (3 natifs, clé + chiffrée roundtrip, **5 URLs refusées** : localhost / 127.0.0.1 / metadata + cloud / ftp: / file:, toggle persisté, 401, probe OK + probe erreur persistée, + outil complet, câblage menu) ; 3 autres fichiers de phase adaptés (1 section + « bientôt » restante) ; **suite complète 1306 verts**, `ruff` 0, `eslint` 0 +- [x] **Livraison** — `VERSION` + `app/main.py` = **7.55.0**, OpenAPI régénéré + (519 chemins), CHANGELOG, ce ROADMAP ### Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L @@ -456,8 +472,8 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j. --- -*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 4 livrées le -2026-10-06 (v7.51.0 → v7.54.0)**, phases 5-8 à valider une par une avant « go ». +*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 5 livrées le +2026-10-06 (v7.51.0 → v7.55.0)**, phases 6-8 à valider une par une avant « go ». Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.* --- diff --git a/VERSION b/VERSION index ccb032e..3c08909 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.54.0 +7.55.0 diff --git a/app/main.py b/app/main.py index b51a843..2827062 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.54.0", + version="7.55.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/migrations.py b/app/migrations.py index 19a24fd..8d0b475 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -1586,6 +1586,27 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None: ) +@register(32, "v7.55.0: connecteurs de l'agent (socle)") +def _migration_agent_connectors(conn: sqlite3.Connection) -> None: + """Catalogue de connecteurs : 3 natifs servis à la volée + personnels ici. + + Colonne ``url`` plate (pas de ``config_json``) : les scopes/OAuth des + phases 6-7 ajouteront leur propre colonne le moment venu. + """ + conn.execute( + """CREATE TABLE IF NOT EXISTS agent_connectors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL, + secret_encrypted TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'unknown', + detail TEXT NOT NULL DEFAULT '', + created_by INTEGER + )""" + ) + + @register(31, "v7.54.0: agent memory (mémoire de l'agent)") def _migration_agent_memory(conn: sqlite3.Connection) -> None: """Toggle par conversation + table des résumés mémorisés. diff --git a/app/routers/agent.py b/app/routers/agent.py index 76dc90f..dff5acc 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -15,7 +15,7 @@ from fastapi.responses import StreamingResponse from app.auth.session import get_current_user from app.config import settings from app.db import get_conn -from app.services import skill_gallery +from app.services import connectors, skill_gallery from app.services.agent_engine import AgentEngine, undo_action from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient from app.services.llm_config import ( @@ -633,6 +633,68 @@ def delete_skill(request: Request, skill_id: int): return {"id": skill_id, "status": "deleted"} +# ── Connecteurs (v7.55.0) ── +# ponytail : catalogue partagé au même titre que les skills (`list_skills` +# n'a pas non plus de filtre par créateur) — la clé n'est jamais renvoyée. + + +@router.get("/connectors") +def list_connectors_route(request: Request): + """Catalogue : 3 natifs (statut dérivé de la config) + personnels.""" + _current_user_id(request) + return {"connectors": connectors.list_connectors()} + + +@router.post("/connectors") +def create_connectors_route(request: Request, body: dict = Body(default={})): + """Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF).""" + _current_user_id(request) + try: + return connectors.create_connector( + body.get("name") or "", body.get("url") or "", str(body.get("secret") or "") + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.patch("/connectors/{connector_id}") +def update_connectors_route(request: Request, connector_id: int, body: dict = Body(default={})): + _current_user_id(request) + try: + row = connectors.update_connector( + connector_id, + name=body.get("name"), + enabled=body.get("enabled"), + secret=body.get("secret"), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if row is None: + raise HTTPException(status_code=404, detail="Connecteur introuvable") + return row + + +@router.delete("/connectors/{connector_id}") +def delete_connectors_route(request: Request, connector_id: int): + _current_user_id(request) + if not connectors.delete_connector(connector_id): + raise HTTPException(status_code=404, detail="Connecteur introuvable") + return {"id": connector_id, "status": "deleted"} + + +@router.post("/connectors/probe") +async def probe_connectors_route(request: Request, body: dict = Body(default={})): + """Teste un connecteur (id, nom ou kind natif) et persiste le résultat.""" + _current_user_id(request) + target = str(body.get("connector") or "").strip() + if not target: + raise HTTPException(status_code=400, detail="connector est requis") + try: + return await connectors.probe(target) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + # ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ── diff --git a/app/services/connectors.py b/app/services/connectors.py new file mode 100644 index 0000000..ab30c20 --- /dev/null +++ b/app/services/connectors.py @@ -0,0 +1,255 @@ +"""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]} diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py index 756f838..55a1a0c 100644 --- a/app/services/tool_registry.py +++ b/app/services/tool_registry.py @@ -1072,6 +1072,48 @@ class SearchCode(Tool): # ══════════════════════════ Registry ══════════════════════════ +class ConnectorFetch(Tool): + name = "connector_fetch" + description = ( + "Lit un connecteur configuré dans FlowDeck : API Gitea / GitHub (natives), " + "recherche web (natif « web »), ou un connecteur personnalisé (URL + clé). " + "`path` est relatif à l'URL du connecteur (ex. « /api/v1/repos ») ; " + "`query` sert au connecteur web. Les hôtes privés sont refusés." + ) + parameters = { + "type": "object", + "properties": { + "connector": {"type": "string", + "description": "id, nom ou kind (gitea / github / web)"}, + "path": {"type": "string", "description": "chemin relatif (API natives)"}, + "query": {"type": "string", "description": "terme de recherche (web)"}, + }, + "required": ["connector"], + } + + async def execute(self, args, *, user_id=None) -> ToolResult: + from app.services import connectors + + try: + res = await connectors.connector_fetch( + str(args.get("connector") or ""), + str(args.get("path") or ""), + str(args.get("query") or ""), + ) + except ValueError as exc: + return ToolResult(status="error", tool=self.name, message=str(exc)) + except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run + logger.warning("connector_fetch failed: %s", exc) + return ToolResult(status="error", tool=self.name, + message=f"Connecteur indisponible: {exc}") + if res.get("status") != "ok": + return ToolResult(status="error", tool=self.name, + message=(res.get("text") or "erreur")[:500]) + text = res.get("text") or "" + return ToolResult(status="success", tool=self.name, target_type="connector", + message=text[:800], data={"text": text}) + + TOOL_CLASSES = [ SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument, CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument, @@ -1080,7 +1122,7 @@ TOOL_CLASSES = [ DeletePage, DeleteCollection, ReadGiteaIssues, SyncGitea, CreateGiteaIssue, DeleteDocument, - WebSearch, FetchUrl, SearchCode, + WebSearch, FetchUrl, SearchCode, ConnectorFetch, ] diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index d431295..b07c68f 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -444,6 +444,18 @@ body.fd-ap-resizing *{cursor:col-resize!important} +
+ + + + + + +
+ + +
+
diff --git a/docs/V74_Agent_Plus_Menu.md b/docs/V74_Agent_Plus_Menu.md index 53bd8eb..40ac95b 100644 --- a/docs/V74_Agent_Plus_Menu.md +++ b/docs/V74_Agent_Plus_Menu.md @@ -1,7 +1,7 @@ # V74 — Menu + de l'assistant : hub de contexte (design) -> **Statut** : design — **phases 1 à 4 livrées (v7.51.0 → v7.54.0, 2026-10-06)** ; -> phases 5-8 restent à livrer. La **mémoire** a été simplifiée par rapport au +> **Statut** : design — **phases 1 à 5 livrées (v7.51.0 → v7.55.0, 2026-10-06)** ; +> phases 6-8 restent à livrer. La **mémoire** a été simplifiée par rapport au > modèle ici : une seule ligne résumé **par conversation** (pas de lignes > `workspace_id NULL` — rien ne les écrivait), extraction déterministe sans LLM. Adaptations faites en cours de route : le parcours > utilise `GET /api/nav/menu?parent_id=` (un niveau par appel, contrat diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 7dca7fc..3294b10 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.54.0" + "version": "7.55.0" }, "paths": { "/auth/register": { @@ -16240,6 +16240,195 @@ } } }, + "/api/agent/connectors": { + "get": { + "tags": [ + "agent" + ], + "summary": "List Connectors Route", + "description": "Catalogue : 3 natifs (statut dérivé de la config) + personnels.", + "operationId": "list_connectors_route_api_agent_connectors_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "agent" + ], + "summary": "Create Connectors Route", + "description": "Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF).", + "operationId": "create_connectors_route_api_agent_connectors_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/agent/connectors/{connector_id}": { + "patch": { + "tags": [ + "agent" + ], + "summary": "Update Connectors Route", + "operationId": "update_connectors_route_api_agent_connectors__connector_id__patch", + "parameters": [ + { + "name": "connector_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Connector Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "agent" + ], + "summary": "Delete Connectors Route", + "operationId": "delete_connectors_route_api_agent_connectors__connector_id__delete", + "parameters": [ + { + "name": "connector_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Connector Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/agent/connectors/probe": { + "post": { + "tags": [ + "agent" + ], + "summary": "Probe Connectors Route", + "description": "Teste un connecteur (id, nom ou kind natif) et persiste le résultat.", + "operationId": "probe_connectors_route_api_agent_connectors_probe_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/agent/mentions": { "get": { "tags": [ diff --git a/static/js/agent_panel_2.js b/static/js/agent_panel_2.js index 1e09302..2527eda 100644 --- a/static/js/agent_panel_2.js +++ b/static/js/agent_panel_2.js @@ -40,7 +40,7 @@ {key:'skills', icon:'✨', label:'Compétences-skills', sub:'Deep research, Skill-creator… · Gérer · Parcourir', action:'skills', chev:true}, {key:'connectors', icon:'🔌', label:'Connecteurs', - sub:'Parcourir / connecteur personnalisé — bientôt (phase 5)', disabled:true}, + sub:'Parcourir les connecteurs · ajouter un personnalisé', action:'connectors', chev:true}, {key:'canvases', icon:'🎨', label:'Design System – Canevas', sub:'Galerie de canevas : créer, insérer, enregistrer', action:'canvases', chev:true}, {key:'plugins', icon:'🧩', label:'Add plugins', @@ -74,6 +74,8 @@ skillForm: {id: null, name: '', description: '', prompt_template: ''}, galleryFilter: '', canvases: [], canvas: null, + connectors: [], connector: null, + connectorForm: {name: '', url: '', secret: ''}, cmdFocus: -1, slashDismiss: false, toastMsg: '', toastErr: false, _toastTimer: null, _livePh: null, @@ -1128,6 +1130,9 @@ if(s === 'skills-manage') return 'Gérer les compétences'; if(s === 'skills-gallery') return 'Parcourir les compétences'; if(s === 'skills-edit') return this.skillForm.id ? 'Modifier le skill' : 'Nouveau skill'; + if(s === 'connectors') return 'Connecteurs'; + if(s === 'connector') return this.connector ? this.connector.name : 'Connecteur'; + if(s === 'connector-new') return 'Ajouter un connecteur'; if(s === 'canvases') return 'Design System – Canevas'; if(s === 'canvas') return this.canvas ? this.canvas.name : 'Canevas'; return 'Ajouter au contexte'; @@ -1140,6 +1145,9 @@ if(s === 'skills-manage') return this._manageItems(); if(s === 'skills-gallery') return this._galleryItems(); if(s === 'skills-edit') return []; // formulaire, pas de liste + if(s === 'connectors') return this._connectorItems(); + if(s === 'connector') return this._connectorActionItems(); + if(s === 'connector-new') return []; if(s === 'canvases') return this._canvasItems(); if(s === 'canvas') return this._canvasActionItems(); if(s === 'root'){ @@ -1230,6 +1238,118 @@ if(!out.length) out.push({key:'g-none', _sep: f ? 'Aucun résultat' : 'Galerie vide'}); return out; }, + // ── Connecteurs (v7.55.0) ── + fetchConnectors(){ + var self = this; + fetch('/api/agent/connectors').then(function(r){ return r.json(); }) + .then(function(d){ + self.connectors = d.connectors || []; + // garde la fiche ouverte alignée sur la liste rafraîchie + var sel = self.connector; + if(sel){ + var found = null; + self.connectors.forEach(function(c){ + if((sel.id != null && c.id === sel.id) || (sel.id == null && c.kind === sel.kind)) found = c; + }); + self.connector = found; + } + }).catch(function(){ self.connectors = []; }); + }, + _connectorStatus(c){ + var mark = c.status === 'ok' ? '✓ ' : (c.status === 'error' ? '✗ ' + : (c.status === 'missing' ? '⚠ ' : '? ')); + return mark + (c.detail || c.status) + (c.enabled ? '' : ' · désactivé'); + }, + _connectorItems(){ + var self = this; + var out = (this.connectors || []).map(function(c){ + return {key:'cn-' + (c.id != null ? c.id : c.kind), icon: c.builtin ? '🌐' : '🔌', + label: c.name, sub: self._connectorStatus(c), + action:'connector', connector:c, chev:true}; + }); + if(!out.length) out.push({key:'cn-none', _sep:'Aucun connecteur'}); + out.push({key:'cn-sep', _sep:'Ajout'}); + out.push({key:'cn-new', icon:'+', label:'Ajouter un connecteur personnalisé', + sub:'URL publique + clé API', action:'connector-new', chev:true}); + return out; + }, + _connectorActionItems(){ + var c = this.connector; + if(!c) return []; + var items = [ + {key:'cn-meta', _sep: (c.builtin ? 'Connecteur natif · ' : 'Connecteur · ') + c.name}, + {key:'cn-probe', icon:'↻', label:'Tester le connecteur', + sub:'Appel de contrôle — status + détail', action:'connector-probe'} + ]; + if(!c.builtin){ + items.push({key:'cn-toggle', icon: c.enabled ? '⏸' : '▶', + label: c.enabled ? 'Désactiver' : 'Activer', + sub:'Désactivé = lecture refusée par l\u2019outil', action:'connector-toggle'}); + items.push({key:'cn-del', icon:'🗑️', label:'Supprimer le connecteur', + sub:'Supprime aussi sa clé', action:'connector-delete'}); + } + return items; + }, + _connectorRef(c){ + c = c || this.connector; + return c ? (c.id != null ? String(c.id) : c.kind) : ''; + }, + connectorProbe(){ + var self = this; + fetch('/api/agent/connectors/probe', { + method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, + body: JSON.stringify({connector: this._connectorRef()}) + }).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); }) + .then(function(res){ + if(!res.ok){ self.toast((res.d && res.d.detail) || 'Test impossible.', true); return; } + self.toast('Connecteur : ' + res.d.status + ' — ' + (res.d.detail || '')); + self.fetchConnectors(); + }).catch(function(){ self.toast('Test impossible (réseau).', true); }); + }, + connectorToggle(){ + var self = this, c = this.connector; + if(!c || c.builtin || c.id == null) return; + fetch('/api/agent/connectors/' + c.id, { + method:'PATCH', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, + body: JSON.stringify({enabled: !c.enabled}) + }).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); }) + .then(function(res){ + if(!res.ok){ self.toast('Bascule impossible.', true); return; } + self.toast(res.d.enabled ? 'Connecteur activé.' : 'Connecteur désactivé.'); + self.fetchConnectors(); + }).catch(function(){ self.toast('Bascule impossible (réseau).', true); }); + }, + connectorDelete(){ + var self = this, c = this.connector; + if(!c || c.builtin || c.id == null) return; + if(!window.confirm('Supprimer le connecteur « ' + c.name + ' » ?')) return; + fetch('/api/agent/connectors/' + c.id, {method:'DELETE', headers:{'X-CSRF-Token': getCsrf()}}) + .then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); }) + .then(function(res){ + if(!res.ok){ self.toast('Suppression impossible.', true); return; } + self.toast('Connecteur supprimé.'); + self.connector = null; + self.plusSection = 'connectors'; self.plusFocus = 0; + self.fetchConnectors(); + }).catch(function(){ self.toast('Suppression impossible (réseau).', true); }); + }, + connectorCreate(){ + var self = this, f = this.connectorForm; + if(!(f.name || '').trim() || !(f.url || '').trim()){ + this.toast('Nom et URL requis.', true); return; + } + fetch('/api/agent/connectors', { + method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, + body: JSON.stringify({name: f.name, url: f.url, secret: f.secret || ''}) + }).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); }) + .then(function(res){ + if(!res.ok){ self.toast((res.d && res.d.detail) || 'Création impossible.', true); return; } + self.toast('Connecteur « ' + res.d.name + ' » ajouté.'); + self.connectorForm = {name:'', url:'', secret:''}; + self.plusSection = 'connectors'; self.plusFocus = 0; + self.fetchConnectors(); + }).catch(function(){ self.toast('Création impossible (réseau).', true); }); + }, // ── Design System – Canevas (v7.53.0) ── fetchCanvases(){ var self = this; @@ -1386,6 +1506,20 @@ if(this.$refs.skillImport) this.$refs.skillImport.click(); return; } + // ── Connecteurs (v7.55.0) ── + if(it.action === 'connectors'){ + this.fetchConnectors(); this.plusSection = 'connectors'; this.plusFocus = 0; return; + } + if(it.action === 'connector'){ + this.connector = it.connector; this.plusSection = 'connector'; this.plusFocus = 0; return; + } + if(it.action === 'connector-new'){ + this.connectorForm = {name:'', url:'', secret:''}; + this.plusSection = 'connector-new'; this.plusFocus = -1; return; + } + if(it.action === 'connector-probe'){ this.connectorProbe(); return; } + if(it.action === 'connector-toggle'){ this.connectorToggle(); return; } + if(it.action === 'connector-delete'){ this.connectorDelete(); return; } // ── Design System – Canevas (v7.53.0) ── if(it.action === 'canvases'){ this.fetchCanvases(); this.plusSection = 'canvases'; this.plusFocus = 0; return; @@ -1416,6 +1550,9 @@ this.plusSection = 'skills'; this.plusFocus = 0; return; } if(sec === 'canvas'){ this.plusSection = 'canvases'; this.plusFocus = 0; return; } + if(sec === 'connector' || sec === 'connector-new'){ + this.plusSection = 'connectors'; this.plusFocus = 0; return; + } this.plusSection = 'root'; this.plusFocus = 0; }, diff --git a/tests/test_v751_plus_menu.py b/tests/test_v751_plus_menu.py index acc1a89..de6ba45 100644 --- a/tests/test_v751_plus_menu.py +++ b/tests/test_v751_plus_menu.py @@ -104,8 +104,8 @@ def test_plus_menu_sections_and_disabled_states(): for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"): assert f"key:{key}" in root, key assert "action:'files'" in root and "action:'skills'" in root - # phases 1-4 livrées → il reste 2 sections « bientôt » (connecteurs, plugins) - assert root.count("disabled:true") == 2 + # phases 1-5 livrées → il reste 1 section « bientôt » (plugins) + assert root.count("disabled:true") == 1 files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0] assert "action:'search'" in files and "action:'browse'" in files diff --git a/tests/test_v753_canvases.py b/tests/test_v753_canvases.py index d4b20e9..973dba1 100644 --- a/tests/test_v753_canvases.py +++ b/tests/test_v753_canvases.py @@ -64,8 +64,8 @@ def test_canvases_menu_wired(client): src = JS_PATH.read_text(encoding="utf-8") root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0] assert "action:'canvases'" in root - # phases 1-4 livrées → 2 sections « bientôt » restantes (connecteurs, plugins) - assert root.count("disabled:true") == 2 + # phases 1-5 livrées → 1 section « bientôt » restante (plugins) + assert root.count("disabled:true") == 1 for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"): assert action in src, action # piège /use : la clé builtin part DANS LE BODY, pas en query string diff --git a/tests/test_v754_memory.py b/tests/test_v754_memory.py index 37c4b42..97e737b 100644 --- a/tests/test_v754_memory.py +++ b/tests/test_v754_memory.py @@ -129,8 +129,8 @@ def test_memory_menu_toggle_wired(): root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0] assert "action:'memory'" in root assert "bientôt (phase 4)" not in root - # phases 1-4 livrées → connecteurs + plugins restent désactivés - assert root.count("disabled:true") == 2 + # phases 1-5 livrées → seul Plugins reste désactivé + assert root.count("disabled:true") == 1 assert "toggleMemory()" in src assert "memory_enabled: next" in src engine = ENGINE.read_text(encoding="utf-8") diff --git a/tests/test_v755_connectors.py b/tests/test_v755_connectors.py new file mode 100644 index 0000000..623e93c --- /dev/null +++ b/tests/test_v755_connectors.py @@ -0,0 +1,170 @@ +"""v7.55.0 — Connecteurs de l'agent : catalogue, CRUD, SSRF, statut, outil LLM.""" + +import asyncio +from pathlib import Path + +import pytest +from conftest import anon_csrf + +from app.db import get_conn +from app.services import connectors +from app.services.sso_provisioning import decrypt_secret + +ROOT = Path(__file__).resolve().parent.parent +JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js" +PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html" + +PUBLIC_URL = "https://example.com/api" + + +def _create(client, **kw): + body = {"name": "Conn perso", "url": PUBLIC_URL, "secret": "sk-live-abc", **kw} + r = client.post("/api/agent/connectors", json=body) + assert r.status_code == 200, r.text + return r.json() + + +def test_native_connectors_always_listed(client): + rows = client.get("/api/agent/connectors").json()["connectors"] + native = {r["kind"]: r for r in rows if r["builtin"]} + assert set(native) == {"gitea", "github", "web"} + for r in native.values(): + assert r["id"] is None + assert r["status"] in ("ok", "missing") + assert r["enabled"] is True + assert native["web"]["status"] == "ok" + + +def test_create_custom_connector_never_returns_secret(client): + row = _create(client) + assert row["kind"] == "custom" and row["builtin"] is False + assert row["has_secret"] is True + assert "sk-live-abc" not in str(row) # jamais en clair dans la réponse + with get_conn() as conn: + stored = conn.execute( + "SELECT secret_encrypted FROM agent_connectors WHERE id=?", (row["id"],) + ).fetchone()["secret_encrypted"] + assert stored and "sk-live-abc" not in stored # chiffré (Fernet) + assert decrypt_secret(stored) == "sk-live-abc" + + +@pytest.mark.parametrize("bad", [ + "http://localhost/secret", + "http://127.0.0.1:8080/admin", + "http://169.254.169.254/latest/meta-data/", + "ftp://exemple.com/api", + "file:///etc/passwd", +]) +def test_create_rejects_non_public_urls(client, bad): + r = client.post("/api/agent/connectors", json={"name": "interne", "url": bad}) + assert r.status_code == 400, r.text + + +def test_patch_toggle_and_delete(client): + row = _create(client) + cid = row["id"] + off = client.patch(f"/api/agent/connectors/{cid}", json={"enabled": False}) + assert off.status_code == 200 and off.json()["enabled"] is False + + listed = {r["id"]: r for r in client.get("/api/agent/connectors").json()["connectors"]} + assert listed[cid]["enabled"] is False + + assert client.delete(f"/api/agent/connectors/{cid}").status_code == 200 + assert client.delete(f"/api/agent/connectors/{cid}").status_code == 404 + assert client.patch(f"/api/agent/connectors/{cid}", json={"enabled": True}).status_code == 404 + + +def test_connectors_require_session(client): + anon_csrf(client) + assert client.get("/api/agent/connectors").status_code == 401 + assert client.post("/api/agent/connectors", + json={"name": "x", "url": PUBLIC_URL}).status_code == 401 + + +def test_probe_persists_status(client, monkeypatch): + row = _create(client) + cid = row["id"] + + async def ok_get(url, headers=None): + assert url.startswith("https://example.com") # URL du connecteur + assert (headers or {}).get("Authorization") == "Bearer sk-live-abc" # clé déchiffrée + return 200, "{}" + + monkeypatch.setattr(connectors, "_get", ok_get) + res = _probe(client, str(cid)) + assert res["status"] == "ok", res + with get_conn() as conn: + st = conn.execute("SELECT status FROM agent_connectors WHERE id=?", (cid,)).fetchone() + assert st["status"] == "ok" + + +def _probe(client, target): + r = client.post("/api/agent/connectors/probe", json={"connector": target}) + assert r.status_code == 200, r.text + return r.json() + + +def test_probe_error_is_persisted(client, monkeypatch): + row = _create(client) + cid = row["id"] + + async def boom(url, headers=None): + raise ValueError("Hôte non autorisé") + + monkeypatch.setattr(connectors, "_get", boom) + res = _probe(client, str(cid)) + assert res["status"] == "error" + assert "Hôte non autorisé" in res["detail"] + with get_conn() as conn: + st = conn.execute("SELECT status, detail FROM agent_connectors WHERE id=?", + (cid,)).fetchone() + assert st["status"] == "error" + + +def test_connector_fetch_tool_registered_and_works(client, monkeypatch): + from app.services.tool_registry import ToolRegistry + + reg = ToolRegistry() + schema = {t["name"]: t for t in reg.schema()} + assert "connector_fetch" in schema + assert schema["connector_fetch"]["parameters"]["required"] == ["connector"] + + row = _create(client) + + async def ok_get(url, headers=None): + return 200, '{"ok": true, "source": "exemple"}' + + monkeypatch.setattr(connectors, "_get", ok_get) + res = asyncio.run(reg.execute("connector_fetch", {"connector": str(row["id"]), + "path": "/status"})) + assert res.status == "success" + assert "exemple" in res.data["text"] + + # connecteur désactivé → refusé + client.patch(f"/api/agent/connectors/{row['id']}", json={"enabled": False}) + res2 = asyncio.run(reg.execute("connector_fetch", {"connector": str(row["id"])})) + assert res2.status == "error" + assert "désactivé" in res2.message.lower() + + # connecteur inconnu → erreur outil (pas d'exception qui casse le run) + res3 = asyncio.run(reg.execute("connector_fetch", {"connector": "999999"})) + assert res3.status == "error" + + +def test_connectors_menu_wired(client): + src = JS_PATH.read_text(encoding="utf-8") + root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0] + assert "action:'connectors'" in root + # phases 1-5 livrées → seule la section Plugins reste « bientôt » + assert root.count("disabled:true") == 1 + for action in ("'connector'", "'connector-new'", "'connector-probe'", + "'connector-toggle'", "'connector-delete'"): + assert action in src, action + for fn in ("fetchConnectors()", "connectorCreate()", "connectorProbe()", + "connectorToggle()", "connectorDelete()"): + assert fn in src, fn + html = PANEL_HTML.read_text(encoding="utf-8") + assert "plusSection==='connector-new'" in html + assert 'type="password"' in html # la clé n'est pas en clair à l'écran + resp = client.get("/accounts") + assert resp.status_code == 200 and "connectorForm" in resp.text