Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8acb906e5 | ||
|
|
1d7750bda0 | ||
|
|
18c72d77fe |
@@ -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
@@ -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
@@ -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.*
|
||||
|
||||
---
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 👍 / 👎) ──
|
||||
|
||||
|
||||
|
||||
@@ -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]}
|
||||
@@ -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}
|
||||
@@ -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]}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user