Compare commits

...
2 Commits
Author SHA1 Message Date
bruno 16f73fe39e feat: Add plugins — catalogue on/off à effet réel (v7.58.0, phase 8/8)
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 2h7m52s
- app/services/plugins.py + migration 35 : table plugins (slug, name,
  description, enabled) pré-remplie avec 3 modules câblés — web-tools,
  web-clipper, automations ; ligne absente = activé (défaut sûr)
- automations OFF → dépendance FastAPI posée à l'include_router dans main.py
  (aucun router touché) → toutes les routes /workspace/automations* refusées +
  garde de tick du scheduler en arrière-plan
- web-clipper OFF → GET /extensions et tout /api/v2/web-clipper/* refusés
- web-tools OFF → web_search et fetch_url retirés du schéma ET de execute()
  via ToolRegistry._all() : le LLM ne les voit plus
- UI rendue côté serveur : global Jinja plugin_enabled(slug) — nav
  « Extensions » / « Automations » en {% if %} (absentes du DOM), sections
  conditionnées en x-show dans settings.html
- menu + : l'entrée « Add plugins » devient vivante (fini disabled:true) —
  liste des 3 plugins avec bascule, GET/PATCH /api/agent/plugins[/slug]
  (slug inconnu → 404, 401 sans session)
- tests : tests/test_v758_plugins.py (10 tests) — routes refusées (302 hors
  /api, 404 JSON pour /api*), outils retirés, nav disparue, persistance,
  câblage ; assertions disabled:true == 0 dans les tests des phases 1/3/4/5/7
- livraison : VERSION + app/main = 7.58.0, OpenAPI 525 chemins, CHANGELOG,
  ROADMAP phase 8 cochée (menu + complet), avenant phase 8 (docs)
2026-10-07 10:19:25 -04:00
bruno f8acb906e5 feat: connecteurs Discord/Telegram/MCP + outils MCP dynamiques au registre (v7.57.0)
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / test (push) Failing after 17m51s
FlowDeck CI / docker (push) Skipped
- presets Discord / Telegram : colonnes kind + auth (bearer/bot/none) sur
  agent_connectors (migration 34) ; Discord envoie le jeton préfixé dans
  l'en-tête d'autorisation, Telegram le place dans l'URL via le substitut
  {secret} remplacé à l'appel — jamais stocké en clair
- Teams : scope ChannelMessage.Read.All ajouté aux Graph scopes M365
- app/services/mcp_client.py : handshake initialize → notifications/initialized
  → tools/list → tools/call (JSON-RPC 2.0, réponse JSON ou premier data: d'un
  text/event-stream), garde SSRF, outils cachés en base (tools_json) —
  le bouton « Tester » rejoue le handshake
- ToolRegistry._all() merge le cache MCP à chaque run : outils exposés au LLM
  sous mcp_<serveur>_<outil> avec leur inputSchema, dispatch tools/call ;
  serveur désactivé → outil absent du schéma, échec réseau → ToolResult(error)
- menu + : champ Type dans le formulaire connecteur (preset URL + auth),
  badge « · N outil(s) » sur la fiche d'un serveur MCP
- tests : tests/test_v757_mcp_discord.py (12 tests, 0 appel réseau réel) ;
  suite complète 1331 verts, ruff 0, eslint 0
- livraison : VERSION + app/main = 7.57.0, OpenAPI 523 chemins, CHANGELOG,
  ROADMAP phase 7 cochée, avenant phase 7 dans docs/V74_Agent_Plus_Menu.md
2026-10-07 09:09:19 -04:00
24 changed files with 1154 additions and 86 deletions
+62
View File
@@ -1,5 +1,67 @@
# Changelog - FlowDeck
## v7.58.0 (2026-10-06) — Add plugins : catalogue on/off à effet réel (phase 8/8)
### Added
- **`app/services/plugins.py` + migration 35** — table `plugins` (`slug`,
`name`, `description`, `enabled`) pré-remplie avec **3 modules câblés** :
`web-tools`, `web-clipper`, `automations`. Ligne absente = activé (défaut
sûr : rien n'est coupé par accident).
- **Effet réel, jamais un simple drapeau** :
- `automations` OFF → dépendance FastAPI posée **à l'`include_router`**
(3 lignes dans `main.py`, aucun router touché) → toutes les routes
`/workspace/automations*` refusées, **et** le scheduler de fond passe en
veille (garde sur chaque tick) ;
- `web-clipper` OFF → `GET /extensions` + tout `/api/v2/web-clipper/*`
refusés ;
- `web-tools` OFF → `web_search` et `fetch_url` retirés du **schéma** de
l'agent et de `execute()` (`ToolRegistry._all()`) : le LLM ne les voit plus.
- **UI masquée côté serveur** — nouveau global Jinja `plugin_enabled(slug)` :
`settings.html` rend la nav « Extensions » / « Automations » sous
`{% if %}` (élément **absent** du DOM, pas seulement caché) et conditionne
les sections en `x-show`.
- **Menu +** — l'entrée « Add plugins » devient une vraie section : liste des
3 plugins (état + description) avec bascule ;
`GET /api/agent/plugins` + `PATCH /api/agent/plugins/{slug}` (slug inconnu →
404, 401 sans session).
- **Tests** — `tests/test_v758_plugins.py` : **10 tests** — catalogue,
persistance en base, routes refusées (302 hors `/api` + 404 JSON pour
`/api*`, handler unifié de `main.py`), outils retirés du registre, nav
disparue du `/settings` rendu, câblage du menu, 401.
## v7.57.0 (2026-10-06) — Connecteurs : Discord, Telegram, Teams, MCP (phase 7/8)
### Added
- **Presets Discord / Telegram** — champ **Type** dans le formulaire « Ajouter
un connecteur » (Personnalisé / Discord / Telegram / Serveur MCP) : URL et
schéma d'authentification pré-remplis. Colonnes `kind` + `auth`
(`bearer`/`bot`/`none`) ajoutées à `agent_connectors` (migration **34**) ;
Discord envoie son jeton préfixé dans l'en-tête d'autorisation, Telegram place
le jeton **dans l'URL** via le substitut `{secret}` — substitué à l'appel,
**jamais stocké en clair** (la colonne url ne contient que le motif).
- **Teams** — scope `ChannelMessage.Read.All` ajouté aux Graph scopes M365
(consentement admin requis à la reconnexion).
- **`app/services/mcp_client.py`** — client MCP (streamable HTTP) : handshake
`initialize` → `notifications/initialized` → `tools/list` → `tools/call`
(JSON-RPC 2.0, réponse JSON **ou** premier `data:` d'un `text/event-stream`),
garde SSRF conservée, erreurs JSON-RPC remontées telles quelles, sortie
bornée à 20 000 car. Les outils sont **cachés** en base (`tools_json`) — le
bouton « Tester le connecteur » rejoue le handshake et met à jour la liste.
- **Outils dynamiques au registre** — `ToolRegistry._all()` merge ce cache à
chaque run : chaque outil est exposé au LLM sous `mcp_<serveur>_<outil>` avec
son `inputSchema` d'origine, et `execute()` dispatche sur `tools/call`.
Serveur désactivé → outil **absent du schéma** ; échec réseau →
`ToolResult(error)` sans casser le run.
- **Menu +** — badge `· N outil(s)` sur la fiche d'un serveur MCP ;
`POST /api/agent/connectors` accepte `kind` + `auth` (400 sur valeurs
inconnues).
- **Tests** — `tests/test_v757_mcp_discord.py` : **12 tests**, 0 appel réseau
réel (`_rpc` / `_get` monkeypatchés) — presets, `{secret}` absent de la base,
handshake + séquence d'appels, cache, dispatch LLM, serveur désactivé,
erreurs RPC, parse SSE, scopes Teams, câblage menu.
## v7.56.0 (2026-10-06) — Connecteurs : Google + Microsoft 365 (phase 6/8)
### Added
+49 -15
View File
@@ -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-6 ✅ 2026-10-06 · phases 7-8 planifiées)
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-8 ✅ 2026-10-06 — menu + complet)
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
> un menu à sections, tel que demandé :
@@ -471,26 +471,60 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
(GOOGLE_/MS_ CLIENT_ID/SECRET + redirect URI), OpenAPI régénéré (523 chemins),
CHANGELOG, ce ROADMAP
### Phase 7 — v7.57.0 — Connecteurs : Discord, Telegram, Teams, MCP · effort L
### Phase 7 — v7.57.0 — Connecteurs : Discord, Telegram, Teams, MCP · effort L ✅
- [ ] **Discord** (bot token : lecture canaux/DMs), **Telegram** (Bot API),
**Teams** (via Graph de la phase 6)
- [ ] **MCP complet** — handshake `initialize`, `tools/list`, `tools/call`
(streamable HTTP) → outils **dynamiques** ajoutés au registre à la connexion
- [ ] **Tests** — schémas MCP, mapping d'outils, échecs d'auth
- [x] **Presets Discord / Telegram** — champ **Type** dans le formulaire
« Ajouter un connecteur » : `kind` + `auth` (`bearer`/`bot`/`none`) ajoutés à
`agent_connectors` (migration **34**) ; Discord = en-tête d'autorisation avec
le jeton préfixé, Telegram = jeton **dans l'URL** via le substitut `{secret}`
(remplacé à l'appel, **jamais stocké en clair** — colonne url = `…/bot{secret}`)
- [x] **Teams** — scope `ChannelMessage.Read.All` ajouté aux Graph scopes M365
(consentement admin)
- [x] **MCP complet** — `app/services/mcp_client.py` : `initialize` →
`notifications/initialized` → `tools/list` → `tools/call` (JSON-RPC 2.0,
réponse JSON **ou** 1er `data:` d'un `text/event-stream`), garde SSRF,
outils **cachés** en base (`tools_json`, maj par le bouton « Tester »)
- [x] **Outils dynamiques au registre** — `ToolRegistry._all()` merge le cache
MCP à chaque run : nom `mcp_<serveur>_<outil>` + `inputSchema` exposés au
LLM, `execute()` dispatche sur `tools/call` ; serveur désactivé → outil
**absent du schéma** ; erreur RPC → `ToolResult(error)` (jamais de run perdu)
- [x] **Tests** — `tests/test_v757_mcp_discord.py` : **12 tests**, 0 appel
réseau (`_rpc` / `_get` monkeypatchés) — presets, `{secret}` absent de la
base, kind/auth invalides → 400, handshake + séquence d'appels, cache
`tools_json`, dispatch LLM, serveur désactivé, erreur handshake → probe
error, parse SSE/erreurs JSON-RPC, slug, scopes Teams, câblage menu
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.57.0**, OpenAPI régénéré,
CHANGELOG, ce ROADMAP
### Phase 8 — v7.58.0 — Add plugins · effort M
### Phase 8 — v7.58.0 — Add plugins · effort M ✅
- [ ] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) — état persisté
- [ ] **Catalogue dans le menu +** — bascule on/off à effet **réel** : un plugin
désactivé retire vraiment sa route/UI (au minimum 3 plugins câblés : web tools de
l'agent, web clipper, automations)
- [ ] **Tests** — plugin OFF = route refusée + UI absente (pas seulement un drapeau lu)
- [x] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) —
migration **35**, table pré-remplie avec les **3 modules** ; ligne absente =
activé (défaut sûr)
- [x] **Catalogue dans le menu +** — l'entrée « Add plugins » devient vivante
(fini le `disabled:true`) : liste des 3 plugins + bascule via
`GET/PATCH /api/agent/plugins[/slug]`
- [x] **Bascule on/off à effet réel** :
- `automations` → **toutes** les routes `/workspace/automations*` refusées
(dépendance FastAPI posée **à l'`include_router`**, aucun router touché) +
scheduler de fond en veille (garde par tick) ;
- `web-clipper` → `GET /extensions` + `/api/v2/web-clipper/*` refusés ;
- `web-tools` → `web_search` / `fetch_url` retirés du **schéma** et de
`execute()` (`ToolRegistry._all()`) ;
- UI → nav « Extensions » / « Automations » rendues sous
`{% if plugin_enabled(...) %}` (global Jinja, élément **absent** du DOM),
sections conditionnées en `x-show`
- [x] **Tests** — `tests/test_v758_plugins.py` : **10 tests** — plugin OFF =
route refusée (302 hors `/api`, 404 JSON pour `/api*`) + UI absente + outil
retiré, persistance, 401 sans session
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.58.0**, OpenAPI régénéré
(525 chemins), CHANGELOG, ce ROADMAP
---
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 6 livrées le
2026-10-06 (v7.51.0 → v7.56.0)**, phases 7-8 à valider une par une avant « go ».
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 8 livrées le
2026-10-06 (v7.51.0 → v7.58.0) : le menu + est complet** (aucune section
« bientôt »). Toute extension ultérieure = nouvelle phase à valider.
Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
---
+1 -1
View File
@@ -1 +1 @@
7.56.0
7.58.0
+11 -5
View File
@@ -5,7 +5,7 @@ import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as _StarHTTPException
@@ -62,6 +62,7 @@ from app.routers.web_clipper import router as web_clipper_router
from app.routers.webauthn import router as webauthn_router
from app.routers.wiki import router as wiki_router
from app.routers.workers import router as workers_router
from app.services import plugins as plugins_service
from app.services.webhook_outbound import init_webhook_tables
logging.basicConfig(
@@ -185,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.56.0",
version="7.58.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -235,7 +236,9 @@ app.include_router(sharing.router)
app.include_router(sidebar_config.router)
app.include_router(export.router)
app.include_router(notifications_router)
app.include_router(automations_router)
# Plugin « automations » OFF → chaque route de ce router renvoie 404
app.include_router(automations_router,
dependencies=[Depends(plugins_service.plugin_required("automations"))])
app.include_router(collaboration_router)
app.include_router(emoji_router)
app.include_router(realtime_router)
@@ -247,8 +250,11 @@ app.include_router(sync.router)
app.include_router(imports_router)
app.include_router(import_page_router)
app.include_router(permissions_router)
app.include_router(web_clipper_api_router)
app.include_router(web_clipper_router)
# Plugin « web-clipper » OFF → page /extensions + API clip refusées
app.include_router(web_clipper_api_router,
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
app.include_router(web_clipper_router,
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
app.include_router(api_v2_router)
app.include_router(api_v2_agent_router)
app.include_router(sites_router)
+37
View File
@@ -1586,6 +1586,43 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
)
@register(35, "v7.58.0: registre des plugins (on/off à effet réel)")
def _migration_plugins(conn: sqlite3.Connection) -> None:
"""Catalogue de l'instance : 3 modules câblés (routes/UI/outils réels)."""
conn.execute(
"""CREATE TABLE IF NOT EXISTS plugins (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1
)"""
)
conn.executemany(
"INSERT OR IGNORE INTO plugins (slug, name, description, enabled) VALUES (?,?,?,1)",
[
("web-tools", "Outils web de l'agent",
"Retire web_search et fetch_url du registre d'outils de l'agent"),
("web-clipper", "Web Clipper",
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
("automations", "Automations",
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
],
)
@register(34, "v7.57.0: connecteurs — kind/auth/tools_json (Discord, Telegram, MCP)")
def _migration_connector_kinds(conn: sqlite3.Connection) -> None:
"""Discord (auth « Bot »), Telegram (jeton dans l'URL via `{secret}`) et
serveurs MCP (kind='mcp', cache d'outils) réutilisent la même table."""
cols = columns(conn, "agent_connectors")
if "kind" not in cols:
conn.execute("ALTER TABLE agent_connectors ADD COLUMN kind TEXT NOT NULL DEFAULT 'custom'")
if "auth" not in cols:
conn.execute("ALTER TABLE agent_connectors ADD COLUMN auth TEXT NOT NULL DEFAULT 'bearer'")
if "tools_json" not in cols:
conn.execute("ALTER TABLE agent_connectors ADD COLUMN tools_json TEXT NOT NULL DEFAULT '[]'")
@register(33, "v7.56.0: tokens OAuth des connecteurs (Google / M365)")
def _migration_connector_tokens(conn: sqlite3.Connection) -> None:
"""Tokens OAuth par (kind, utilisateur), chiffrés Fernet côté service."""
+21 -2
View File
@@ -17,7 +17,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
from app.auth.session import get_current_user
from app.config import settings
from app.db import get_conn
from app.services import connectors, oauth_connectors, skill_gallery
from app.services import connectors, oauth_connectors, plugins, 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 (
@@ -647,13 +647,32 @@ def list_connectors_route(request: Request):
return {"connectors": connectors.list_connectors(user_id)}
@router.get("/plugins")
def list_plugins_route(request: Request):
"""Catalogue des plugins de l'instance (menu + → « Add plugins »)."""
_current_user_id(request)
return {"plugins": plugins.list_plugins()}
@router.patch("/plugins/{slug}")
def set_plugin_route(request: Request, slug: str, body: dict = Body(default={})):
"""Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau."""
_current_user_id(request)
try:
return plugins.set_enabled(slug, bool(body.get("enabled")))
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@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 "")
body.get("name") or "", body.get("url") or "", str(body.get("secret") or ""),
kind=str(body.get("kind") or "custom"),
auth=str(body.get("auth") or "bearer"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+4 -1
View File
@@ -27,7 +27,7 @@ import time
from datetime import UTC, datetime, timedelta
from app.db import get_conn
from app.services import notifications
from app.services import notifications, plugins
from app.services.http_client import shared_client
logger = logging.getLogger(__name__)
@@ -479,6 +479,9 @@ async def automation_scheduler():
"""Background loop: fire due cron automations (checked every 60s)."""
while True:
try:
if not plugins.is_enabled("automations"): # plugin OFF = rien de planifié
await asyncio.sleep(60)
continue
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
+94 -34
View File
@@ -1,7 +1,10 @@
"""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.
5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée,
plus des connecteurs **personnels** persistés (URL + clé) en base avec un
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
l'URL via ``{secret}``) | ``mcp`` (serveur Model Context Protocol, cache
d'outils dans ``tools_json``).
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
@@ -10,6 +13,7 @@ provider) — 3 probes dans un dict + 1 fetch. Les adapters lourds des phases 6-
from __future__ import annotations
import json
import logging
from app.config import settings
@@ -20,6 +24,18 @@ from app.services.sso_provisioning import decrypt_secret, encrypt_secret
logger = logging.getLogger(__name__)
NATIVE_KINDS = ("gitea", "github", "web", "google", "ms365")
CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp")
AUTH_SCHEMES = ("bearer", "bot", "none")
# Presets proposés par le formulaire du menu + (le jeton reste toujours saisi
# par l'utilisateur — jamais codé en dur ici).
PRESETS = {
"discord": {"url": "https://discord.com/api/v10", "auth": "bot",
"hint": "Bot Discord Developers → Token"},
"telegram": {"url": "https://api.telegram.org/bot{secret}", "auth": "none",
"hint": "BotFather → Bot Token (jeton dans l'URL)"},
"mcp": {"url": "https://", "auth": "bearer",
"hint": "URL du endpoint MCP (streamable HTTP)"},
}
OAUTH_KINDS = oauth_connectors.OAUTH_KINDS
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
@@ -48,6 +64,21 @@ def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
def _row(r) -> dict:
"""Ligne `agent_connectors` → dict API (le jeton n'y figure jamais)."""
try:
tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else []
except (ValueError, TypeError):
tools = []
return {
"id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"],
"url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]),
"status": r["status"], "detail": r["detail"] or "",
"has_secret": bool(r["secret_encrypted"]), "oauth": False,
"tools_count": len(tools),
}
def list_connectors(user_id: int | None = None) -> list[dict]:
"""Catalogue : natifs (toujours présents) + connecteurs personnels."""
out: list[dict] = []
@@ -62,46 +93,45 @@ def list_connectors(user_id: int | None = None) -> list[dict]:
})
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail "
"FROM agent_connectors ORDER BY id"
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
"kind, auth, tools_json 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
return out + [_row(r) for r in rows]
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,)
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
"kind, auth, tools_json 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"])}
return _row(r) if r else None
# ── CRUD (personnels) ────────────────────────────────────────────────────────
def create_connector(name: str, url: str, secret: str = "") -> dict:
def create_connector(name: str, url: str, secret: str = "", *,
kind: str = "custom", auth: str = "bearer") -> 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
if kind not in CUSTOM_KINDS:
raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})")
if auth not in AUTH_SCHEMES:
raise ValueError(f"auth invalide: {auth} (attendu {', '.join(AUTH_SCHEMES)})")
url = (url or "").strip()
if "{secret}" in url and not (secret or "").strip():
raise ValueError("clé requise : l'URL contient {secret}")
url = _validate_url(url) # 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", ""),
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, "
"detail, kind, auth) VALUES (?,?,?,?,?,?,?)",
(name, url, encrypt_secret(secret or ""), "unknown", "", kind, auth),
)
conn.commit()
cid = cur.lastrowid
@@ -143,22 +173,44 @@ def delete_connector(connector_id: int) -> bool:
# ── 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."""
"""En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée.
``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs).
"""
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
secret = ""
secret, auth = "", "bearer"
if connector_id:
with get_conn() as conn:
r = conn.execute(
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
"SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
if secret:
if r:
secret = decrypt_secret(r["secret_encrypted"] or "")
auth = r["auth"] or "bearer"
if secret and auth == "bot":
headers["Authorization"] = f"Bot {secret}"
elif secret and auth != "none":
headers["Authorization"] = f"Bearer {secret}"
headers["X-Api-Key"] = secret
return headers
def _with_secret(url: str, connector_id: int) -> str:
"""Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair)."""
if "{secret}" not in url:
return url
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 not secret:
raise ValueError("clé manquante pour une URL contenant {secret}")
return url.replace("{secret}", secret)
async def _get(url: str, headers: dict | None = None) -> tuple[int, str]:
"""GET gardé SSRF (validation + re-vérification après redirection).
@@ -184,7 +236,7 @@ def _resolve(connector: str) -> tuple[str, str | int]:
row = get(int(token))
if row is None:
raise ValueError(f"Connecteur inconnu: {token}")
return ("custom", row["id"])
return (row["kind"], row["id"])
low = token.lower()
if low in NATIVE_KINDS:
return (low, low)
@@ -193,7 +245,7 @@ def _resolve(connector: str) -> tuple[str, str | int]:
"SELECT id FROM agent_connectors WHERE lower(name)=?", (low,)
).fetchone()
if row:
return ("custom", row["id"])
return (get(row["id"])["kind"], row["id"])
raise ValueError(f"Connecteur inconnu: {token}")
@@ -201,7 +253,11 @@ async def probe(connector: str, user_id: int | None = None) -> dict:
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
kind, ref = _resolve(connector)
try:
if kind in OAUTH_KINDS:
if kind == "mcp":
from app.services import mcp_client
tools = await mcp_client.initialize_and_list(int(ref))
status, detail = "ok", f"MCP · {len(tools)} outil(s)"
elif kind in OAUTH_KINDS:
res = await oauth_connectors.api_get(kind, user_id,
oauth_connectors.PROVIDERS[kind]["probe_path"])
status, detail = res["status"], res["text"][:200]
@@ -216,12 +272,13 @@ async def probe(connector: str, user_id: int | None = None) -> dict:
status, detail = "ok", native_state("web")[1]
else:
row = get(int(ref))
code, _ = await _get(row["url"], _headers(int(ref)))
url = _with_secret(row["url"], int(ref))
code, _ = await _get(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":
if kind not in NATIVE_KINDS:
with get_conn() as conn:
conn.execute(
"UPDATE agent_connectors SET status=?, detail=? WHERE id=?",
@@ -246,6 +303,9 @@ async def connector_fetch(connector: str, path: str = "", query: str = "",
path = (path or "").strip()
if kind in OAUTH_KINDS:
return await oauth_connectors.api_get(kind, user_id, path)
if kind == "mcp":
from app.services import mcp_client
return {"status": "ok", "text": mcp_client.tools_text(int(ref))}
if kind == "gitea":
base = settings.gitea_url.rstrip("/")
url = f"{base}/{path.lstrip('/')}" if path else f"{base}/api/v1/version"
@@ -267,7 +327,7 @@ async def connector_fetch(connector: str, path: str = "", query: str = "",
return {"status": "error", "text": "Connecteur supprimé"}
if not row["enabled"]:
return {"status": "error", "text": "Connecteur désactivé"}
url = row["url"].rstrip("/")
url = _with_secret(row["url"], int(ref)).rstrip("/")
if path:
url += "/" + path.lstrip("/")
code, text = await _get(url, _headers(int(ref)))
+181
View File
@@ -0,0 +1,181 @@
"""Client MCP (Model Context Protocol) — streamable HTTP, v7.57.0.
JSON-RPC 2.0 : ``initialize`` → ``notifications/initialized`` → ``tools/list``
→ ``tools/call``. Les outils sont **cachés** dans
``agent_connectors.tools_json`` (kind ``mcp``) puis exposés au LLM par
``ToolRegistry`` sous des noms ``mcp_<serveur>_<outil>`` — outils dynamiques,
sans état en mémoire.
ponytail : **1 seul seam** ``_rpc()`` (monkeypatché en test) ; on lit soit la
réponse JSON directe, soit la première ligne ``data:`` d'une
``text/event-stream`` — pas de client SSE à l'état (les méthodes utilisées
répondent en JSON chez la grande majorité des serveurs).
"""
from __future__ import annotations
import json
import logging
import re
from app.db import get_conn
from app.services.connectors import _headers, _with_secret, get
logger = logging.getLogger(__name__)
# Version la plus répandue côté serveurs (un serveur plus récent rétrograde).
PROTOCOL_VERSION = "2024-11-05"
CLIENT_INFO = {"name": "FlowDeck", "version": "7"}
def tool_name(server_name: str, tool_name: str) -> str:
"""Nom LLM stable : ``mcp_<serveur>_<outil>`` (slug minuscules/underscores)."""
def slug(text: str) -> str:
return re.sub(r"[^a-z0-9]+", "_", str(text).lower()).strip("_")
joined = f"mcp_{slug(server_name)}_{slug(tool_name)}"
return re.sub(r"_{2,}", "_", joined)
def parse_body(content_type: str, body: str, status: int) -> dict:
"""Corps MCP → dict JSON (JSON direct ou événement ``data:`` d'un flux SSE)."""
text = body or ""
if "text/event-stream" in (content_type or ""):
for line in text.splitlines():
if line.startswith("data:"):
text = line[5:].strip()
break
try:
data = json.loads(text)
except ValueError as exc:
raise ValueError(f"Réponse MCP illisible (HTTP {status})") from exc
if isinstance(data, dict) and data.get("error"):
err = data["error"] if isinstance(data["error"], dict) else {}
raise ValueError(f"MCP {err.get('code', '?')} : {err.get('message', 'erreur')}")
return data if isinstance(data, dict) else {}
async def _rpc(url: str, payload: dict, headers: dict | None = None) -> dict:
"""POST JSON-RPC gardé SSRF → réponse décodée. Point d'injection des tests."""
from app.services.http_client import shared_client
from app.services.importers.url_fetch import _validate_url
safe = _validate_url(url)
hdrs = {"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
**(headers or {})}
async with shared_client(timeout=15, headers=hdrs) as client:
resp = await client.post(safe, json=payload)
return parse_body(resp.headers.get("content-type", ""), resp.text or "",
resp.status_code)
async def _notify(url: str, payload: dict, headers: dict | None = None) -> None:
"""Notification JSON-RPC (202 sans corps) — les erreurs sont ignorées."""
try:
await _rpc(url, payload, headers)
except Exception as exc: # noqa: BLE001
logger.debug("MCP notification ignorée: %s", exc)
def _server(connector_id: int) -> dict:
row = get(connector_id)
if row is None:
raise ValueError("Serveur MCP introuvable")
if row["kind"] != "mcp":
raise ValueError("Ce connecteur n'est pas un serveur MCP")
if not row["enabled"]:
raise ValueError("Serveur MCP désactivé")
return {"id": row["id"], "name": row["name"], "url": row["url"],
"headers": _headers(connector_id)}
def _normalize(server: dict, raw: list) -> list[dict]:
out = []
for t in raw if isinstance(raw, list) else []:
if not isinstance(t, dict) or not t.get("name"):
continue
params = t.get("inputSchema")
if not isinstance(params, dict):
params = {"type": "object", "properties": {}}
out.append({
"name": tool_name(server["name"], t["name"]),
"original": str(t["name"]),
"description": str(t.get("description") or "Outil MCP"),
"parameters": params,
})
return out
def _save_tools(connector_id: int, tools: list[dict]) -> None:
with get_conn() as conn:
conn.execute("UPDATE agent_connectors SET tools_json=? WHERE id=?",
(json.dumps(tools), connector_id))
conn.commit()
async def initialize_and_list(connector_id: int) -> list[dict]:
"""Handshake + ``tools/list`` → met à jour le cache de la base."""
srv = _server(connector_id)
url = _with_secret(srv["url"], connector_id)
await _rpc(url, {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
"clientInfo": CLIENT_INFO},
}, srv["headers"])
await _notify(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
srv["headers"])
data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
"params": {}}, srv["headers"])
result = data.get("result") if isinstance(data.get("result"), dict) else {}
tools = _normalize(srv, result.get("tools") or [])
_save_tools(connector_id, tools)
return tools
def cached_tools() -> list[dict]:
"""Outils MCP des serveurs **activés** (cache en base) — lu par ToolRegistry."""
with get_conn() as conn:
rows = conn.execute(
"SELECT id, tools_json FROM agent_connectors WHERE kind='mcp' AND enabled=1"
).fetchall()
out: list[dict] = []
for r in rows:
try:
tools = json.loads(r["tools_json"] or "[]")
except (ValueError, TypeError):
tools = []
for t in tools if isinstance(tools, list) else []:
if isinstance(t, dict) and t.get("name"):
out.append({**t, "connector_id": r["id"]})
return out
def tools_text(connector_id: int) -> str:
"""Liste lisible des outils cachés (utilisée par ``connector_fetch``)."""
tools = [t for t in cached_tools() if t["connector_id"] == connector_id]
if not tools:
return "Aucun outil en cache — lancez « Tester le connecteur » (tools/list)."
return "\n".join(f"- {t['name']} : {t.get('description', '')}" for t in tools)
def _content_text(result: dict) -> str:
parts = []
for item in result.get("content") or []:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(str(item.get("text") or ""))
else:
parts.append(json.dumps(item, ensure_ascii=False)[:2000])
return "\n".join(p for p in parts if p) or json.dumps(result, ensure_ascii=False)[:4000]
async def call_tool(connector_id: int, tool: str, arguments: dict | None = None) -> dict:
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.)."""
srv = _server(connector_id)
url = _with_secret(srv["url"], connector_id)
data = await _rpc(url, {
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": tool, "arguments": arguments or {}},
}, srv["headers"])
result = data.get("result") if isinstance(data.get("result"), dict) else {}
text = _content_text(result)[:20000]
return {"status": "error" if result.get("isError") else "ok", "text": text}
+1
View File
@@ -50,6 +50,7 @@ PROVIDERS: dict[str, dict] = {
"scopes": [
"openid", "email", "profile", "offline_access",
"User.Read", "Files.Read", "Mail.Read", "Calendars.Read",
"ChannelMessage.Read.All", # Teams (phase 7) — consentement admin
],
"extra": {"response_mode": "query", "prompt": "consent"},
"probe_path": "/me",
+77
View File
@@ -0,0 +1,77 @@
"""Plugins de l'instance — on/off à **effet réel** (v7.58.0).
Chaque plugin câble une chose concrète, jamais un simple drapeau :
- ``web-tools`` → retiré du registre d'outils de l'agent (schéma + exécution)
- ``web-clipper`` → routers ``/extensions`` et ``/api/v2/web-clipper/*`` refusés
- ``automations`` → routes ``/workspace/automations*`` refusées + scheduler
Le garde de route est une **dépendance FastAPI posée à l'`include_router`**
(``main.py``) : 3 lignes, aucun fichier de router touché.
"""
from __future__ import annotations
from fastapi import HTTPException, Request
from app.db import get_conn
# slug → (nom affiché, description affichée)
CATALOG: list[tuple[str, str, str]] = [
("web-tools", "Outils web de l'agent",
"Retire web_search et fetch_url du registre d'outils de l'agent"),
("web-clipper", "Web Clipper",
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
("automations", "Automations",
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
]
# slug → outils LLM retirés du registre quand le plugin est OFF
PLUGIN_TOOLS: dict[str, tuple[str, ...]] = {"web-tools": ("web_search", "fetch_url")}
def is_enabled(slug: str) -> bool:
"""Ligne absente = activé (défaut sûr : ne rien couper par accident)."""
with get_conn() as conn:
row = conn.execute("SELECT enabled FROM plugins WHERE slug=?", (slug,)).fetchone()
return True if row is None else bool(row["enabled"])
def list_plugins() -> list[dict]:
with get_conn() as conn:
rows = {r["slug"]: bool(r["enabled"])
for r in conn.execute("SELECT slug, enabled FROM plugins")}
return [{"slug": slug, "name": name, "description": desc,
"enabled": rows.get(slug, True)}
for slug, name, desc in CATALOG]
def set_enabled(slug: str, enabled: bool) -> dict:
if not any(s == slug for s, _, _ in CATALOG):
raise ValueError(f"Plugin inconnu: {slug}")
name = next(n for s, n, _ in CATALOG if s == slug)
desc = next(d for s, _, d in CATALOG if s == slug)
with get_conn() as conn:
# SQLite vérifie NOT NULL AVANT l'upsert : il faut fournir name/description.
conn.execute(
"INSERT INTO plugins (slug, name, description, enabled) VALUES (?,?,?,?) "
"ON CONFLICT(slug) DO UPDATE SET enabled=excluded.enabled",
(slug, name, desc, 1 if enabled else 0))
conn.commit()
return next(p for p in list_plugins() if p["slug"] == slug)
def plugin_required(slug: str):
"""Dépendance FastAPI : 404 dès que le plugin est désactivé.
`Request` doit être importé au **module** : les annotations sont des
chaînes (`from __future__ import annotations`) et FastAPI les résout dans
les globales du module — sinon il lit un query param → 422.
"""
def dep(request: Request) -> None:
# annotation Request OBLIGATOIRE : sans elle FastAPI lit un query param → 422
if not is_enabled(slug):
raise HTTPException(status_code=404, detail=f"Plugin désactivé: {slug}")
return dep
+59 -6
View File
@@ -1115,6 +1115,36 @@ class ConnectorFetch(Tool):
message=text[:800], data={"text": text})
class McpTool(Tool):
"""Outil MCP dynamique — instance construite à la volée depuis le cache
en base (`mcp_client.cached_tools()`), pas enregistrée dans TOOL_CLASSES."""
def __init__(self, entry: dict):
self.name = entry["name"]
self.description = entry.get("description") or "Outil MCP"
self.parameters = entry.get("parameters") or {"type": "object", "properties": {}}
self.entry = entry
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services import mcp_client
try:
res = await mcp_client.call_tool(self.entry["connector_id"],
self.entry.get("original") or "", args)
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("MCP tool failed (%s): %s", self.name, exc)
return ToolResult(status="error", tool=self.name,
message=f"MCP indisponible: {exc}")
if res.get("status") != "ok":
return ToolResult(status="error", tool=self.name,
message=(res.get("text") or "erreur MCP")[:500])
text = res.get("text") or ""
return ToolResult(status="success", tool=self.name, target_type="mcp",
message=text[:800], data={"text": text})
TOOL_CLASSES = [
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
@@ -1133,24 +1163,47 @@ class ToolRegistry:
def __init__(self):
self.tools: dict[str, Tool] = {t.name: t() for t in TOOL_CLASSES}
def _all(self) -> dict[str, Tool]:
"""Outils statiques + **outils MCP dynamiques** (cache en base).
Les MCP ne sont jamais mis en cache en mémoire : un run re-lit la base,
donc « Tester le connecteur » suffit à rendre un outil disponible.
"""
tools = dict(self.tools)
try:
from app.services import mcp_client
from app.services import plugins as plugins_service
for entry in mcp_client.cached_tools():
tools[entry["name"]] = McpTool(entry)
# plugin « web-tools » OFF → outils web retirés du schéma ET de execute()
for slug, names in plugins_service.PLUGIN_TOOLS.items():
if not plugins_service.is_enabled(slug):
for name in names:
tools.pop(name, None)
except Exception: # noqa: BLE001 — la base ne doit jamais casser un run
logger.exception("dynamic tools load failed")
return tools
def list(self, scope: dict | None = None) -> list[str]:
"""Tool names allowed by an agent scope (default: all)."""
tools = self._all()
allowed = (scope or {}).get("tools")
if allowed is None:
return list(self.tools.keys())
return [n for n in self.tools if n in allowed]
return list(tools.keys())
return [n for n in tools if n in allowed]
def schema(self, scope: dict | None = None) -> list[dict]:
"""Function-calling schema for the LLM, filtered by scope."""
tools = self._all()
return [
{"name": self.tools[n].name,
"description": self.tools[n].description,
"parameters": self.tools[n].parameters}
{"name": tools[n].name,
"description": tools[n].description,
"parameters": tools[n].parameters}
for n in self.list(scope)
]
async def execute(self, tool: str, args: dict, *, user_id: int | None = None) -> ToolResult:
impl = self.tools.get(tool)
impl = self._all().get(tool)
if not impl:
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
return await impl.execute(args, user_id=user_id)
+10 -3
View File
@@ -445,11 +445,18 @@ body.fd-ap-resizing *{cursor:col-resize!important}
</div>
</div>
<div class="fd-plus-form" x-show="plusSection==='connector-new'" x-cloak>
<label for="fd-plus-cn-kind">Type</label>
<select id="fd-plus-cn-kind" x-model="connectorForm.kind" @change="connectorPreset()">
<option value="custom">Connecteur personnalisé (HTTP + clé)</option>
<option value="discord">Discord (bot)</option>
<option value="telegram">Telegram (bot)</option>
<option value="mcp">Serveur MCP (streamable HTTP)</option>
</select>
<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>
<label for="fd-plus-cn-url" x-text="connectorForm.hint || 'URL publique (hôtes privés refusés)'"></label>
<input id="fd-plus-cn-url" type="text" x-model="connectorForm.url" placeholder="https://api.exemple.com">
<label for="fd-plus-cn-secret">Clé / jeton — 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>
+4 -4
View File
@@ -138,13 +138,13 @@
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="navTo('api-tokens')"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
{% if plugin_enabled('web-clipper') %}<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>{% endif %}
<div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
<div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>
{% if plugin_enabled('automations') %}<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>{% endif %}
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent &amp; IA</div>
<!-- Admin nav: only visible to admins -->
<template x-if="userIsAdmin">
@@ -423,7 +423,7 @@
</div>
<!-- Extensions (Web Clipper) -->
<div x-show="activeSection==='extensions'">
<div x-show="{{ 'true' if plugin_enabled('web-clipper') else 'false' }} &amp;&amp; activeSection==='extensions'">
<h2>Extensions</h2>
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
<div class="setting-group">
@@ -584,7 +584,7 @@
</div>
<!-- Automations -->
<div x-show="activeSection==='automations'">
<div x-show="{{ 'true' if plugin_enabled('automations') else 'false' }} &amp;&amp; activeSection==='automations'">
<h2>Automations</h2>
<p class="section-desc">Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.</p>
+8
View File
@@ -77,3 +77,11 @@ except OSError: # pragma: no cover
ENV.globals["asset_version"] = ASSET_VERSION
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()
# Plugins activés : conditionne les blocs de UI rendus côté serveur.
ENV.globals["plugin_enabled"] = lambda slug: _plugin_enabled(slug)
def _plugin_enabled(slug: str) -> bool:
from app.services.plugins import is_enabled
return is_enabled(slug)
+43
View File
@@ -202,6 +202,49 @@ transaction par migration — A31).
Chaque phase se clôt par : `ruff` + suite verte, bump de version, CHANGELOG, ROADMAP
à jour, tag.
## 9bis. Avenant phase 7 (v7.57.0) — décisions livrées
- **D5 (`probe/list_sources/fetch` par adapter) écartée** : 1 switch sur
`agent_connectors.kind` dans `probe()` / `connector_fetch()` — une interface
à 3 méthodes pour 4 kinds tordrait plus qu'elle ne simplifie ; ajouter un
kind = 1 branche de plus dans le même switch.
- **Discord / Telegram = presets du socle**, pas 3 adapters dédiés : le champ
**Type** du formulaire pré-remplit l'URL + le schéma d'auth
(`bearer`/`bot`/`none`). La Bot API de Telegram impose le jeton **dans l'URL**
→ substitut `{secret}` remplacé à l'appel (`connectors._with_secret`),
stockage = motif seul, test « le jeton n'est jamais en base ».
- **MCP : outils dynamiques sans état** — `ToolRegistry._all()` relit le cache
`tools_json` à chaque run (rien à invalider, « Tester » suffit) ; nom LLM
`mcp_<serveur>_<outil>` (slug sans double soulignement) ; dispatch
`tools/call` dans `McpTool.execute()`. Serveur désactivé → outil **absent du
schéma**, échec réseau → `ToolResult(error)` : le run continue toujours.
- **Pas de client SSE à l'état** : `parse_body()` lit le JSON direct ou le
premier `data:` d'un `text/event-stream`, ce qui couvre
`initialize` / `tools/list` / `tools/call` des serveurs streamables courants.
Upgrade : client SSE persistant si un serveur ne répond qu'en flux.
## 9ter. Avenant phase 8 (v7.58.0) — décisions livrées
- **Garde de route = dépendance posée à l'`include_router`** (`main.py`), pas
de décorateur par route ni de middleware : 3 lignes, aucun router modifié.
⚠️ l'annotation `request: Request` doit être importée **au module** : avec
`from __future__ import annotations` les annotations sont des chaînes et
FastAPI les résout dans les globales du module — sinon il lit un query param
→ 422 (piège réel, corrigé en cours de phase).
- **Effet réel aux 4 niveaux** : routes (dépendance), arrière-plan (garde de
tick du scheduler), outils (`ToolRegistry._all()`), UI (rendu serveur).
- **UI : `{% if %}` pour la nav, `x-show` pour les sections** — la nav est
**absente du DOM** (test « UI absente » au sens propre) ; les grosses
sections ne sont pas restructurées, leur booléen est rendu au serveur.
- **Handler unifié des 404** (comportement historique conservé) : hors `/api`
une route refusée **redirige 302 → /workspaces**, `/api*` répond 404 JSON —
les tests assertent les deux formes au lieu de réécrire le handler.
- **SQLite** : `INSERT … ON CONFLICT` évalue NOT NULL **avant** l'upsert →
fournir toutes les colonnes NOT NULL, même pour un simple changement d'état.
- `ponytail:` plafond assumé — pas de classe-adapter par plugin ni de
« marketplace » : ajouter un plugin = 1 tuple dans `CATALOG` (+ 1 garde si
son effet n'est pas déjà couvert).
## 10. Hors périmètre
- Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ.
+73 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.56.0"
"version": "7.58.0"
},
"paths": {
"/auth/register": {
@@ -16299,6 +16299,78 @@
}
}
},
"/api/agent/plugins": {
"get": {
"tags": [
"agent"
],
"summary": "List Plugins Route",
"description": "Catalogue des plugins de l'instance (menu + → « Add plugins »).",
"operationId": "list_plugins_route_api_agent_plugins_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/api/agent/plugins/{slug}": {
"patch": {
"tags": [
"agent"
],
"summary": "Set Plugin Route",
"description": "Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau.",
"operationId": "set_plugin_route_api_agent_plugins__slug__patch",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Slug"
}
}
],
"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"
}
}
}
}
}
}
},
"/api/agent/connectors/{connector_id}": {
"patch": {
"tags": [
+63 -6
View File
@@ -44,7 +44,7 @@
{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',
sub:'Modules on/off — bientôt (phase 8)', disabled:true},
sub:'Modules on/off — effet réel : routes, UI, outils', action:'plugins', chev:true},
{key:'memory', icon:'🧠', label:'Mémoire (on/off)',
sub:'Résumés des échanges injectés à chaque run', action:'memory'}
];
@@ -74,8 +74,9 @@
skillForm: {id: null, name: '', description: '', prompt_template: ''},
galleryFilter: '',
canvases: [], canvas: null,
connectors: [], connector: null,
connectorForm: {name: '', url: '', secret: ''},
connectors: [], connector: null, plugins: [],
connectorForm: {name: '', url: '', secret: '', kind: 'custom',
auth: 'bearer', hint: ''},
cmdFocus: -1, slashDismiss: false,
toastMsg: '', toastErr: false, _toastTimer: null,
_livePh: null,
@@ -1147,6 +1148,7 @@
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';
if(s === 'plugins') return 'Add plugins';
return 'Ajouter au contexte';
},
get plusItems(){
@@ -1162,6 +1164,7 @@
if(s === 'connector-new') return [];
if(s === 'canvases') return this._canvasItems();
if(s === 'canvas') return this._canvasActionItems();
if(s === 'plugins') return this._pluginItems();
if(s === 'root'){
// l'entrée Mémoire reflète l'état réel du toggle de la conversation
var on = this.memoryOn;
@@ -1270,7 +1273,41 @@
_connectorStatus(c){
var mark = c.status === 'ok' ? '✓ ' : (c.status === 'error' ? '✗ '
: (c.status === 'missing' ? '⚠ ' : '? '));
return mark + (c.detail || c.status) + (c.enabled ? '' : ' · désactivé');
var tools = (c.kind === 'mcp' && c.tools_count) ? ' · ' + c.tools_count + ' outil(s)' : '';
return mark + (c.detail || c.status) + tools + (c.enabled ? '' : ' · désactivé');
},
// ── Plugins de l'instance (v7.58.0) ──
fetchPlugins(){
var self = this;
fetch('/api/agent/plugins').then(function(r){ return r.json(); })
.then(function(d){ self.plugins = (d && d.plugins) || []; })
.catch(function(){ self.plugins = []; });
},
_pluginItems(){
var out = (this.plugins || []).map(function(pl){
return {key:'pl-' + pl.slug, icon: pl.enabled ? '✅' : '⛔',
label: pl.name,
sub: (pl.enabled ? 'Activé' : 'Désactivé') + ' — ' + pl.description,
action:'plugin-toggle', slug: pl.slug};
});
if(!out.length) out.push({key:'pl-none', _sep:'Aucun plugin'});
out.push({key:'pl-hint', _sep:
'OFF = routes refusées, UI masquée, outils retirés'});
return out;
},
pluginToggle(slug){
var self = this;
var pl = (this.plugins || []).filter(function(x){ return x.slug === slug; })[0];
if(!pl) return;
fetch('/api/agent/plugins/' + encodeURIComponent(slug), {
method:'PATCH', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({enabled: !pl.enabled})
}).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) || 'Bascule impossible.', true); return; }
self.toast(res.d.enabled ? 'Plugin activé.' : 'Plugin désactivé.');
self.fetchPlugins();
}).catch(function(){ self.toast('Bascule impossible (réseau).', true); });
},
_connectorItems(){
var self = this;
@@ -1377,6 +1414,20 @@
self.fetchConnectors();
}).catch(function(){ self.toast('Suppression impossible (réseau).', true); });
},
// Type choisi dans le formulaire → URL / schéma d'auth / aide pré-remplis.
connectorPreset(){
var f = this.connectorForm;
var presets = {
custom: {url:'', auth:'bearer', hint:'URL publique de votre API (hôtes privés refusés)'},
discord: {url:'https://discord.com/api/v10', auth:'bot',
hint:'Discord Developers → Bot Token (jeton en Authorization)'},
telegram:{url:'https://api.telegram.org/bot{secret}', auth:'none',
hint:'BotFather → Bot Token ({secret} complète l\u2019URL)'},
mcp: {url:'', auth:'bearer', hint:'URL du endpoint MCP (streamable HTTP)'}
};
var p = presets[f.kind] || presets.custom;
f.url = p.url; f.auth = p.auth; f.hint = p.hint;
},
connectorCreate(){
var self = this, f = this.connectorForm;
if(!(f.name || '').trim() || !(f.url || '').trim()){
@@ -1384,7 +1435,8 @@
}
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 || ''})
body: JSON.stringify({name: f.name, url: f.url, secret: f.secret || '',
kind: f.kind || 'custom', auth: f.auth || 'bearer'})
}).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; }
@@ -1558,9 +1610,14 @@
this.connector = it.connector; this.plusSection = 'connector'; this.plusFocus = 0; return;
}
if(it.action === 'connector-new'){
this.connectorForm = {name:'', url:'', secret:''};
this.connectorForm = {name:'', url:'', secret:'', kind:'custom',
auth:'bearer', hint:''};
this.plusSection = 'connector-new'; this.plusFocus = -1; return;
}
if(it.action === 'plugins'){
this.fetchPlugins(); this.plusSection = 'plugins'; this.plusFocus = 0; return;
}
if(it.action === 'plugin-toggle'){ this.pluginToggle(it.slug); 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; }
+2 -2
View File
@@ -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-5 livrées → il reste 1 section « bientôt » (plugins)
assert root.count("disabled:true") == 1
# v7.58.0 : menu complet — plus aucune section « bientôt » (plugins vivant)
assert root.count("disabled:true") == 0
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
assert "action:'search'" in files and "action:'browse'" in files
+1 -2
View File
@@ -64,8 +64,7 @@ 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-5 livrées → 1 section « bientôt » restante (plugins)
assert root.count("disabled:true") == 1
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
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
+1 -2
View File
@@ -129,8 +129,7 @@ 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-5 livrées → seul Plugins reste désactivé
assert root.count("disabled:true") == 1
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
assert "toggleMemory()" in src
assert "memory_enabled: next" in src
engine = ENGINE.read_text(encoding="utf-8")
+1 -2
View File
@@ -158,8 +158,7 @@ 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
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
for action in ("'connector'", "'connector-new'", "'connector-probe'",
"'connector-toggle'", "'connector-delete'"):
assert action in src, action
+209
View File
@@ -0,0 +1,209 @@
"""v7.57.0 — Discord / Telegram (presets) + serveurs MCP (outils dynamiques)."""
import asyncio
from pathlib import Path
import pytest
from conftest import anon_csrf
from app.db import get_conn
from app.services import connectors, mcp_client
from app.services.tool_registry import ToolRegistry
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
def _create(client, **kw):
body = {"name": "Conn", "url": "https://example.com/v1", "secret": "sk-1", **kw}
r = client.post("/api/agent/connectors", json=body)
assert r.status_code == 200, r.text
return r.json()
# ── Presets Discord / Telegram ──────────────────────────────────────────────
def test_discord_preset_uses_bot_auth(client):
row = _create(client, name="Discord", url="https://discord.com/api/v10",
auth="bot", kind="discord")
assert row["kind"] == "discord" and row["auth"] == "bot"
assert connectors._headers(row["id"])["Authorization"].startswith("Bot ")
def test_telegram_secret_lives_in_url_not_in_db(client, monkeypatch):
row = _create(client, name="Telegram", kind="telegram", auth="none",
url="https://api.telegram.org/bot{secret}", secret="123456:abc")
with get_conn() as conn:
stored = conn.execute("SELECT url FROM agent_connectors WHERE id=?",
(row["id"],)).fetchone()["url"]
assert "{secret}" in stored and "123456" not in stored # jamais en clair
seen = []
async def fake_get(url, headers=None):
seen.append((url, headers))
return 200, '{"ok": true}'
monkeypatch.setattr(connectors, "_get", fake_get)
asyncio.run(connectors.connector_fetch(str(row["id"]), path="getMe"))
assert seen[0][0] == "https://api.telegram.org/bot123456:abc/getMe"
assert "Authorization" not in seen[0][1] # auth=none
def test_secret_placeholder_requires_key_and_valid_kinds(client):
no_key = client.post("/api/agent/connectors",
json={"name": "TG", "url": "https://api.telegram.org/bot{secret}"})
assert no_key.status_code == 400 and "secret" in no_key.json()["detail"]
bad_kind = client.post("/api/agent/connectors",
json={"name": "X", "url": "https://example.com/x", "kind": "slack"})
assert bad_kind.status_code == 400 and "kind" in bad_kind.json()["detail"]
bad_auth = client.post("/api/agent/connectors",
json={"name": "X", "url": "https://example.com/x", "auth": "digest"})
assert bad_auth.status_code == 400 and "auth" in bad_auth.json()["detail"]
# ── MCP : handshake, cache, outils dynamiques ───────────────────────────────
def _mcp_server(client) -> int:
return _create(client, name="Demo Server", kind="mcp", url="https://example.com/mcp")["id"]
def _fake_rpc_factory(calls):
async def fake_rpc(url, payload, headers=None):
assert url.startswith("https://example.com/mcp") # SSRF: hôte public fixe
calls.append(payload.get("method"))
method = payload.get("method")
if method == "initialize":
return {"result": {"protocolVersion": "2024-11-05",
"serverInfo": {"name": "demo"}}}
if method == "tools/list":
return {"result": {"tools": [
{"name": "search_docs", "description": "Cherche dans les notes",
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}},
"required": ["q"]}},
{"name": "Echo", "description": "Répète"},
]}}
if method == "tools/call":
return {"result": {"content": [{"type": "text", "text": "résultat utile"}]}}
return {}
return fake_rpc
def test_mcp_probe_handshakes_and_caches_tools(client, monkeypatch):
sid = _mcp_server(client)
calls = []
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory(calls))
res = asyncio.run(connectors.probe(str(sid)))
assert res["status"] == "ok", res
assert "2 outil(s)" in res["detail"]
assert calls == ["initialize", "notifications/initialized", "tools/list"]
with get_conn() as conn:
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
(sid,)).fetchone()["tools_json"]
assert "mcp_demo_server_search_docs" in tools # nom LLM calculé
rows = client.get("/api/agent/connectors").json()["connectors"]
me = {r["id"]: r for r in rows if r["id"] == sid}[sid]
assert me["kind"] == "mcp" and me["tools_count"] == 2
def test_dynamic_tools_reach_the_llm_schema_and_execute(client, monkeypatch):
sid = _mcp_server(client)
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory([]))
asyncio.run(connectors.probe(str(sid)))
reg = ToolRegistry()
schema = {t["name"]: t for t in reg.schema()}
assert "mcp_demo_server_search_docs" in schema
assert schema["mcp_demo_server_search_docs"]["parameters"]["required"] == ["q"]
assert "mcp_demo_server_echo" in schema
res = asyncio.run(reg.execute("mcp_demo_server_search_docs", {"q": "notes"}))
assert res.status == "success"
assert "résultat utile" in res.data["text"]
def test_disabled_mcp_server_hides_its_tools(client, monkeypatch):
sid = _mcp_server(client)
monkeypatch.setattr(mcp_client, "_rpc", _fake_rpc_factory([]))
asyncio.run(connectors.probe(str(sid)))
assert "mcp_demo_server_echo" in {t["name"] for t in ToolRegistry().schema()}
client.patch(f"/api/agent/connectors/{sid}", json={"enabled": False})
assert "mcp_demo_server_echo" not in {t["name"] for t in ToolRegistry().schema()}
# hors du registre → refus explicite plutôt qu'un appel réseau raté
res = asyncio.run(ToolRegistry().execute("mcp_demo_server_echo", {}))
assert res.status == "error" and "inconnu" in res.message.lower()
def test_mcp_rpc_errors_become_probe_errors(client, monkeypatch):
sid = _mcp_server(client)
async def bad_rpc(url, payload, headers=None):
if payload.get("method") == "initialize":
raise ValueError("MCP -32000 : handshake refusé")
return {}
monkeypatch.setattr(mcp_client, "_rpc", bad_rpc)
res = asyncio.run(connectors.probe(str(sid)))
assert res["status"] == "error" and "handshake" in res["detail"]
# les outils restent absents : pas de cache partiel
with get_conn() as conn:
tools = conn.execute("SELECT tools_json FROM agent_connectors WHERE id=?",
(sid,)).fetchone()["tools_json"]
assert tools == "[]"
def test_parse_body_handles_sse_and_rpc_errors():
sse = mcp_client.parse_body(
"text/event-stream; charset=utf-8",
": keep-alive\nevent: message\ndata: {\"result\": {\"ok\": 1}}\n\n", 200)
assert sse == {"result": {"ok": 1}}
assert mcp_client.parse_body("application/json", '{"result": {}}', 200) == {"result": {}}
with pytest.raises(ValueError, match="illisible"):
mcp_client.parse_body("text/html", "<html>gateway</html>", 502)
with pytest.raises(ValueError, match="-32601"):
mcp_client.parse_body("application/json",
'{"error": {"code": -32601, "message": "Method not found"}}', 200)
def test_tool_name_slug():
assert mcp_client.tool_name("Demo Server", "search-docs") == "mcp_demo_server_search_docs"
assert mcp_client.tool_name("!!", "Echo") == "mcp_echo"
# ── Teams + câblage ─────────────────────────────────────────────────────────
def test_ms365_scopes_include_teams_messages():
from app.services.oauth_connectors import PROVIDERS
assert "ChannelMessage.Read.All" in PROVIDERS["ms365"]["scopes"]
assert "Files.Read" in PROVIDERS["ms365"]["scopes"]
def test_connectors_menu_wired_for_presets(client):
src = JS_PATH.read_text(encoding="utf-8")
for token in ("connectorPreset()", "kind: f.kind || 'custom'",
"auth: f.auth || 'bearer'",
"https://discord.com/api/v10",
"https://api.telegram.org/bot{secret}", "presets[f.kind]"):
assert token in src, token
html = PANEL_HTML.read_text(encoding="utf-8")
assert "fd-plus-cn-kind" in html
for token in ('value="discord"', 'value="telegram"', 'value="mcp"',
'connectorForm.kind'):
assert token in html, token
# v7.58.0 : plus aucune section « bientôt » (plugins rendu vivant)
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert root.count("disabled:true") == 0
resp = client.get("/accounts")
assert resp.status_code == 200 and "connectorPreset" in resp.text
def test_mcp_routes_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": "https://example.com/x",
"kind": "mcp"}).status_code == 401
+142
View File
@@ -0,0 +1,142 @@
"""v7.58.0 — Add plugins : registre persistant + on/off à effet réel."""
import asyncio
from pathlib import Path
from conftest import anon_csrf
from app.services import plugins
from app.services.tool_registry import ToolRegistry
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
SETTINGS_HTML = ROOT / "app" / "templates" / "settings.html"
AUTOMATIONS_PY = ROOT / "app" / "services" / "automations.py"
def _set(client, slug: str, enabled: bool) -> dict:
r = client.patch(f"/api/agent/plugins/{slug}", json={"enabled": enabled})
assert r.status_code == 200, r.text
return r.json()
# ── Catalogue ───────────────────────────────────────────────────────────────
def test_catalog_lists_the_three_wired_plugins(client):
data = client.get("/api/agent/plugins").json()["plugins"]
assert [p["slug"] for p in data] == ["web-tools", "web-clipper", "automations"]
assert all(p["enabled"] for p in data)
assert all(p["name"] and p["description"] for p in data)
def test_unknown_slug_is_404(client):
assert client.patch("/api/agent/plugins/nope",
json={"enabled": False}).status_code == 404
def test_toggle_persists_in_db(client):
_set(client, "web-clipper", False)
assert plugins.is_enabled("web-clipper") is False # relu depuis la base
_set(client, "web-clipper", True)
assert plugins.is_enabled("web-clipper") is True
# ── Effet réel : routes refusées ────────────────────────────────────────────
def test_automations_routes_refused_when_plugin_off(client):
# 404 hors /api = redirection vers /workspaces (handler unifié de main.py)
def get():
return client.get("/workspace/automations", follow_redirects=False)
assert get().status_code == 200
_set(client, "automations", False)
assert get().status_code == 302
assert get().headers["location"] == "/workspaces"
assert client.post("/workspace/automations", json={},
follow_redirects=False).status_code == 302
assert plugins.is_enabled("automations") is False
_set(client, "automations", True)
assert get().status_code == 200
def test_scheduler_tick_guarded_when_automations_off(client):
# le scheduler doit vérifier le plugin avant chaque tick ( effet réel, pas un
# drapeau lu nulle part )
src = AUTOMATIONS_PY.read_text(encoding="utf-8")
assert 'plugins.is_enabled("automations")' in src
_set(client, "automations", False)
assert plugins.is_enabled("automations") is False
def test_web_clipper_api_and_page_refused_when_plugin_off(client):
def page():
return client.get("/extensions", follow_redirects=False)
assert client.get("/api/v2/web-clipper/status").status_code == 200
assert page().status_code == 200
_set(client, "web-clipper", False)
assert client.get("/api/v2/web-clipper/status").status_code == 404
assert client.post("/api/v2/web-clipper/clip", json={}).status_code == 404
assert page().status_code == 302 and page().headers["location"] == "/workspaces"
_set(client, "web-clipper", True)
assert client.get("/api/v2/web-clipper/status").status_code == 200
assert page().status_code == 200
# ── Effet réel : outils de l'agent ──────────────────────────────────────────
def test_web_tools_leaves_the_llm_registry_when_plugin_off(client):
names = {t["name"] for t in ToolRegistry().schema()}
assert {"web_search", "fetch_url"} <= names
_set(client, "web-tools", False)
names = {t["name"] for t in ToolRegistry().schema()}
assert not ({"web_search", "fetch_url"} & names)
res = asyncio.run(ToolRegistry().execute("fetch_url",
{"url": "https://example.com"}))
assert res.status == "error" and "inconnu" in res.message.lower()
_set(client, "web-tools", True)
names = {t["name"] for t in ToolRegistry().schema()}
assert {"web_search", "fetch_url"} <= names
# ── UI : settings rendu côté serveur + menu + ──────────────────────────────
def test_settings_hides_disabled_plugins(client):
html = client.get("/settings").text
assert "navTo('extensions')" in html and "navTo('automations')" in html # ON
_set(client, "automations", False)
_set(client, "web-clipper", False)
html = client.get("/settings").text
assert "navTo('automations')" not in html # nav retirée
assert "navTo('extensions')" not in html
assert "x-show=\"false &amp;&amp; activeSection==='automations'\"" in html
_set(client, "automations", True)
_set(client, "web-clipper", True)
html = client.get("/settings").text
assert "navTo('automations')" in html and "navTo('extensions')" in html
def test_menu_catalogue_is_live(client):
src = JS_PATH.read_text(encoding="utf-8")
for token in ("action:'plugins'", "_pluginItems()", "pluginToggle(slug)",
"/api/agent/plugins", "Add plugins"):
assert token in src, token
# la section n'est plus une coquille « bientôt »
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "disabled:true" not in root
panel = PANEL_HTML.read_text(encoding="utf-8")
assert "plusSection!=='root'" in panel # en-tête/rétour génériques
settings = SETTINGS_HTML.read_text(encoding="utf-8")
assert settings.count("plugin_enabled(") >= 4
def test_plugins_routes_require_session(client):
anon_csrf(client)
assert client.get("/api/agent/plugins").status_code == 401
assert client.patch("/api/agent/plugins/automations",
json={"enabled": False}).status_code == 401