Compare commits

...
3 Commits
Author SHA1 Message Date
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
bruno 1d7750bda0 feat: connecteurs Google + Microsoft 365 (OAuth2 PKCE) — phase 6/8 (v7.56.0)
FlowDeck CI / lint (push) Successful in 2m14s
FlowDeck CI / test (push) Failing after 16m4s
FlowDeck CI / docker (push) Skipped
- app/services/oauth_connectors.py : flow OAuth2 complet PKCE (S256) pour
  2 fournisseurs décrits par 1 dict — Google (Drive/Gmail/Calendar en lecture
  seule) et Microsoft 365 (Graph Files.Read / Mail.Read / Calendars.Read) ;
  begin() = URL d'autorisation + state + code_verifier, complete() = échange du
  code, access_token() = refresh automatique (60 s de marge, refresh_token
  conservé si absent de la réponse), api_get() = path absolu refusé + validation
  SSRF + borne 20 000 car.
- Tokens chiffrés Fernet en réutilisant calendar_sync._encrypt_tokens (zéro
  dépendance) dans la table connector_tokens (migration 33, PK (kind, user_id)).
- 4 routes /api/agent/connectors/oauth/{kind}/… : status, authorize (cookies
  d'état HttpOnly 10 min, retour same-origin validé), callback (GET safe, state
  comparé en temps constant, tokens stockés puis cookies purgés, redirection
  ?oauth=connected / ?oauth_error=), disconnect. OpenAPI 523 chemins.
- Config + .env.example : GOOGLE_CLIENT_ID/SECRET, MS_CLIENT_ID/SECRET (vidés =
  « non configuré »), redirect URI dérivé d'APP_BASE_URL.
- Catalogue : google/ms365 en natifs avec badge connecté/non connecté ;
  connector_fetch et Tester passent par l'API du fournisseur avec le token de
  l'utilisateur (user_id transmis par l'outil LLM).
- Menu + : « Se connecter » / « Déconnecter » sur la fiche, toast au retour du
  flux (URL nettoyée par history.replaceState). État dans la fiche du menu
  plutôt qu'une page dédiée.
- Tests : tests/test_v756_oauth_connectors.py (13), 0 appel réseau réel
  (_post_form / _api_get monkeypatchés) — state forgé refusé sans échange,
  tokens chiffrés en base, refresh, URL absolue refusée, 401/404, câblage menu ;
  test_v755 adapté (5 natifs). Suite complète 1319 verts (-n auto), ruff 0,
  eslint 0 erreur (19 warnings préexistants hors fichiers touchés).
2026-10-07 08:29:22 -04:00
bruno 18c72d77fe feat: menu + — Connecteurs (socle) — phase 5/8 (v7.55.0)
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / test (push) Failing after 15m16s
FlowDeck CI / docker (push) Skipped
- 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.
2026-10-06 23:26:19 -04:00
22 changed files with 2551 additions and 48 deletions
+10
View File
@@ -20,6 +20,16 @@ GITHUB_OAUTH_CLIENT_SECRET=
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
OAUTH_REDIRECT_URI=
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
# Vidés = connecteur « non configuré » (le menu + affiche la raison).
# Le redirect URI à enregistrer est dérivé d'APP_BASE_URL :
# {APP_BASE_URL}/api/agent/connectors/oauth/google/callback
# {APP_BASE_URL}/api/agent/connectors/oauth/ms365/callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
MS_CLIENT_ID=
MS_CLIENT_SECRET=
# ── App ──
APP_SECRET_KEY=change-me-to-random
APP_HOST=0.0.0.0
+118
View File
@@ -1,5 +1,123 @@
# Changelog - FlowDeck
## 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
- **`app/services/oauth_connectors.py`** — flow OAuth2 complet **PKCE (S256)** pour
2 fournisseurs décrits par 1 dict :
- **Google** : Drive / Gmail / Calendar **en lecture seule** ;
- **Microsoft 365** : Graph `Files.Read` / `Mail.Read` / `Calendars.Read` ;
- `begin()` → URL d'autorisation + state + code_verifier ; `complete()` →
échange du code ; `access_token()` → **refresh automatique** (60 s avant
expiration, `refresh_token` conservé si le fournisseur n'en renvoie pas) ;
`api_get()` → lecture bornée, `path` absolu refusé + validation SSRF ;
- tokens chiffrés **Fernet** via `_encrypt_tokens` de `calendar_sync`
(réutilisation, aucune nouvelle dépendance) dans la table
**`connector_tokens`** (migration **33**, PK `(kind, user_id)`).
- **4 routes OAuth** (`/api/agent/connectors/oauth/{kind}/…`) : `status`,
`authorize` (cookies d'état HttpOnly 10 min + retour same-origin validé),
`callback` (GET = SAFE_METHODS, `state` comparé en temps constant, tokens
stockés puis cookies purgés, redirection `?oauth=connected` /
`?oauth_error=…`), `disconnect`. **OpenAPI : 523 chemins**.
- **Config + `.env.example`** : `GOOGLE_CLIENT_ID/SECRET`, `MS_CLIENT_ID/SECRET`
(vidés = « non configuré »), redirect URI dérivé d'APP_BASE_URL.
- **Catalogue** : les 2 nouveaux natifs apparaissent avec le badge
« non connecté — lancer la connexion OAuth » / « connecté · N scope(s) » ;
`connector_fetch` et `Tester` passent par l'API Graph/Google avec le token de
l'utilisateur (outil LLM = `user_id` désormais transmis).
- **Menu +** : « Se connecter » (redirige vers le fournisseur) / « Déconnecter »
sur la fiche du connecteur, + toast au retour du flux (URL nettoyée via
`history.replaceState`).
### Tests
- `tests/test_v756_oauth_connectors.py` — **13 tests** : URL d'autorisation
(PKCE, scopes, redirect URI, cookies), 400 non configuré, callback → tokens
**chiffrés** en base + redirection + `status.connected`, **state forgé refusé
sans aucun appel réseau**, callback sans session → `oauth_error=session`,
refresh (grant_type, conservation du refresh_token), `api_get` (Bearer + hôte
fixe + URL absolue refusée), non connecté, déconnexion, catalogue, outil LLM,
401 sans session, 404 kind inconnu, câblage menu. Aucun appel réseau réel
(`_post_form` / `_api_get` monkeypatchés).
- `test_v755` adapté : **5 natifs** (gitea, github, web, google, ms365).
- **Suite complète : 1319 passed / 0 failed** (`-n auto`), `ruff` 0,
`eslint` 0 problème.
## v7.55.0 (2026-10-06) — Menu + : Connecteurs — socle (phase 5/8)
### Added
- **Table `agent_connectors`** (migration **32**) : `name`, `url`,
`secret_encrypted`, `enabled`, `status`, `detail` — colonnes **plates**, pas de
`config_json` (les scopes OAuth des phases 6-7 ajouteront le leur).
- **`app/services/connectors.py`** — 1 fichier (pas de package) :
- **3 connecteurs natifs** (`gitea`, `github`, `web`) servis à la volée, statut
dérivé de la config **sans réseau** (`native_state()`) ;
- CRUD des connecteurs personnalisés : l'URL passe par **`_validate_url`
(garde SSRF)** à la création, la clé est **chiffrée Fernet** (`encrypt_secret`)
et **jamais renvoyée** (seul `has_secret` l'est) ;
- `probe()` : appel de contrôle (`_get`, timeout 10 s, re-vérification de
l'hôte après redirection) qui **persiste** `status`/`detail` ;
- `connector_fetch()` : lecture pour l'LLM — API Gitea/GitHub (native),
recherche web (native) ou GET du connecteur personnalisé, borné à 20 000 car.
- **API** (`/api/agent/connectors`) — `GET` (catalogue), `POST` (création, 400 sur
URL privée), `PATCH` (name/enabled/secret), `DELETE`, `POST /connectors/probe`
; session requise partout (401), CSRF global. **OpenAPI : 519 chemins**.
- **Outil LLM `connector_fetch`** (`tool_registry`, 26ᵉ outil) — args
`connector` (id / nom / kind), `path`, `query` ; connecteur désactivé ou
inconnu = erreur outil, **jamais une exception qui casse le run**.
- **Menu + : section « Connecteurs »** — catalogue avec badge de statut
(✓/✗/⚠/?), fiche par connecteur (Tester, Activer/Désactiver, Supprimer — les
2 derniers masqués pour les natifs) et formulaire « Ajouter un connecteur
personnalisé » (nom, URL, clé en `type="password"`).
### Tests
- `tests/test_v755_connectors.py` — **13 tests** : 3 natifs listés, clé jamais
renvoyée + chiffrée en base (roundtrip Fernet), **5 URLs refusées** (localhost,
127.0.0.1, metadata cloud, ftp:, file:), toggle persisté + 404, 401 sans
session, probe OK (clé bien envoyée) / probe erreur persistée, outil
`connector_fetch` (schéma, succès, désactivé, inconnu), câblage du menu.
- `test_v751`/`test_v753`/`test_v754` adaptés : **1 seule section « bientôt »**
(plugins).
- **Suite complète : 1306 passed / 0 failed** (`-n auto`), `ruff` 0,
`eslint` 0 problème.
## v7.54.0 (2026-10-06) — Menu + : Mémoire de l'agent (phase 4/8)
### Added
+77 -27
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-4 ✅ 2026-10-06 · phases 5-8 planifiées)
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-7 ✅ 2026-10-06 · phase 8 planifiée)
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
> un menu à sections, tel que demandé :
@@ -416,35 +416,85 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.54.0**, OpenAPI régénéré
(516 chemins), CHANGELOG, ce ROADMAP
### Phase 5 — v7.55.0 — Connecteurs : socle · effort L
### Phase 5 — v7.55.0 — Connecteurs : socle · effort L ✅ (livrée 2026-10-06)
- [ ] **Table `agent_connectors`** (`kind`, `name`, `config`, credentials chiffrés,
`enabled`, `status`)
- [ ] **`app/services/connectors/`** — interface commune `probe() / list_sources() / fetch()`
+ 3 adapters **natifs déjà présents** : Gitea, GitHub, Web (`web_search` / `fetch_url`, v7.46)
- [ ] **Menu « Connecteurs »** — « Parcourir les connecteurs » (catalogue + statut
connecté/déconnecté) et « Ajouter un connecteur personnalisé » (URL + clé + type
HTTP / MCP)
- [ ] **Tools agent** — un tool par connecteur exposé au LLM via `tool_registry`
- [ ] **Sécurité** — garde SSRF `_is_public_host` sur toute URL, credentials chiffrés,
session requise, CSRF sur les écritures
- [ ] **Tests** — CRUD, statut, vecteurs SSRF, schéma des tools
- [x] **Table `agent_connectors`** (migration **32**) — colonnes **plates**
(`name`, `url`, `secret_encrypted`, `enabled`, `status`, `detail`) ; le
`config_json` prévu est **remis** (YAGNI : rien ne le remplissait — les scopes
OAuth des phases 6-7 ajouteront le leur)
- [x] **`app/services/connectors.py`** — **1 fichier au lieu du package
`connectors/`** promis (3 probes + 1 fetch ne justifient pas une arborescence) ;
3 natifs `gitea`/`github`/`web` servis à la volée, statut **sans réseau**
(`native_state()`) ; `probe()` et `connector_fetch()` passent par `_get()`
(gardé SSRF + re-vérification après redirection, timeout 10 s, borne 20 000 car.)
- [x] **Menu « Connecteurs »** — « Parcourir » = catalogue avec badge
(✓ ok / ✗ error / ⚠ missing / ? inconnu) + fiche (Tester, Activer/Désactiver,
Supprimer — masqués pour les natifs) ; « Ajouter un connecteur personnalisé » =
formulaire nom + URL + clé (`type="password"`)
- [x] **Tool agent** — **un seul `connector_fetch`** (id/nom/kind + `path` +
`query`) au lieu d'un outil par connecteur : même capacité, moins de bruit de
schéma — l'outil dispatche vers Gitea/GitHub/web/personnalisé. Connecteur
désactivé ou inconnu = erreur outil, jamais une exception de run
- [x] **Sécurité** — `_validate_url` (SSRF) à la création **et** à chaque appel,
clé **chiffrée Fernet** et jamais renvoyée (`has_secret` seulement), 401 sans
session sur les 5 routes, CSRF global
- [x] **Tests** — `tests/test_v755_connectors.py` : **13 tests** (3 natifs, clé
chiffrée roundtrip, **5 URLs refusées** : localhost / 127.0.0.1 / metadata
cloud / ftp: / file:, toggle persisté, 401, probe OK + probe erreur persistée,
outil complet, câblage menu) ; 3 autres fichiers de phase adaptés (1 section
« bientôt » restante) ; **suite complète 1306 verts**, `ruff` 0, `eslint` 0
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.55.0**, OpenAPI régénéré
(519 chemins), CHANGELOG, ce ROADMAP
### Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L
### Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L ✅ (livrée 2026-10-06)
- [ ] **OAuth2 PKCE Google** — Drive, Gmail, Calendar : connexion, refresh, déconnexion,
lecture limitée aux sources choisies
- [ ] **OAuth2 Microsoft (Graph)** — Outlook / OneDrive / SharePoint
- [ ] **Écran connecteur** — état, scopes, dernière synchro, bouton déconnecter
- [ ] **Tests** — callbacks, refresh, échec, **aucun appel réseau réel** (transport injecté)
- [x] **OAuth2 PKCE Google** — Drive / Gmail / Calendar **en lecture seule** ;
connexion (`begin()` → URL + state + `code_verifier` S256), échange du code,
**refresh automatique** (60 s de marge, `refresh_token` conservé si absent de
la réponse), déconnexion — table `connector_tokens` (migration **33**,
PK `(kind, user_id)`), tokens chiffrés Fernet **réutilisant**
`calendar_sync._encrypt_tokens` (zéro dépendance ajoutée)
- [x] **OAuth2 Microsoft (Graph)** — `Files.Read` / `Mail.Read` / `Calendars.Read`
+ `offline_access`, même code (2 providers décrits par **1 dict**)
- [x] **Écran connecteur** — **pas de page dédiée** : l'état vit dans la fiche du
menu + (badge « connecté · N scope(s) » / « non connecté »), boutons
**Se connecter / Déconnecter**, toast au retour du flux (`?oauth=connected` /
`?oauth_error=` nettoyés par `history.replaceState`)
- [x] **Tests** — `tests/test_v756_oauth_connectors.py` : **13 tests**, **0 appel
réseau réel** (`_post_form` / `_api_get` monkeypatchés) — URL d'autorisation
(PKCE, scopes, redirect URI, cookies), 400 non configuré, callback → tokens
chiffrés + redirection, **state forgé refusé sans échange**, callback sans
session, refresh, `api_get` (Bearer, URL absolue refusée), déconnexion,
catalogue, outil LLM, 401/404, câblage menu ; `test_v755` adapté (5 natifs) ;
**suite complète 1319 verts**, `ruff` 0, `eslint` 0
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.56.0**, `.env.example`
(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
@@ -456,8 +506,8 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
---
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 4 livrées le
2026-10-06 (v7.51.0 → v7.54.0)**, phases 5-8 à valider une par une avant « go ».
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 7 livrées le
2026-10-06 (v7.51.0 → v7.57.0)**, phase 8 (plugins) à valider avant « go ».
Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
---
+1 -1
View File
@@ -1 +1 @@
7.54.0
7.57.0
+8
View File
@@ -126,6 +126,14 @@ class Settings(BaseSettings):
agent_max_tokens_budget: int = 500000
agent_run_timeout_seconds: int = 300
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
# Client OAuth2 à créer dans les consoles : Google Cloud (OAuth client web)
# et Entra ID (application web). Le redirect URI est dérivé d'APP_BASE_URL.
google_client_id: str = ""
google_client_secret: str = ""
ms_client_id: str = ""
ms_client_secret: str = ""
# ── Mémoire de l'agent (v7.54.0) ──
# État initial du toggle « Mémoire » à la création d'une conversation ;
# le basculement se fait ensuite par PATCH /api/agent/conversations/{id}.
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.54.0",
version="7.57.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+48
View File
@@ -1586,6 +1586,54 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
)
@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."""
conn.execute(
"""CREATE TABLE IF NOT EXISTS connector_tokens (
kind TEXT NOT NULL,
user_id INTEGER NOT NULL,
tokens_enc TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (kind, user_id)
)"""
)
@register(32, "v7.55.0: connecteurs de l'agent (socle)")
def _migration_agent_connectors(conn: sqlite3.Connection) -> None:
"""Catalogue de connecteurs : 3 natifs servis à la volée + personnels ici.
Colonne ``url`` plate (pas de ``config_json``) : les scopes/OAuth des
phases 6-7 ajouteront leur propre colonne le moment venu.
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS agent_connectors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
secret_encrypted TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'unknown',
detail TEXT NOT NULL DEFAULT '',
created_by INTEGER
)"""
)
@register(31, "v7.54.0: agent memory (mémoire de l'agent)")
def _migration_agent_memory(conn: sqlite3.Connection) -> None:
"""Toggle par conversation + table des résumés mémorisés.
+173 -2
View File
@@ -7,15 +7,17 @@ from __future__ import annotations
import asyncio
import json
import logging
import secrets
from datetime import UTC
from urllib.parse import urlparse
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
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 skill_gallery
from app.services import connectors, oauth_connectors, skill_gallery
from app.services.agent_engine import AgentEngine, undo_action
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
from app.services.llm_config import (
@@ -633,6 +635,175 @@ def delete_skill(request: Request, skill_id: int):
return {"id": skill_id, "status": "deleted"}
# ── Connecteurs (v7.55.0) ──
# ponytail : catalogue partagé au même titre que les skills (`list_skills`
# n'a pas non plus de filtre par créateur) — la clé n'est jamais renvoyée.
@router.get("/connectors")
def list_connectors_route(request: Request):
"""Catalogue : natifs (statut dérivé de la config) + personnels."""
user_id = _current_user_id(request)
return {"connectors": connectors.list_connectors(user_id)}
@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 ""),
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
@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."""
target = str(body.get("connector") or "").strip()
if not target:
raise HTTPException(status_code=400, detail="connector est requis")
user_id = _current_user_id(request)
try:
return await connectors.probe(target, user_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
# ── Connecteurs OAuth : Google / Microsoft 365 (v7.56.0) ──
# Flow : POST authorize (cookies state/verifier/next) → fournisseur →
# GET callback (échange du code, tokens chiffrés) → redirection vers la page
# d'origine. Le GET est en SAFE_METHODS : pas de CSRF (comme /auth/callback).
def _oauth_kind(kind: str) -> str:
if kind not in oauth_connectors.OAUTH_KINDS:
raise HTTPException(status_code=404, detail="Connecteur OAuth inconnu")
return kind
def _oauth_next(request: Request, kind: str, key: str, default: str = "/") -> str:
"""Chemin de retour same-origin (cookie `key` pour le callback)."""
raw = request.cookies.get(key, "") or default
path = urlparse(raw).path if raw.startswith("http") else raw
if not path.startswith("/") or path.startswith("//"):
return default
return path
@router.get("/connectors/oauth/{kind}/status")
def connector_oauth_status(request: Request, kind: str):
"""État du connecteur OAuth pour l'utilisateur courant."""
_oauth_kind(kind)
user_id = _current_user_id(request)
try:
oauth_connectors._client(kind)
configured, detail = True, ""
except ValueError as exc:
configured, detail = False, str(exc)
tok = oauth_connectors.tokens(kind, user_id)
return {
"kind": kind, "configured": configured, "configured_detail": detail,
"connected": bool(tok.get("access_token")), "scope": tok.get("scope", ""),
"expires_at": tok.get("expires_at", 0),
}
@router.post("/connectors/oauth/{kind}/authorize")
def connector_oauth_authorize(request: Request, kind: str):
"""URL d'autorisation (PKCE) + cookies d'état éphémères (10 min)."""
_oauth_kind(kind)
_current_user_id(request)
try:
flow = oauth_connectors.begin(kind)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
referer = request.headers.get("referer") or ""
next_path = urlparse(referer).path if referer else "/"
if not next_path.startswith("/") or next_path.startswith("//"):
next_path = "/"
secure = request.url.scheme == "https"
resp = JSONResponse({"url": flow["url"], "kind": kind})
for key, value in (
(f"fd_oauth_state_{kind}", flow["state"]),
(f"fd_oauth_verifier_{kind}", flow["verifier"]),
(f"fd_oauth_next_{kind}", next_path),
):
resp.set_cookie(key, value, max_age=600, httponly=True, samesite="lax", secure=secure)
return resp
@router.get("/connectors/oauth/{kind}/callback")
async def connector_oauth_callback(
request: Request, kind: str, code: str = "", state: str = "", error: str = ""
):
"""Reçoit le code du fournisseur, échange, stocke, renvoie sur la page."""
_oauth_kind(kind)
next_path = _oauth_next(request, kind, f"fd_oauth_next_{kind}")
expected = request.cookies.get(f"fd_oauth_state_{kind}", "")
verifier = request.cookies.get(f"fd_oauth_verifier_{kind}", "")
def _back(flag: str) -> RedirectResponse:
sep = "&" if "?" in next_path else "?"
resp = RedirectResponse(f"{next_path}{sep}{flag}", status_code=302)
for key in (f"fd_oauth_state_{kind}", f"fd_oauth_verifier_{kind}",
f"fd_oauth_next_{kind}"):
resp.delete_cookie(key, samesite="lax")
return resp
if error:
return _back("oauth_error=" + error[:80])
if not code or not expected or not secrets.compare_digest(expected, state):
return _back("oauth_error=state")
user = get_current_user(request)
if not user or not user.get("id"):
return _back("oauth_error=session")
try:
tokens = await oauth_connectors.complete(kind, code, verifier)
except ValueError as exc:
return _back("oauth_error=" + str(exc)[:80].replace(" ", "+"))
oauth_connectors.save(kind, int(user["id"]), tokens)
logger.info("Connector OAuth connected: %s (user #%s)", kind, user["id"])
return _back("oauth=connected")
@router.post("/connectors/oauth/{kind}/disconnect")
def connector_oauth_disconnect(request: Request, kind: str):
_oauth_kind(kind)
user_id = _current_user_id(request)
removed = oauth_connectors.clear(kind, user_id)
return {"kind": kind, "status": "disconnected" if removed else "not-connected"}
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
+336
View File
@@ -0,0 +1,336 @@
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
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
(Google, M365, Discord/Telegram, MCP) arriveront avec leur propre module.
"""
from __future__ import annotations
import json
import logging
from app.config import settings
from app.db import get_conn
from app.services import oauth_connectors
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)
# ── Natifs (config, sans réseau) ─────────────────────────────────────────────
def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
"""(status, detail) d'un connecteur natif — dérivé de la config, sans réseau."""
if kind in OAUTH_KINDS:
try:
oauth_connectors._client(kind) # lève si client_id/secret absents
except ValueError as exc:
return ("missing", str(exc))
tok = oauth_connectors.tokens(kind, user_id)
if tok.get("access_token"):
scope = (tok.get("scope") or "").split()
return ("ok", f"connecté · {len(scope)} scope(s)")
return ("missing", "non connecté — lancer la connexion OAuth")
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 _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] = []
for kind in NATIVE_KINDS:
status, detail = native_state(kind, user_id)
out.append({
"id": None, "builtin": True, "kind": kind,
"name": (oauth_connectors.PROVIDERS[kind]["name"] if kind in OAUTH_KINDS
else kind.capitalize()),
"url": "", "enabled": True, "status": status, "detail": detail,
"has_secret": False, "oauth": kind in OAUTH_KINDS,
})
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
"kind, auth, tools_json FROM agent_connectors ORDER BY id"
).fetchall()
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, "
"kind, auth, tools_json FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
return _row(r) if r else None
# ── CRUD (personnels) ────────────────────────────────────────────────────────
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")
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, kind, auth) VALUES (?,?,?,?,?,?,?)",
(name, url, encrypt_secret(secret or ""), "unknown", "", kind, auth),
)
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.
``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs).
"""
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
secret, auth = "", "bearer"
if connector_id:
with get_conn() as conn:
r = conn.execute(
"SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
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).
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 (row["kind"], 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 (get(row["id"])["kind"], row["id"])
raise ValueError(f"Connecteur inconnu: {token}")
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 == "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]
elif 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))
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 not in NATIVE_KINDS:
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 = "",
user_id: int | None = None) -> dict:
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
kind, ref = _resolve(connector)
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"
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 = _with_secret(row["url"], int(ref)).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]}
+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}
+255
View File
@@ -0,0 +1,255 @@
"""Connecteurs OAuth Google / Microsoft 365 (v7.56.0 — phase 6).
Flow : ``begin()`` (URL d'autorisation PKCE S256 + state) → callback
``complete()`` (échange du code) → tokens chiffrés Fernet → ``api_get()`` avec
refresh automatique.
Réutilise : `_encrypt_tokens` / `_decrypt_tokens` (`calendar_sync`, déjà Fernet)
et `shared_client` (A42). Les scopes sont **figés en lecture seule**.
ponytail : 2 providers décrits par 1 dict (pas de classe par provider) ; les
scopes au choix et un écran de connexion dédié arriveront si le besoin se
présente — l'état vit dans le catalogue du menu +.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import secrets
import time
from urllib.parse import urlencode
from app.config import settings
from app.db import get_conn
from app.services.calendar_sync import _decrypt_tokens, _encrypt_tokens
logger = logging.getLogger(__name__)
PROVIDERS: dict[str, dict] = {
"google": {
"name": "Google (Drive · Gmail · Calendar)",
"authorize": "https://accounts.google.com/o/oauth2/v2/auth",
"token": "https://oauth2.googleapis.com/token",
"api": "https://www.googleapis.com",
"scopes": [
"openid", "email", "profile",
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
],
"extra": {"access_type": "offline", "include_granted_scopes": "true"},
"probe_path": "/oauth2/v3/userinfo",
},
"ms365": {
"name": "Microsoft 365 (Outlook · OneDrive)",
"authorize": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"token": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"api": "https://graph.microsoft.com/v1.0",
"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",
},
}
OAUTH_KINDS = tuple(PROVIDERS)
# l'access token est renouvelé 60 s avant expiration
_REFRESH_SKEW = 60
# ── Config client ───────────────────────────────────────────────────────────
def _client(kind: str) -> tuple[str, str, str]:
"""(client_id, client_secret, redirect_uri) — lève si non configuré."""
if kind not in PROVIDERS:
raise ValueError(f"Connecteur OAuth inconnu: {kind}")
if kind == "google":
cid, secret = settings.google_client_id, settings.google_client_secret
else:
cid, secret = settings.ms_client_id, settings.ms_client_secret
if not cid or not secret:
raise ValueError(
f"Connecteur {kind} non configuré (GOOGLE_CLIENT_ID/SECRET ou MS_CLIENT_ID/SECRET)"
)
redirect = f"{settings.app_base_url.rstrip('/')}/api/agent/connectors/oauth/{kind}/callback"
return cid, secret, redirect
def callback_path(kind: str) -> str:
return f"/api/agent/connectors/oauth/{kind}/callback"
def _pkce_pair() -> tuple[str, str]:
verifier = secrets.token_urlsafe(48)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return verifier, base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
# ── Flow ────────────────────────────────────────────────────────────────────
def begin(kind: str) -> dict:
"""URL d'autorisation + state + code_verifier (le route met les cookies)."""
cid, _secret, redirect = _client(kind)
verifier, challenge = _pkce_pair()
state = secrets.token_urlsafe(24)
params = {
"client_id": cid,
"redirect_uri": redirect,
"response_type": "code",
"scope": " ".join(PROVIDERS[kind]["scopes"]),
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
params.update(PROVIDERS[kind].get("extra", {}))
return {
"url": f"{PROVIDERS[kind]['authorize']}?{urlencode(params)}",
"state": state,
"verifier": verifier,
"redirect_uri": redirect,
}
def _normalize(data: dict) -> dict:
expires_in = int(data.get("expires_in") or 3600)
return {
"access_token": str(data.get("access_token") or ""),
"refresh_token": str(data.get("refresh_token") or ""),
"scope": str(data.get("scope") or ""),
"expires_at": int(time.time()) + expires_in,
}
async def complete(kind: str, code: str, verifier: str) -> dict:
"""Échange le code contre des tokens (aucun réseau ici hors `_post_form`)."""
cid, secret, redirect = _client(kind)
data = await _post_form(PROVIDERS[kind]["token"], {
"client_id": cid,
"client_secret": secret,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect,
"code_verifier": verifier,
})
tokens = _normalize(data)
if not tokens["access_token"]:
raise ValueError(str(data.get("error_description") or data.get("error") or "échec de l'échange du code"))
return tokens
# ── Stockage (chiffré Fernet, comme calendar_sync) ──────────────────────────
def save(kind: str, user_id: int, tokens: dict) -> None:
with get_conn() as conn:
conn.execute(
"INSERT INTO connector_tokens (kind, user_id, tokens_enc, updated_at) "
"VALUES (?,?,?,CURRENT_TIMESTAMP) "
"ON CONFLICT(kind, user_id) DO UPDATE SET "
"tokens_enc=excluded.tokens_enc, updated_at=CURRENT_TIMESTAMP",
(kind, user_id, _encrypt_tokens(tokens)),
)
conn.commit()
def tokens(kind: str, user_id: int | None) -> dict:
if not user_id:
return {}
with get_conn() as conn:
row = conn.execute(
"SELECT tokens_enc FROM connector_tokens WHERE kind=? AND user_id=?",
(kind, user_id),
).fetchone()
return _decrypt_tokens(row["tokens_enc"]) if row else {}
def clear(kind: str, user_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute(
"DELETE FROM connector_tokens WHERE kind=? AND user_id=?", (kind, user_id)
)
conn.commit()
return cur.rowcount > 0
def is_connected(kind: str, user_id: int | None) -> bool:
return bool(tokens(kind, user_id).get("access_token"))
# ── Réseau ──────────────────────────────────────────────────────────────────
async def _post_form(url: str, data: dict) -> dict:
"""POST x-www-form-urlencoded vers le token endpoint → dict JSON.
Point d'injection des tests (on monkeypatche ``oauth_connectors._post_form``).
"""
from app.services.http_client import shared_client
async with shared_client(timeout=15, headers={"Accept": "application/json"}) as client:
resp = await client.post(url, data=data)
return resp.json()
async def _api_get(url: str, headers: dict) -> tuple[int, str]:
"""GET d'une API fournisseur — 2ᵉ point d'injection des tests."""
from app.services.http_client import shared_client
async with shared_client(timeout=15, headers=headers) as client:
resp = await client.get(url)
return resp.status_code, (resp.text or "")[:20000]
async def access_token(kind: str, user_id: int | None) -> str:
"""Access token valide, rafraîchi (refresh_token) si nécessaire."""
tok = tokens(kind, user_id)
if not tok.get("access_token"):
return ""
if tok.get("expires_at", 0) > time.time() + _REFRESH_SKEW:
return tok["access_token"]
if not tok.get("refresh_token"):
return "" # expiré sans refresh → à reconnexion
try:
cid, secret, redirect = _client(kind)
data = await _post_form(PROVIDERS[kind]["token"], {
"client_id": cid,
"client_secret": secret,
"grant_type": "refresh_token",
"refresh_token": tok["refresh_token"],
"redirect_uri": redirect,
})
fresh = _normalize(data)
if not fresh["access_token"]:
raise ValueError(data.get("error_description") or "refresh refusé")
# Google ne renvoie parfois pas de nouveau refresh_token : on garde l'ancien
fresh["refresh_token"] = fresh["refresh_token"] or tok["refresh_token"]
save(kind, int(user_id), fresh)
return fresh["access_token"]
except Exception as exc: # noqa: BLE001 — jamais d'exception qui casse un run
logger.warning("OAuth refresh failed (%s): %s", kind, exc)
return ""
async def api_get(kind: str, user_id: int | None, path: str = "") -> dict:
"""GET sur l'API du fournisseur avec le token de l'utilisateur."""
if kind not in PROVIDERS:
return {"status": "error", "text": f"Connecteur OAuth inconnu: {kind}"}
access = await access_token(kind, user_id)
if not access:
return {"status": "error", "text": f"{kind} non connecté (lancez la connexion OAuth)"}
if "://" in (path or ""):
return {"status": "error", "text": "path doit être relatif (pas d'URL absolue)"}
# base fixe + chemin : pas d'urljoin (une URL absolue ne peut pas dévier
# l'hôte), puis validation SSRF par principe.
url = PROVIDERS[kind]["api"].rstrip("/") + "/" + (path or "").lstrip("/")
from app.services.importers.url_fetch import _validate_url
try:
_validate_url(url)
except ValueError as exc:
return {"status": "error", "text": str(exc)}
code, text = await _api_get(url, {"Authorization": f"Bearer {access}"})
if code >= 400:
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
return {"status": "ok", "text": text[:20000]}
+97 -7
View File
@@ -1072,6 +1072,79 @@ class SearchCode(Tool):
# ══════════════════════════ Registry ══════════════════════════
class ConnectorFetch(Tool):
name = "connector_fetch"
description = (
"Lit un connecteur configuré dans FlowDeck : API Gitea / GitHub (natives), "
"recherche web (natif « web »), ou un connecteur personnalisé (URL + clé). "
"`path` est relatif à l'URL du connecteur (ex. « /api/v1/repos ») ; "
"`query` sert au connecteur web. Les hôtes privés sont refusés."
)
parameters = {
"type": "object",
"properties": {
"connector": {"type": "string",
"description": "id, nom ou kind (gitea / github / web)"},
"path": {"type": "string", "description": "chemin relatif (API natives)"},
"query": {"type": "string", "description": "terme de recherche (web)"},
},
"required": ["connector"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services import connectors
try:
res = await connectors.connector_fetch(
str(args.get("connector") or ""),
str(args.get("path") or ""),
str(args.get("query") or ""),
user_id=user_id,
)
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})
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,
@@ -1080,7 +1153,7 @@ TOOL_CLASSES = [
DeletePage, DeleteCollection,
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
DeleteDocument,
WebSearch, FetchUrl, SearchCode,
WebSearch, FetchUrl, SearchCode, ConnectorFetch,
]
@@ -1090,24 +1163,41 @@ 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
for entry in mcp_client.cached_tools():
tools[entry["name"]] = McpTool(entry)
except Exception: # noqa: BLE001 — la base ne doit jamais casser un run
logger.exception("MCP 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)
+19
View File
@@ -444,6 +444,25 @@ body.fd-ap-resizing *{cursor:col-resize!important}
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
</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" 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>
<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)">
</div>
</div>
+23 -2
View File
@@ -1,7 +1,7 @@
# V74 — Menu + de l'assistant : hub de contexte (design)
> **Statut** : design — **phases 1 à 4 livrées (v7.51.0 → v7.54.0, 2026-10-06)** ;
> phases 5-8 restent à livrer. La **mémoire** a été simplifiée par rapport au
> **Statut** : design — **phases 1 à 6 livrées (v7.51.0 → v7.56.0, 2026-10-06)** ;
> phases 7-8 restent à livrer. La **mémoire** a été simplifiée par rapport au
> modèle ici : une seule ligne résumé **par conversation** (pas de lignes
> `workspace_id NULL` — rien ne les écrivait), extraction déterministe sans LLM. Adaptations faites en cours de route : le parcours
> utilise `GET /api/nav/menu?parent_id=` (un niveau par appel, contrat
@@ -202,6 +202,27 @@ 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.
## 10. Hors périmètre
- Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ.
+383 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.54.0"
"version": "7.57.0"
},
"paths": {
"/auth/register": {
@@ -16240,6 +16240,388 @@
}
}
},
"/api/agent/connectors": {
"get": {
"tags": [
"agent"
],
"summary": "List Connectors Route",
"description": "Catalogue : 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/connectors/oauth/{kind}/status": {
"get": {
"tags": [
"agent"
],
"summary": "Connector Oauth Status",
"description": "État du connecteur OAuth pour l'utilisateur courant.",
"operationId": "connector_oauth_status_api_agent_connectors_oauth__kind__status_get",
"parameters": [
{
"name": "kind",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Kind"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/connectors/oauth/{kind}/authorize": {
"post": {
"tags": [
"agent"
],
"summary": "Connector Oauth Authorize",
"description": "URL d'autorisation (PKCE) + cookies d'état éphémères (10 min).",
"operationId": "connector_oauth_authorize_api_agent_connectors_oauth__kind__authorize_post",
"parameters": [
{
"name": "kind",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Kind"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/connectors/oauth/{kind}/callback": {
"get": {
"tags": [
"agent"
],
"summary": "Connector Oauth Callback",
"description": "Reçoit le code du fournisseur, échange, stocke, renvoie sur la page.",
"operationId": "connector_oauth_callback_api_agent_connectors_oauth__kind__callback_get",
"parameters": [
{
"name": "kind",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Kind"
}
},
{
"name": "code",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "Code"
}
},
{
"name": "state",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "State"
}
},
{
"name": "error",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "Error"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/connectors/oauth/{kind}/disconnect": {
"post": {
"tags": [
"agent"
],
"summary": "Connector Oauth Disconnect",
"operationId": "connector_oauth_disconnect_api_agent_connectors_oauth__kind__disconnect_post",
"parameters": [
{
"name": "kind",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Kind"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/agent/mentions": {
"get": {
"tags": [
+202 -1
View File
@@ -40,7 +40,7 @@
{key:'skills', icon:'✨', label:'Compétences-skills',
sub:'Deep research, Skill-creator… · Gérer · Parcourir', action:'skills', chev:true},
{key:'connectors', icon:'🔌', label:'Connecteurs',
sub:'Parcourir / connecteur personnalisé — bientôt (phase 5)', disabled:true},
sub:'Parcourir les connecteurs · ajouter un personnalisé', action:'connectors', chev:true},
{key:'canvases', icon:'🎨', label:'Design System – Canevas',
sub:'Galerie de canevas : créer, insérer, enregistrer', action:'canvases', chev:true},
{key:'plugins', icon:'🧩', label:'Add plugins',
@@ -74,6 +74,9 @@
skillForm: {id: null, name: '', description: '', prompt_template: ''},
galleryFilter: '',
canvases: [], canvas: null,
connectors: [], connector: null,
connectorForm: {name: '', url: '', secret: '', kind: 'custom',
auth: 'bearer', hint: ''},
cmdFocus: -1, slashDismiss: false,
toastMsg: '', toastErr: false, _toastTimer: null,
_livePh: null,
@@ -84,6 +87,18 @@
init(){
var self = this;
var el = document.getElementById('fd-agent-panel');
// Retour du flux OAuth (Google / M365) : signaler le résultat puis nettoyer l'URL.
try{
var qs = new URLSearchParams(window.location.search);
if(qs.get('oauth') || qs.get('oauth_error')){
this.toast(qs.get('oauth_error')
? 'Connexion du connecteur refusée : ' + qs.get('oauth_error')
: 'Connecteur connecté.', !!qs.get('oauth_error'));
qs.delete('oauth'); qs.delete('oauth_error');
window.history.replaceState(null, '', window.location.pathname
+ (qs.toString() ? '?' + qs.toString() : '') + window.location.hash);
}
}catch{ /* volontaire */ }
function applyState(){
if(el){ if(self.open){ el.classList.add('open'); el.setAttribute('aria-hidden','false'); } else { el.classList.remove('open'); el.setAttribute('aria-hidden','true'); } }
@@ -1128,6 +1143,9 @@
if(s === 'skills-manage') return 'Gérer les compétences';
if(s === 'skills-gallery') return 'Parcourir les compétences';
if(s === 'skills-edit') return this.skillForm.id ? 'Modifier le skill' : 'Nouveau skill';
if(s === 'connectors') return 'Connecteurs';
if(s === 'connector') return this.connector ? this.connector.name : 'Connecteur';
if(s === 'connector-new') return 'Ajouter un connecteur';
if(s === 'canvases') return 'Design System – Canevas';
if(s === 'canvas') return this.canvas ? this.canvas.name : 'Canevas';
return 'Ajouter au contexte';
@@ -1140,6 +1158,9 @@
if(s === 'skills-manage') return this._manageItems();
if(s === 'skills-gallery') return this._galleryItems();
if(s === 'skills-edit') return []; // formulaire, pas de liste
if(s === 'connectors') return this._connectorItems();
if(s === 'connector') return this._connectorActionItems();
if(s === 'connector-new') return [];
if(s === 'canvases') return this._canvasItems();
if(s === 'canvas') return this._canvasActionItems();
if(s === 'root'){
@@ -1230,6 +1251,166 @@
if(!out.length) out.push({key:'g-none', _sep: f ? 'Aucun résultat' : 'Galerie vide'});
return out;
},
// ── Connecteurs (v7.55.0) ──
fetchConnectors(){
var self = this;
fetch('/api/agent/connectors').then(function(r){ return r.json(); })
.then(function(d){
self.connectors = d.connectors || [];
// garde la fiche ouverte alignée sur la liste rafraîchie
var sel = self.connector;
if(sel){
var found = null;
self.connectors.forEach(function(c){
if((sel.id != null && c.id === sel.id) || (sel.id == null && c.kind === sel.kind)) found = c;
});
self.connector = found;
}
}).catch(function(){ self.connectors = []; });
},
_connectorStatus(c){
var mark = c.status === 'ok' ? '✓ ' : (c.status === 'error' ? '✗ '
: (c.status === 'missing' ? '⚠ ' : '? '));
var tools = (c.kind === 'mcp' && c.tools_count) ? ' · ' + c.tools_count + ' outil(s)' : '';
return mark + (c.detail || c.status) + tools + (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.oauth){
var connected = c.status === 'ok';
items.push({key:'cn-oauth', icon: connected ? '🔓' : '🔑',
label: connected ? 'Déconnecter' : 'Se connecter',
sub: connected ? 'Retire les autorisations de ce compte'
: 'Ouvre la page de connexion du fournisseur',
action: connected ? 'connector-disconnect' : 'connector-connect'});
}
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); });
},
// ── Connexion OAuth Google / M365 (v7.56.0) ──
connectorConnect(){
var self = this, c = this.connector;
if(!c || !c.oauth) return;
fetch('/api/agent/connectors/oauth/' + c.kind + '/authorize', {
method:'POST', 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((res.d && res.d.detail) || 'Connexion impossible.', true); return; }
window.location.href = res.d.url; // → fournisseur → callback → retour ici
}).catch(function(){ self.toast('Connexion impossible (réseau).', true); });
},
connectorDisconnect(){
var self = this, c = this.connector;
if(!c || !c.oauth) return;
fetch('/api/agent/connectors/oauth/' + c.kind + '/disconnect', {
method:'POST', 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('Déconnexion impossible.', true); return; }
self.toast('Connecteur déconnecté.');
self.fetchConnectors();
}).catch(function(){ self.toast('Déconnexion 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); });
},
// 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()){
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 || '',
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; }
self.toast('Connecteur « ' + res.d.name + ' » ajouté.');
self.connectorForm = {name:'', url:'', secret:''};
self.plusSection = 'connectors'; self.plusFocus = 0;
self.fetchConnectors();
}).catch(function(){ self.toast('Création impossible (réseau).', true); });
},
// ── Design System – Canevas (v7.53.0) ──
fetchCanvases(){
var self = this;
@@ -1386,6 +1567,23 @@
if(this.$refs.skillImport) this.$refs.skillImport.click();
return;
}
// ── Connecteurs (v7.55.0) ──
if(it.action === 'connectors'){
this.fetchConnectors(); this.plusSection = 'connectors'; this.plusFocus = 0; return;
}
if(it.action === 'connector'){
this.connector = it.connector; this.plusSection = 'connector'; this.plusFocus = 0; return;
}
if(it.action === 'connector-new'){
this.connectorForm = {name:'', url:'', secret:'', kind:'custom',
auth:'bearer', hint:''};
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; }
if(it.action === 'connector-connect'){ this.connectorConnect(); return; }
if(it.action === 'connector-disconnect'){ this.connectorDisconnect(); return; }
// ── Design System – Canevas (v7.53.0) ──
if(it.action === 'canvases'){
this.fetchCanvases(); this.plusSection = 'canvases'; this.plusFocus = 0; return;
@@ -1416,6 +1614,9 @@
this.plusSection = 'skills'; this.plusFocus = 0; return;
}
if(sec === 'canvas'){ this.plusSection = 'canvases'; this.plusFocus = 0; return; }
if(sec === 'connector' || sec === 'connector-new'){
this.plusSection = 'connectors'; this.plusFocus = 0; return;
}
this.plusSection = 'root';
this.plusFocus = 0;
},
+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-4 livrées → il reste 2 sections « bientôt » (connecteurs, plugins)
assert root.count("disabled:true") == 2
# phases 1-5 livrées → il reste 1 section « bientôt » (plugins)
assert root.count("disabled:true") == 1
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
assert "action:'search'" in files and "action:'browse'" in files
+2 -2
View File
@@ -64,8 +64,8 @@ def test_canvases_menu_wired(client):
src = JS_PATH.read_text(encoding="utf-8")
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'canvases'" in root
# phases 1-4 livrées → 2 sections « bientôt » restantes (connecteurs, plugins)
assert root.count("disabled:true") == 2
# phases 1-5 livrées → 1 section « bientôt » restante (plugins)
assert root.count("disabled:true") == 1
for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"):
assert action in src, action
# piège /use : la clé builtin part DANS LE BODY, pas en query string
+2 -2
View File
@@ -129,8 +129,8 @@ def test_memory_menu_toggle_wired():
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert "action:'memory'" in root
assert "bientôt (phase 4)" not in root
# phases 1-4 livrées → connecteurs + plugins restent désactivés
assert root.count("disabled:true") == 2
# phases 1-5 livrées → seul Plugins reste désactivé
assert root.count("disabled:true") == 1
assert "toggleMemory()" in src
assert "memory_enabled: next" in src
engine = ENGINE.read_text(encoding="utf-8")
+173
View File
@@ -0,0 +1,173 @@
"""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", "google", "ms365"}
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"
# les connecteurs OAuth n'existent que si configurés (client_id/secret)
assert native["google"]["oauth"] is True
assert native["google"]["status"] == "missing"
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
+231
View File
@@ -0,0 +1,231 @@
"""v7.56.0 — Connecteurs OAuth Google / Microsoft 365 (PKCE, tokens, refresh)."""
import asyncio
import time
from pathlib import Path
import pytest
from conftest import anon_csrf
from app.db import get_conn
from app.services import oauth_connectors
ROOT = Path(__file__).resolve().parent.parent
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
@pytest.fixture
def configured(monkeypatch):
"""Clients OAuth fictifs (aucune clé réelle nécessaire pour les tests)."""
from app.config import settings
monkeypatch.setattr(settings, "google_client_id", "gid-test")
monkeypatch.setattr(settings, "google_client_secret", "gsec-test")
monkeypatch.setattr(settings, "ms_client_id", "mid-test")
monkeypatch.setattr(settings, "ms_client_secret", "msec-test")
monkeypatch.setattr(settings, "app_base_url", "https://flowdeck.example")
return settings
def _begin(client, kind="google"):
r = client.post(f"/api/agent/connectors/oauth/{kind}/authorize")
assert r.status_code == 200, r.text
return r.json()
def test_authorize_url_has_pkce_scope_and_state(client, configured):
data = _begin(client)
url = data["url"]
assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth?")
assert "client_id=gid-test" in url
assert "code_challenge=" in url and "code_challenge_method=S256" in url
assert "drive.readonly" in url and "gmail.readonly" in url and "calendar.readonly" in url
assert "state=" in url
assert "redirect_uri=" in url and "api%2Fagent%2Fconnectors%2Foauth%2Fgoogle%2Fcallback" in url
# les cookies d'état partent avec la réponse (10 min, HttpOnly)
for key in ("fd_oauth_state_google", "fd_oauth_verifier_google", "fd_oauth_next_google"):
assert key in client.cookies
def test_authorize_without_client_config_is_400(client):
r = client.post("/api/agent/connectors/oauth/google/authorize")
assert r.status_code == 400
assert "non configuré" in r.json()["detail"]
def test_callback_saves_tokens_encrypted_and_redirects(client, configured, monkeypatch):
_begin(client)
state = client.cookies["fd_oauth_state_google"]
async def fake_post(url, form=None, **kw):
assert url == "https://oauth2.googleapis.com/token"
assert form["grant_type"] == "authorization_code"
assert form["code"] == "abc123"
assert form["code_verifier"] == client.cookies["fd_oauth_verifier_google"]
return {"access_token": "at-1", "refresh_token": "rt-1",
"expires_in": 3600, "scope": "openid email"}
monkeypatch.setattr(oauth_connectors, "_post_form", fake_post)
resp = client.get(
"/api/agent/connectors/oauth/google/callback",
params={"code": "abc123", "state": state},
follow_redirects=False,
)
assert resp.status_code == 302
assert "oauth=connected" in resp.headers["location"]
with get_conn() as conn:
row = conn.execute("SELECT tokens_enc FROM connector_tokens").fetchone()
assert row and "at-1" not in row["tokens_enc"] # chiffré
assert oauth_connectors.tokens("google", 1)["access_token"] == "at-1"
status = client.get("/api/agent/connectors/oauth/google/status").json()
assert status["connected"] is True and status["configured"] is True
def test_callback_rejects_state_mismatch(client, configured, monkeypatch):
called = []
async def fake_post(url, form=None, **kw):
called.append(form)
return {"access_token": "at"}
monkeypatch.setattr(oauth_connectors, "_post_form", fake_post)
resp = client.get(
"/api/agent/connectors/oauth/google/callback",
params={"code": "abc", "state": "forged"}, # != cookie
follow_redirects=False,
)
assert resp.status_code == 302 and "oauth_error=state" in resp.headers["location"]
assert called == []
assert not oauth_connectors.is_connected("google", 1)
def test_callback_without_session_redirects(client, configured, monkeypatch):
_begin(client)
state = client.cookies["fd_oauth_state_google"]
async def fake_post(url, form=None, **kw):
return {"access_token": "at"}
monkeypatch.setattr(oauth_connectors, "_post_form", fake_post)
# on retire la SEULE session (anon_csrf effacerait aussi le cookie d'état OAuth)
client.cookies.delete("flowdeck_session")
client.auth = None
resp = client.get(
"/api/agent/connectors/oauth/google/callback",
params={"code": "abc", "state": state},
follow_redirects=False,
)
assert resp.status_code == 302 and "oauth_error=session" in resp.headers["location"]
def test_refresh_token_flow(client, configured, monkeypatch):
oauth_connectors.save("google", 1, {
"access_token": "old", "refresh_token": "rt-keep",
"expires_at": int(time.time()) - 10, "scope": "openid",
})
calls = []
async def fake_post(url, form=None, **kw):
calls.append(form)
return {"access_token": "new", "expires_in": 3600} # pas de refresh_token
monkeypatch.setattr(oauth_connectors, "_post_form", fake_post)
access = asyncio.run(oauth_connectors.access_token("google", 1))
assert access == "new"
assert calls[0]["grant_type"] == "refresh_token"
assert calls[0]["refresh_token"] == "rt-keep"
stored = oauth_connectors.tokens("google", 1)
assert stored["refresh_token"] == "rt-keep" # conservé si absent de la réponse
assert stored["expires_at"] > time.time()
def test_api_get_bearer_and_refuses_absolute_url(client, configured, monkeypatch):
oauth_connectors.save("google", 1, {"access_token": "at", "refresh_token": "",
"expires_at": int(time.time()) + 3600,
"scope": "openid"})
seen = []
async def fake_get(url, headers=None):
seen.append((url, headers))
return 200, '{"emails": 3}'
monkeypatch.setattr(oauth_connectors, "_api_get", fake_get)
res = asyncio.run(oauth_connectors.api_get("google", 1, "/gmail/v1/users/me/profile"))
assert res["status"] == "ok"
assert seen[0][0].startswith("https://www.googleapis.com/gmail/v1/")
assert seen[0][1]["Authorization"] == "Bearer at"
# une URL absolue dans `path` ne doit pas dévier (hôte fixe + validation)
res2 = asyncio.run(oauth_connectors.api_get("google", 1, "https://evil.example/x"))
assert res2["status"] == "error"
assert len(seen) == 1 # aucun appel réseau
def test_api_get_requires_connection(client, configured):
res = asyncio.run(oauth_connectors.api_get("google", 1, "/me"))
assert res["status"] == "error" and "non connecté" in res["text"]
def test_disconnect_clears_tokens(client, configured):
oauth_connectors.save("ms365", 1, {"access_token": "at", "expires_at": 1, "scope": ""})
assert oauth_connectors.is_connected("ms365", 1)
r = client.post("/api/agent/connectors/oauth/ms365/disconnect")
assert r.status_code == 200 and r.json()["status"] == "disconnected"
assert not oauth_connectors.is_connected("ms365", 1)
def test_catalogue_marks_oauth_connectors(client, configured):
rows = client.get("/api/agent/connectors").json()["connectors"]
by_kind = {r["kind"]: r for r in rows if r["builtin"]}
assert "google" in by_kind and "ms365" in by_kind
assert by_kind["google"]["oauth"] is True
assert by_kind["google"]["status"] == "missing" # configuré mais pas connecté
assert "non connecté" in by_kind["google"]["detail"]
oauth_connectors.save("google", 1, {"access_token": "at", "expires_at": 9_999_999_999,
"scope": "openid email drive"})
rows = client.get("/api/agent/connectors").json()["connectors"]
g = {r["kind"]: r for r in rows if r["builtin"]}["google"]
assert g["status"] == "ok" and "connecté" in g["detail"]
def test_tool_dispatches_to_oauth_api(client, configured, monkeypatch):
from app.services.tool_registry import ToolRegistry
oauth_connectors.save("google", 1, {"access_token": "at", "refresh_token": "",
"expires_at": int(time.time()) + 3600,
"scope": "openid"})
async def fake_get(url, headers=None):
assert url.startswith("https://www.googleapis.com/")
assert headers["Authorization"] == "Bearer at"
return 200, '{"name": "Bruno"}'
monkeypatch.setattr(oauth_connectors, "_api_get", fake_get)
res = asyncio.run(ToolRegistry().execute(
"connector_fetch", {"connector": "google", "path": "/oauth2/v3/userinfo"},
user_id=1))
assert res.status == "success"
assert "Bruno" in res.data["text"]
def test_oauth_routes_require_session(client):
anon_csrf(client)
assert client.get("/api/agent/connectors/oauth/google/status").status_code == 401
assert client.post("/api/agent/connectors/oauth/google/authorize").status_code == 401
assert client.post("/api/agent/connectors/oauth/google/disconnect").status_code == 401
# kind inconnu → 404 même authentifié
assert client.post("/api/agent/connectors/oauth/dropbox/authorize").status_code == 404
def test_oauth_menu_wired():
src = JS_PATH.read_text(encoding="utf-8")
for token in ("'connector-connect'", "'connector-disconnect'",
"connectorConnect()", "connectorDisconnect()",
"qs.get('oauth_error')", "Connecteur connecté."):
assert token in src, token
assert "c.oauth" in src
# le fournisseur revient avec `oauth=connected` (côté route)
router = (ROOT / "app" / "routers" / "agent.py").read_text(encoding="utf-8")
assert "oauth=connected" in router
+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
# 1 seule section « bientôt » restante (plugins)
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
assert root.count("disabled:true") == 1
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