feat: menu + — Connecteurs (socle) — phase 5/8 (v7.55.0)
- 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.
This commit is contained in:
@@ -1,5 +1,46 @@
|
|||||||
# Changelog - FlowDeck
|
# 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)
|
## v7.54.0 (2026-10-06) — Menu + : Mémoire de l'agent (phase 4/8)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+31
-15
@@ -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)
|
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
|
||||||
> un menu à sections, tel que demandé :
|
> 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é
|
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.54.0**, OpenAPI régénéré
|
||||||
(516 chemins), CHANGELOG, ce ROADMAP
|
(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,
|
- [x] **Table `agent_connectors`** (migration **32**) — colonnes **plates**
|
||||||
`enabled`, `status`)
|
(`name`, `url`, `secret_encrypted`, `enabled`, `status`, `detail`) ; le
|
||||||
- [ ] **`app/services/connectors/`** — interface commune `probe() / list_sources() / fetch()`
|
`config_json` prévu est **remis** (YAGNI : rien ne le remplissait — les scopes
|
||||||
+ 3 adapters **natifs déjà présents** : Gitea, GitHub, Web (`web_search` / `fetch_url`, v7.46)
|
OAuth des phases 6-7 ajouteront le leur)
|
||||||
- [ ] **Menu « Connecteurs »** — « Parcourir les connecteurs » (catalogue + statut
|
- [x] **`app/services/connectors.py`** — **1 fichier au lieu du package
|
||||||
connecté/déconnecté) et « Ajouter un connecteur personnalisé » (URL + clé + type
|
`connectors/`** promis (3 probes + 1 fetch ne justifient pas une arborescence) ;
|
||||||
HTTP / MCP)
|
3 natifs `gitea`/`github`/`web` servis à la volée, statut **sans réseau**
|
||||||
- [ ] **Tools agent** — un tool par connecteur exposé au LLM via `tool_registry`
|
(`native_state()`) ; `probe()` et `connector_fetch()` passent par `_get()`
|
||||||
- [ ] **Sécurité** — garde SSRF `_is_public_host` sur toute URL, credentials chiffrés,
|
(gardé SSRF + re-vérification après redirection, timeout 10 s, borne 20 000 car.)
|
||||||
session requise, CSRF sur les écritures
|
- [x] **Menu « Connecteurs »** — « Parcourir » = catalogue avec badge
|
||||||
- [ ] **Tests** — CRUD, statut, vecteurs SSRF, schéma des tools
|
(✓ 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
|
### 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
|
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 5 livrées le
|
||||||
2026-10-06 (v7.51.0 → v7.54.0)**, phases 5-8 à valider une par une avant « go ».
|
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.*
|
Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.54.0",
|
version="7.55.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -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)")
|
@register(31, "v7.54.0: agent memory (mémoire de l'agent)")
|
||||||
def _migration_agent_memory(conn: sqlite3.Connection) -> None:
|
def _migration_agent_memory(conn: sqlite3.Connection) -> None:
|
||||||
"""Toggle par conversation + table des résumés mémorisés.
|
"""Toggle par conversation + table des résumés mémorisés.
|
||||||
|
|||||||
+63
-1
@@ -15,7 +15,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
from app.auth.session import get_current_user
|
from app.auth.session import get_current_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_conn
|
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.agent_engine import AgentEngine, undo_action
|
||||||
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
|
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
|
||||||
from app.services.llm_config import (
|
from app.services.llm_config import (
|
||||||
@@ -633,6 +633,68 @@ def delete_skill(request: Request, skill_id: int):
|
|||||||
return {"id": skill_id, "status": "deleted"}
|
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 👍 / 👎) ──
|
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]}
|
||||||
@@ -1072,6 +1072,48 @@ class SearchCode(Tool):
|
|||||||
|
|
||||||
# ══════════════════════════ Registry ══════════════════════════
|
# ══════════════════════════ 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 = [
|
TOOL_CLASSES = [
|
||||||
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
|
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
|
||||||
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
|
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
|
||||||
@@ -1080,7 +1122,7 @@ TOOL_CLASSES = [
|
|||||||
DeletePage, DeleteCollection,
|
DeletePage, DeleteCollection,
|
||||||
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
||||||
DeleteDocument,
|
DeleteDocument,
|
||||||
WebSearch, FetchUrl, SearchCode,
|
WebSearch, FetchUrl, SearchCode, ConnectorFetch,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -444,6 +444,18 @@ body.fd-ap-resizing *{cursor:col-resize!important}
|
|||||||
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
|
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fd-plus-form" x-show="plusSection==='connector-new'" x-cloak>
|
||||||
|
<label for="fd-plus-cn-name">Nom</label>
|
||||||
|
<input id="fd-plus-cn-name" type="text" x-model="connectorForm.name" placeholder="ex. Base de connaissances" @keydown.enter.prevent="connectorCreate()">
|
||||||
|
<label for="fd-plus-cn-url">URL publique (hôtes privés refusés)</label>
|
||||||
|
<input id="fd-plus-cn-url" type="url" x-model="connectorForm.url" placeholder="https://api.exemple.com">
|
||||||
|
<label for="fd-plus-cn-secret">Clé API — stockée chiffrée, jamais renvoyée</label>
|
||||||
|
<input id="fd-plus-cn-secret" type="password" x-model="connectorForm.secret" placeholder="sk-…" autocomplete="off">
|
||||||
|
<div class="fd-proposal-bar">
|
||||||
|
<button class="fd-proposal-btn primary" type="button" @click="connectorCreate()">✓ Ajouter</button>
|
||||||
|
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<input type="file" accept="application/json,.json" x-ref="skillImport" class="fd-plus-file" aria-label="Importer un skill JSON" @change="importSkillFile($event)">
|
<input type="file" accept="application/json,.json" x-ref="skillImport" class="fd-plus-file" aria-label="Importer un skill JSON" @change="importSkillFile($event)">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# V74 — Menu + de l'assistant : hub de contexte (design)
|
# 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)** ;
|
> **Statut** : design — **phases 1 à 5 livrées (v7.51.0 → v7.55.0, 2026-10-06)** ;
|
||||||
> phases 5-8 restent à livrer. La **mémoire** a été simplifiée par rapport au
|
> 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
|
> 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
|
> `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
|
> utilise `GET /api/nav/menu?parent_id=` (un niveau par appel, contrat
|
||||||
|
|||||||
+190
-1
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "FlowDeck",
|
"title": "FlowDeck",
|
||||||
"version": "7.54.0"
|
"version": "7.55.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/auth/register": {
|
"/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": {
|
"/api/agent/mentions": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
+138
-1
@@ -40,7 +40,7 @@
|
|||||||
{key:'skills', icon:'✨', label:'Compétences-skills',
|
{key:'skills', icon:'✨', label:'Compétences-skills',
|
||||||
sub:'Deep research, Skill-creator… · Gérer · Parcourir', action:'skills', chev:true},
|
sub:'Deep research, Skill-creator… · Gérer · Parcourir', action:'skills', chev:true},
|
||||||
{key:'connectors', icon:'🔌', label:'Connecteurs',
|
{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',
|
{key:'canvases', icon:'🎨', label:'Design System – Canevas',
|
||||||
sub:'Galerie de canevas : créer, insérer, enregistrer', action:'canvases', chev:true},
|
sub:'Galerie de canevas : créer, insérer, enregistrer', action:'canvases', chev:true},
|
||||||
{key:'plugins', icon:'🧩', label:'Add plugins',
|
{key:'plugins', icon:'🧩', label:'Add plugins',
|
||||||
@@ -74,6 +74,8 @@
|
|||||||
skillForm: {id: null, name: '', description: '', prompt_template: ''},
|
skillForm: {id: null, name: '', description: '', prompt_template: ''},
|
||||||
galleryFilter: '',
|
galleryFilter: '',
|
||||||
canvases: [], canvas: null,
|
canvases: [], canvas: null,
|
||||||
|
connectors: [], connector: null,
|
||||||
|
connectorForm: {name: '', url: '', secret: ''},
|
||||||
cmdFocus: -1, slashDismiss: false,
|
cmdFocus: -1, slashDismiss: false,
|
||||||
toastMsg: '', toastErr: false, _toastTimer: null,
|
toastMsg: '', toastErr: false, _toastTimer: null,
|
||||||
_livePh: null,
|
_livePh: null,
|
||||||
@@ -1128,6 +1130,9 @@
|
|||||||
if(s === 'skills-manage') return 'Gérer les compétences';
|
if(s === 'skills-manage') return 'Gérer les compétences';
|
||||||
if(s === 'skills-gallery') return 'Parcourir 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 === '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 === 'canvases') return 'Design System – Canevas';
|
||||||
if(s === 'canvas') return this.canvas ? this.canvas.name : 'Canevas';
|
if(s === 'canvas') return this.canvas ? this.canvas.name : 'Canevas';
|
||||||
return 'Ajouter au contexte';
|
return 'Ajouter au contexte';
|
||||||
@@ -1140,6 +1145,9 @@
|
|||||||
if(s === 'skills-manage') return this._manageItems();
|
if(s === 'skills-manage') return this._manageItems();
|
||||||
if(s === 'skills-gallery') return this._galleryItems();
|
if(s === 'skills-gallery') return this._galleryItems();
|
||||||
if(s === 'skills-edit') return []; // formulaire, pas de liste
|
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 === 'canvases') return this._canvasItems();
|
||||||
if(s === 'canvas') return this._canvasActionItems();
|
if(s === 'canvas') return this._canvasActionItems();
|
||||||
if(s === 'root'){
|
if(s === 'root'){
|
||||||
@@ -1230,6 +1238,118 @@
|
|||||||
if(!out.length) out.push({key:'g-none', _sep: f ? 'Aucun résultat' : 'Galerie vide'});
|
if(!out.length) out.push({key:'g-none', _sep: f ? 'Aucun résultat' : 'Galerie vide'});
|
||||||
return out;
|
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) ──
|
// ── Design System – Canevas (v7.53.0) ──
|
||||||
fetchCanvases(){
|
fetchCanvases(){
|
||||||
var self = this;
|
var self = this;
|
||||||
@@ -1386,6 +1506,20 @@
|
|||||||
if(this.$refs.skillImport) this.$refs.skillImport.click();
|
if(this.$refs.skillImport) this.$refs.skillImport.click();
|
||||||
return;
|
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) ──
|
// ── Design System – Canevas (v7.53.0) ──
|
||||||
if(it.action === 'canvases'){
|
if(it.action === 'canvases'){
|
||||||
this.fetchCanvases(); this.plusSection = 'canvases'; this.plusFocus = 0; return;
|
this.fetchCanvases(); this.plusSection = 'canvases'; this.plusFocus = 0; return;
|
||||||
@@ -1416,6 +1550,9 @@
|
|||||||
this.plusSection = 'skills'; this.plusFocus = 0; return;
|
this.plusSection = 'skills'; this.plusFocus = 0; return;
|
||||||
}
|
}
|
||||||
if(sec === 'canvas'){ this.plusSection = 'canvases'; 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.plusSection = 'root';
|
||||||
this.plusFocus = 0;
|
this.plusFocus = 0;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ def test_plus_menu_sections_and_disabled_states():
|
|||||||
for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"):
|
for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"):
|
||||||
assert f"key:{key}" in root, key
|
assert f"key:{key}" in root, key
|
||||||
assert "action:'files'" in root and "action:'skills'" in root
|
assert "action:'files'" in root and "action:'skills'" in root
|
||||||
# phases 1-4 livrées → il reste 2 sections « bientôt » (connecteurs, plugins)
|
# phases 1-5 livrées → il reste 1 section « bientôt » (plugins)
|
||||||
assert root.count("disabled:true") == 2
|
assert root.count("disabled:true") == 1
|
||||||
|
|
||||||
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
|
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
|
||||||
assert "action:'search'" in files and "action:'browse'" in files
|
assert "action:'search'" in files and "action:'browse'" in files
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ def test_canvases_menu_wired(client):
|
|||||||
src = JS_PATH.read_text(encoding="utf-8")
|
src = JS_PATH.read_text(encoding="utf-8")
|
||||||
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
||||||
assert "action:'canvases'" in root
|
assert "action:'canvases'" in root
|
||||||
# phases 1-4 livrées → 2 sections « bientôt » restantes (connecteurs, plugins)
|
# phases 1-5 livrées → 1 section « bientôt » restante (plugins)
|
||||||
assert root.count("disabled:true") == 2
|
assert root.count("disabled:true") == 1
|
||||||
for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"):
|
for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"):
|
||||||
assert action in src, action
|
assert action in src, action
|
||||||
# piège /use : la clé builtin part DANS LE BODY, pas en query string
|
# piège /use : la clé builtin part DANS LE BODY, pas en query string
|
||||||
|
|||||||
@@ -129,8 +129,8 @@ def test_memory_menu_toggle_wired():
|
|||||||
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
||||||
assert "action:'memory'" in root
|
assert "action:'memory'" in root
|
||||||
assert "bientôt (phase 4)" not in root
|
assert "bientôt (phase 4)" not in root
|
||||||
# phases 1-4 livrées → connecteurs + plugins restent désactivés
|
# phases 1-5 livrées → seul Plugins reste désactivé
|
||||||
assert root.count("disabled:true") == 2
|
assert root.count("disabled:true") == 1
|
||||||
assert "toggleMemory()" in src
|
assert "toggleMemory()" in src
|
||||||
assert "memory_enabled: next" in src
|
assert "memory_enabled: next" in src
|
||||||
engine = ENGINE.read_text(encoding="utf-8")
|
engine = ENGINE.read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user