From 8d6f5fbf1c899e6eff43499419e38d70389f0073 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Thu, 22 Oct 2026 01:54:32 +0200 Subject: [PATCH] Modifications en cours --- .ressources/AGENT.md | 4 +- GUIDE_PERMISSIONS.md | 116 +++++- MAIA.md | 0 README.md | 60 +++- SPECS_TECH.md | 36 +- packages/agent/src/agents.ts | 2 +- packages/agent/src/index.ts | 4 + packages/agent/src/init.test.ts | 4 +- packages/agent/src/init.ts | 20 +- packages/agent/src/lot2.test.ts | 6 +- packages/agent/src/mcp-client.ts | 340 ++++++++++++++++++ packages/agent/src/mcp-config.ts | 139 +++++++ packages/agent/src/mcp-rules.ts | 109 ++++++ packages/agent/src/mcp.test.ts | 316 ++++++++++++++++ packages/agent/src/mcp.ts | 104 ++++++ packages/agent/src/new-agent.test.ts | 2 +- .../agent/src/permissions-redirect.test.ts | 83 +++++ packages/agent/src/permissions.ts | 142 +++++++- packages/cli/src/index.ts | 28 +- packages/core/src/extensions.ts | 48 --- packages/core/src/registry.ts | 20 -- packages/ui/src/components.tsx | 29 ++ packages/ui/src/index.ts | 2 +- packages/ui/src/ink-ui.tsx | 7 +- packages/ui/src/store.test.ts | 9 + packages/ui/src/store.ts | 17 +- pnpm-lock.yaml | 254 ++++++------- pnpm-workspace.yaml | 2 + 28 files changed, 1667 insertions(+), 236 deletions(-) delete mode 100644 MAIA.md create mode 100644 packages/agent/src/mcp-client.ts create mode 100644 packages/agent/src/mcp-config.ts create mode 100644 packages/agent/src/mcp-rules.ts create mode 100644 packages/agent/src/mcp.test.ts create mode 100644 packages/agent/src/mcp.ts create mode 100644 packages/agent/src/permissions-redirect.test.ts delete mode 100644 packages/core/src/extensions.ts delete mode 100644 packages/core/src/registry.ts diff --git a/.ressources/AGENT.md b/.ressources/AGENT.md index f0796dd..63912cd 100644 --- a/.ressources/AGENT.md +++ b/.ressources/AGENT.md @@ -1,13 +1,13 @@ --- # ════════════════════════════════════════════════════════════════════════════ -# MODÈLE D'AGENT MAIA — créé par /newAgent +# MODÈLE D'AGENT MAIA — créé par /createAgent # Modifiez ce fichier : changez la description et les trois sections du bas. # Les lignes qui commencent par # dans l'en-tête sont des commentaires. # Pour activer une propriété facultative (exemples collés au #), supprimez simplement le # au début de sa ligne. # ════════════════════════════════════════════════════════════════════════════ # name (OBLIGATOIRE) — le nom de l'agent. Il doit être identique au nom du dossier. -# /newAgent le renseigne pour vous : ne le modifiez pas, sauf si vous renommez aussi le dossier. +# /createAgent le renseigne pour vous : ne le modifiez pas, sauf si vous renommez aussi le dossier. name: code_reviewer # description (OBLIGATOIRE) — une phrase courte : à quoi sert l'agent, et quand NE PAS l'utiliser. diff --git a/GUIDE_PERMISSIONS.md b/GUIDE_PERMISSIONS.md index e360e5b..21e3388 100644 --- a/GUIDE_PERMISSIONS.md +++ b/GUIDE_PERMISSIONS.md @@ -78,7 +78,8 @@ MAIA découpe la commande aux séparateurs `&&`, `||`, `;`, `|` et aux retours - Pour **`allow`** : **tous** les morceaux doivent être couverts par une règle. Un seul morceau non couvert, et MAIA redemande confirmation. - Pour **`deny` et `ask`** : **un seul** morceau qui correspond suffit. Le découpage est même plus fin : il coupe aussi aux `$(…)`, aux backticks, aux parenthèses et aux redirections, pour retrouver une commande cachée dans `echo $(rm -rf x)`. -- Une commande qui contient une substitution (`$(…)`, backticks, `<(…)`) ou une redirection (`>`, `>>`) n'est **jamais** autorisée automatiquement par un `allow` : MAIA demande toujours. +- Une commande qui contient une substitution (`$(…)`, backticks, `<(…)`, `>(…)`) n'est **jamais** autorisée automatiquement par un `allow` : MAIA demande toujours. +- Une commande qui contient une **redirection de sortie** (`>`, `>>`, `2>`, `&>`…) n'est autorisée que si le fichier visé l'est aussi par une règle `Redirect` (§ 8). Sans règle `Redirect`, MAIA demande toujours. ### 4.2 Écrire un motif @@ -112,7 +113,8 @@ Résultats : | `ls -la src` | autorisée | | `lsof` | confirmation (autre commande) | | `ls && rm -rf x` | confirmation (le morceau `rm -rf x` n'est pas couvert) | -| `ls > sortie.txt` | confirmation (redirection) | +| `ls > sortie.txt` | confirmation (redirection vers un fichier, voir § 8) | +| `ls 2>&1` | autorisée (duplication de flux, aucun fichier) | | `ls $(pwd)` | confirmation (substitution) | Pour autoriser plusieurs commandes de lecture : @@ -198,17 +200,96 @@ Pour **demander confirmation** plutôt qu'interdire (ex. `git push`), utilisez ` Même avec `"Bash(git *)"` en `allow`, ces commandes redemandent confirmation. -## 8. Limites : `deny` n'est pas un bac à sable +## 8. Redirections (`>`, `>>`, `2>`, `&>`…) + +Une redirection de sortie écrit dans un fichier **sans passer par l'outil `Write`**. Elle est donc contrôlée à part, avec des règles **`Redirect(chemin)`** (ou `Redirect` seul pour « toutes les redirections »). Le chemin suit la même syntaxe que `Read`/`Write` : relatif au dossier de lancement, `*` et `**` acceptés, un dossier couvre son contenu. + +### 8.1 Comportement par défaut : on demande + +Tant qu'aucune règle `Redirect` ne les autorise, les redirections vers un fichier **repassent en confirmation**, même si la commande elle-même est autorisée : + +```json +{ "permissions": { "allow": ["Bash(ls *)"] } } +``` + +| Commande | Décision | +| --- | --- | +| `ls` | autorisée | +| `ls > out.txt` · `ls >> out.txt` · `ls 2> err.txt` · `ls &> all.txt` | confirmation | +| `ls 2>&1` · `ls >&2` | autorisées : ce sont des **duplications de flux**, elles n'écrivent dans aucun fichier | +| `echo "a > b"` (avec `Bash(echo *)`) | autorisée : un `>` entre guillemets n'est pas une redirection | + +C'est la configuration la plus sûre : rien à faire pour **ne pas** autoriser les redirections. + +### 8.2 Autoriser certaines redirections + +Ajoutez des règles `Redirect(...)` dans `allow`. Le fichier visé doit être couvert **et** la commande doit être autorisée par ailleurs (`Redirect` seul n'autorise aucune commande) : + +```json +{ + "permissions": { + "allow": [ + "Bash(ls *)", "Bash(npm run build *)", + "Redirect(/dev/null)", + "Redirect(logs/**)" + ] + } +} +``` + +| Commande | Décision | +| --- | --- | +| `ls > /dev/null` · `ls 2>/dev/null` | autorisées | +| `npm run build >> logs/build.log 2>&1` | autorisée | +| `ls > src/main.ts` | confirmation (fichier non couvert) | +| `ls > logs/a.log > src/x` | confirmation : **toutes** les cibles doivent être couvertes | +| `cat f > logs/a.log` | confirmation : `cat` n'est pas autorisé | +| `ls > logs/*.txt` · `ls > $HOME/x` · `ls > logs/../src/a` | confirmation : une cible qui dépend du shell (`*`, `?`, `[ ]`, `{ }`, `$`) n'est jamais considérée comme couverte | + +Pour autoriser **toutes** les redirections (à réserver à un environnement jetable ou combiné à des `deny`) : `"Redirect"`. + +### 8.3 Interdire ou toujours demander + +`deny` et `ask` s'appliquent même si la commande est autorisée, et même avec `"Bash"` complet dans `allow` : + +```json +{ + "permissions": { + "allow": ["Bash", "Redirect"], + "deny": ["Redirect(.env)", "Redirect(.git/**)", "Redirect(~/**)"], + "ask": ["Redirect(docs/**)"] + } +} +``` + +| Commande | Décision | +| --- | --- | +| `echo x > .env` | refusée | +| `echo x >> .git/config` | refusée | +| `echo x >> ~/.bashrc` | refusée (le `~` est comparé tel qu'il est écrit : le motif `~/**` le reconnaît) | +| `echo y 2> .env` (placé dans une suite `a; b`) | refusée | +| `echo x > docs/a.md` | confirmation | +| `echo x > notes.txt` | autorisée | + +### 8.4 À savoir + +- `Redirect` ne concerne que les redirections de **sortie**. Une redirection d'entrée (`< fichier`) lit un fichier : elle n'est pas contrôlée (utilisez `Bash(cat .env)` ou `Read(.env)` en `deny` pour protéger une lecture). +- Une redirection sans commande (`> fichier`, qui vide un fichier), un guillemet non fermé ou une cible absente : confirmation. +- Les substitutions (`> $(…)`, `>(…)`) sont toujours en confirmation. +- D'autres commandes écrivent sans `>` : `tee`, `cp`, `mv`, `dd of=…`, `sed -i`. Autorisez-les (ou interdisez-les) explicitement avec des règles `Bash(...)`, par exemple `deny: ["Bash(tee *)", "Bash(* tee *)"]`. +- Les règles `Redirect` vont dans les mêmes fichiers `settings.json` que les autres et s'additionnent comme elles. Un sous-agent peut en ajouter dans la propriété `permissions` de son `AGENT.md`. + +## 9. Limites : `deny` n'est pas un bac à sable Les règles analysent le **texte** de la commande. Elles ne peuvent pas tout voir : - `find . -delete`, `git clean -fd`, `truncate`, `mv x /dev/null`, `python -c "import os; os.remove('f')"` ou `perl -e …` suppriment des fichiers **sans** écrire `rm`. Ajoutez les règles correspondantes si elles vous importent (`Bash(find * -delete*)`, `Bash(git clean *)`), ou n'autorisez que les commandes dont vous avez besoin (liste `allow` courte) et laissez le reste en confirmation. - Une commande lancée depuis un script (`bash script.sh`) n'est pas analysée : le contenu du script est invisible. -- Les `deny` sur les outils de fichiers (`Read(.env)`, `Write(.git/**)`) ne concernent que ces outils : `Bash(cat .env)` est une autre règle, à ajouter si besoin. +- Les `deny` sur les outils de fichiers (`Read(.env)`, `Write(.git/**)`) ne concernent que ces outils : `Bash(cat .env)` ou une redirection `> .env` sont d'autres règles (`Bash(...)`, `Redirect(...)`), à ajouter si besoin. Approche conseillée : peu de `allow` (lecture seule, tests, build), quelques `ask` (publication, écriture hors projet), des `deny` pour ce qui ne doit jamais arriver (`rm`, `sudo`, `curl … | sh`), et **laisser le défaut** (confirmation) pour tout le reste. -## 9. Sous-agents +## 10. Sous-agents Un sous-agent hérite des permissions de l'agent principal. Sa définition (`AGENT.md`) peut en **ajouter** : @@ -220,7 +301,23 @@ permissions: Les règles s'additionnent : un `deny` ou un `ask` hérité n'est jamais levé par un sous-agent. Quand un sous-agent a besoin d'une confirmation, la question s'affiche dans l'interface principale, préfixée `[sous-agent nom]`. -## 10. Aide-mémoire +## 11. Serveurs MCP : `Mcp(serveur[,outil])` + +Les serveurs MCP sont déclarés une fois pour toutes dans `~/.maia/mcp.json` (voir le README) ; **aucun n'est actif tant qu'une règle `allow` ne l'autorise**. Syntaxe : `Mcp(clé du serveur)` ou `Mcp(clé du serveur,outil)`. + +| Règle | Effet | +| --- | --- | +| `allow: ["Mcp(github)"]` | active `github` ; tous ses outils sont autorisés sans confirmation | +| `deny: ["Mcp(github,delete_repository)"]` | l'outil n'est jamais proposé au modèle (`*` accepté : `Mcp(github,delete_*)`) | +| `ask: ["Mcp(github,merge_pull_request)"]` | cet outil demande confirmation malgré le `allow` du serveur | +| `allow: ["Mcp(github)"]` + `deny: ["Mcp(github,*)"]` | serveur **désactivé** : MAIA ne tente pas de s'y connecter | +| `allow: ["Mcp(*)"]` (= `Mcp(*,*)`) | active **tous** les serveurs déclarés dans `mcp.json`, tous leurs outils autorisés | +| `deny: ["Mcp(*,delete_repository)"]` | retire cet outil (motif accepté) sur tous les serveurs ; en `ask` : confirmation partout | +| `allow: ["Mcp(github,list_issues)"]` | active `github` ; seul cet outil est autorisé, les autres demandent confirmation | + +Ces règles sont lues **avant** toute connexion ; deny > ask > allow, comme pour les autres outils. Mettre `allow` dans `~/.maia/settings.json` active le serveur pour tous les projets. + +## 12. Aide-mémoire ```json { @@ -230,6 +327,7 @@ Les règles s'additionnent : un `deny` ou un `ask` hérité n'est jamais levé p "Edit(src/**)", "Bash(ls *)", "Bash(git status)", "Bash(git diff *)", "Bash(npm run test:*)", + "Redirect(/dev/null)", "Redirect(logs/**)", "WebFetch(domain:docs.python.org)" ], "ask": [ @@ -238,7 +336,8 @@ Les règles s'additionnent : un `deny` ou un `ask` hérité n'est jamais levé p "deny": [ "Bash(rm *)", "Bash(* rm *)", "Bash(sudo *)", "Bash(* sudo *)", - "Read(.env)", "Write(.git/**)" + "Read(.env)", "Write(.git/**)", + "Redirect(.env)", "Redirect(.git/**)" ] } } @@ -252,5 +351,8 @@ Les règles s'additionnent : un `deny` ou un `ask` hérité n'est jamais levé p | autoriser une commande avec tube | une règle **par morceau** (§ 6) | | toujours confirmer une commande | `ask: ["Bash(git push *)"]` | | interdire une commande partout, tubes compris | `deny: ["Bash(rm *)", "Bash(* rm *)"]` | +| autoriser les redirections vers `/dev/null` et `logs/` | `allow: ["Bash(ls *)", "Redirect(/dev/null)", "Redirect(logs/**)"]` (§ 8.2) | +| ne pas autoriser les redirections | ne rien ajouter : elles demandent confirmation (§ 8.1) | +| interdire d'écrire dans un fichier par `>` | `deny: ["Redirect(.env)"]` (§ 8.3) | | protéger un fichier | `deny: ["Read(.env)", "Write(.env)", "Edit(.env)"]` | | limiter les sites web | `allow: ["WebFetch(domain:docs.python.org)"]` | diff --git a/MAIA.md b/MAIA.md deleted file mode 100644 index e69de29..0000000 diff --git a/README.md b/README.md index fc60daa..8d64454 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,9 @@ Spécifications techniques détaillées (contrats, formats, constantes) : [`SPEC | `/permissions` | affiche les règles de permissions chargées, par fichier : badges `ALLOW`, `ASK`, `DENY` suivis des règles | | `/model` | recharge la liste des modèles LiteLLM et change de modèle (conversation conservée) | | `/clear` | supprime le contexte de la session et recharge `MAIA.md` / `CLAUDE.md` et les skills, comme au démarrage (la session garde son nom) | -| `/newAgent [nom]` | crée un sous-agent depuis le modèle `.ressources/AGENT.md` : demande le niveau (projet `./.maia/agents` ou global `~/.maia/agents`) puis le nom | +| `/createAgent [nom]` | crée un sous-agent depuis le modèle `.ressources/AGENT.md` : demande le niveau (projet `./.maia/agents` ou global `~/.maia/agents`) puis le nom | | `/agents` | liste les sous-agents chargés (nom, origine, description) | +| `/mcp` | liste les serveurs MCP activés : `OK` (vert) ou `FAIL` (rouge) | | `/compact` | résume la conversation et repart de ce résumé (voir « Compactage ») | | `/rename ` · `/load [nom]` · `/new` | sessions (voir plus bas) | | `/exit` (ou Ctrl+D) | quitte (la session est sauvegardée) | @@ -260,7 +261,7 @@ Format d'une règle : `Outil` (tous les appels de l'outil) ou `Outil(motif)` : Précisions sur `Bash` : - commande composée (`&&`, `;`, `|`) : un `deny` ou un `ask` s'applique dès qu'**une** des parties correspond, y compris cachée dans `$(…)`, des backticks ou des parenthèses ; un `allow` ne s'applique que si **toutes** les parties sont couvertes ; -- un `allow` ne couvre jamais une commande avec redirection (`>`) ou substitution (`$(…)`, backticks) : elle repasse en `ask`. +- un `allow` ne couvre jamais une commande avec substitution (`$(…)`, backticks) : elle repasse en `ask` ; une redirection de sortie (`>`, `>>`, `2>`, `&>`) repasse en `ask` sauf si le fichier visé est autorisé par une règle `Redirect(chemin)` (`2>&1` n'écrit dans aucun fichier : sans effet). `deny: ["Redirect(.env)"]` interdit d'écrire dans ce fichier par redirection, même avec `Bash` autorisé. Détails : `GUIDE_PERMISSIONS.md` § 8. Au démarrage, MAIA indique (`INFO`) les fichiers chargés et le nombre de règles, ou qu'aucune règle n'est définie ; un JSON invalide ou une règle mal formée donne un `WARN` et est ignoré. Les `deny` sont du « meilleur effort » : ils ne remplacent pas un vrai bac à sable. Les commandes `!` et les références `@fichier` tapées par l'utilisateur ne passent pas par ces règles. @@ -344,7 +345,7 @@ Code : `packages/core/src/compact.ts`, `sessions.ts` (`nextCompactedName`, `star MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle. -Définition : un dossier par agent, avec un fichier `AGENT.md`, dans `./.maia/agents//` (dossier de lancement) ou `~/.maia/agents//` (utilisateur) ; le projet l'emporte sur l'utilisateur à nom égal. **Créer un agent** : `/newAgent` (ou `/newAgent mon_agent`) demande le niveau (Projet / Global) et le nom, puis copie le modèle `.ressources/AGENT.md` dans `//AGENT.md` en y mettant le bon `name:` (jamais d'écrasement d'un agent existant). Le modèle explique chaque propriété en commentaires (`#`) ; les propriétés facultatives y sont commentées, supprimez le `#` pour les activer. Tapez ensuite `/clear` pour charger le nouvel agent. Le dossier `.ressources` est celui de MAIA (racine du projet MAIA, ou `MAIA_RESSOURCES`), pas celui de votre projet. Les commentaires `# …` en fin de ligne de l'en-tête sont acceptés (mettez la valeur entre guillemets si elle contient ` #`). +Définition : un dossier par agent, avec un fichier `AGENT.md`, dans `./.maia/agents//` (dossier de lancement) ou `~/.maia/agents//` (utilisateur) ; le projet l'emporte sur l'utilisateur à nom égal. **Créer un agent** : `/createAgent` (ou `/createAgent mon_agent`) demande le niveau (Projet / Global) et le nom, puis copie le modèle `.ressources/AGENT.md` dans `//AGENT.md` en y mettant le bon `name:` (jamais d'écrasement d'un agent existant). Le modèle explique chaque propriété en commentaires (`#`) ; les propriétés facultatives y sont commentées, supprimez le `#` pour les activer. Tapez ensuite `/clear` pour charger le nouvel agent. Le dossier `.ressources` est celui de MAIA (racine du projet MAIA, ou `MAIA_RESSOURCES`), pas celui de votre projet. Les commentaires `# …` en fin de ligne de l'en-tête sont acceptés (mettez la valeur entre guillemets si elle contient ` #`). Les agents chargés sont listés au démarrage (« Agents chargés : ») et avec `/agents`. @@ -384,10 +385,57 @@ Pour chaque problème : catégorie (CRITICAL / HIGH / MEDIUM / LOW), description - **Fin de mission** : un message INFO est affiché dans l'interface principale (durée, nombre d'appels d'outils) ; un échec est signalé par un avertissement et renvoyé à l'agent principal comme erreur d'outil. Délai maximal : 30 minutes. - **Trace** (`trace: true`) : la conversation du sous-agent est enregistrée dans `./.maia/.sessions/` sous le nom `{session de l'agent principal}-{agent}`, ex. `session_2026-10-20_00_01_53-agent_1.json` (`_2`, `_3`… si le nom est pris). Sans `trace`, rien n'est enregistré. +## Serveurs MCP (distants) + +MAIA peut utiliser les outils de serveurs **MCP** accessibles par une URL (Streamable HTTP, ou l'ancien transport SSE). Pas d'OAuth ; seuls les **outils** MCP sont utilisés (pas les ressources ni les prompts). + +**1. Déclarer les serveurs (global uniquement)** : `~/.maia/mcp.json`. La clé est le nom du serveur. + +```json +{ + "mcpServers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" }, + "excludeTools": ["delete_repository"] + }, + "docs": { "url": "https://docs.example.com/mcp", "timeout": 60 } + } +} +``` + +| Propriété | Rôle | +| --- | --- | +| `url` | obligatoire (`http://` ou `https://`) | +| `type` | `http` (défaut, Streamable HTTP) ou `sse` (ancien transport) | +| `headers` | en-têtes HTTP (jetons, clés d'API) | +| `timeout` | secondes, défaut 30 : connexion et chaque appel | +| `includeTools` / `excludeTools` | liste de noms d'outils (`*` accepté) retenus / retirés | +| `enabled` | `false` : le serveur est ignoré | +| `description` | commentaire libre | + +`${VARIABLE}` est remplacé par la variable d'environnement (dans `url` et `headers`) ; une variable absente donne un avertissement et une valeur vide. + +**2. Activer par projet** : **aucun serveur n'est actif par défaut**. Il faut l'autoriser dans le `settings.json` du projet : + +```json +{ "permissions": { "allow": ["Mcp(github)"], "deny": ["Mcp(github,delete_repository)"] } } +``` + +- `Mcp(serveur)` : active le serveur, tous ses outils sont autorisés sans confirmation ; +- `Mcp(serveur,outil)` : un outil (`*` accepté, ex. `Mcp(github,delete_*)`) ; en `deny` l'outil n'est jamais proposé au modèle ; en `ask` il demande confirmation ; +- `Mcp(*)` (= `Mcp(*,*)`) : tous les serveurs déclarés et tous leurs outils ; `Mcp(*,outil)` : cet outil sur tous les serveurs ; +- `allow: ["Mcp(github)"]` + `deny: ["Mcp(github,*)"]` : serveur **désactivé**, MAIA ne cherche pas à s'y connecter. + +Les permissions sont traitées **avant** toute connexion ; seuls les serveurs activés sont ensuite contactés (en parallèle). Un serveur en échec n'empêche pas MAIA de démarrer : avertissement, état `FAIL`. + +**3. Utilisation** : les outils s'appellent `mcp__serveur__outil`. `/mcp` liste les serveurs **activés** : bandeau vert `OK` s'il est connecté, bandeau rouge `FAIL` (avec la cause en grisé) sinon. Les connexions sont coupées à la sortie de MAIA (`/exit`, Ctrl+D, SIGTERM/SIGHUP) et refaites à chaque `/clear`, `/new`, `/load`, `/model` et `/compact`. Les sous-agents n'utilisent pas les outils MCP. + ## Reste à faire - Provider LiteLLM : validation sur un vrai serveur (streaming SSE et appels d'outils testés seulement sur des flux simulés) ; compatibilité de `@inkjs/ui` avec Ink 7 à confirmer à l'usage (l'affichage des badges a été vu fonctionner par l'utilisateur). -- Agent : hooks, serveurs MCP ; sous-agents : `reasoning_effort` du modèle non utilisé (l'effort borne les étapes), pas de sous-agent imbriqué. +- Agent : hooks ; MCP : OAuth, ressources/prompts, outils MCP pour les sous-agents ; sous-agents : `reasoning_effort` du modèle non utilisé (l'effort borne les étapes), pas de sous-agent imbriqué. - Éventuellement rétablir sous `.maia` ce que supprimait l'abandon de `.claude` : variables d'environnement et slash-commandes personnalisées (le code des commandes existe encore dans `packages/agent/src/commands.ts`, il n'est plus appelé). - `@fichier` dans le corps d'une commande personnalisée ; autocomplétion des chemins `@` à la saisie. - Liste à cocher (choix multiples) : n'existe pas encore ; elle recevrait le badge `ASK`. @@ -441,4 +489,6 @@ Journal des évolutions, dans l'ordre : 40. **Guide des permissions** : `GUIDE_PERMISSIONS.md` (syntaxe, exemples simples et complexes, interdiction d'une commande cachée dans un tube, limites). 41. **Signature dans la bannière** : « Arnaud VINCENT (avec ClaudeCode) » sous « Un seul agent, plusieurs modèles » (ligne séparée par un espace ; nom en #EEF527, ClaudeCode en #F57D27). 42. **Lancement direct d'un sous-agent** : une saisie qui commence par `&` (`&Agent_1 tâche…`) exécute le sous-agent sans passer par le LLM ; son résumé est affiché et ajouté au contexte. -43. **`/newAgent`** : création guidée d'un sous-agent (niveau projet ou global) à partir du modèle commenté `.ressources/AGENT.md` ; commentaires de fin de ligne acceptés dans l'en-tête de `AGENT.md`, commentaires HTML des sections ignorés. +43. **`/createAgent`** (ex-`/newAgent`) : création guidée d'un sous-agent (niveau projet ou global) à partir du modèle commenté `.ressources/AGENT.md` ; commentaires de fin de ligne acceptés dans l'en-tête de `AGENT.md`, commentaires HTML des sections ignorés. +44. **Règles `Redirect`** : autoriser, demander ou interdire les redirections de sortie (`>`, `>>`, `2>`, `&>`) fichier par fichier ; sans règle, elles demandent confirmation ; `2>&1` et les `>` entre guillemets ne comptent pas ; guide mis à jour (§ 8). +45. **Serveurs MCP distants** : déclaration globale `~/.maia/mcp.json` (expansion `${VAR}`, `type` http/sse, `headers`, `timeout`, `includeTools`/`excludeTools`), aucun serveur actif par défaut, activation par `Mcp(serveur)` dans le `settings.json`, `Mcp(serveur,outil)` pour retirer/confirmer un outil, `Mcp(serveur,*)` en `deny` désactive sans connexion ; connexion parallèle après traitement des permissions ; outils `mcp__serveur__outil` ; commande `/mcp` (bandeaux `OK` vert / `FAIL` rouge). diff --git a/SPECS_TECH.md b/SPECS_TECH.md index 9888b49..2d740b4 100644 --- a/SPECS_TECH.md +++ b/SPECS_TECH.md @@ -16,7 +16,7 @@ MAIA est un assistant IA en ligne de commande (CLI) : - l'agent lit, cherche, modifie des fichiers et exécute des commandes dans le **dossier de lancement** grâce à des outils, soumis à des permissions ; - sessions persistantes, skills, outils personnalisés, exécution parallèle des outils, compactage du contexte. -Hors périmètre actuel : sous-agents / outil Task, hooks, serveurs MCP, slash-commandes personnalisées (code présent mais non appelé), autocomplétion des chemins `@`, listes à choix multiples (voir § 18). +Hors périmètre actuel : hooks, slash-commandes personnalisées (code présent mais non appelé), autocomplétion des chemins `@`, listes à choix multiples (voir § 18). ## 2. Pile technique @@ -295,7 +295,8 @@ Pour toute entrée invalide, l'outil lève une exception renvoyée comme erreur - `deny` et `ask` s'appliquent dès qu'**une** partie de la commande correspond (`&&`, `;`, `|`, substitutions `$(…)`, backticks, parenthèses, redirections — découpe fine) ; - `allow` ne s'applique que si **toutes** les parties sont couvertes ; -- un `allow` ne couvre **jamais** une commande avec redirection (`>`) ou substitution : elle repasse en `ask`. +- un `allow` ne couvre **jamais** une commande avec substitution (`$(…)`, backticks, `<(…)`, `>(…)`) : elle repasse en `ask` ; +- **Redirections de sortie** : `extractRedirections` (guillemets et `\\` respectés ; préfixes `2`, `&` ; `>>`, `>|`, `>&fichier` ; `2>&1`, `>&2`, `>&-` = duplications sans fichier) donne la commande sans redirections et les fichiers visés. Règles `Redirect` (sans motif : toutes ; `Redirect(chemin)` : motif de chemin relatif au dossier de lancement, comme `Write`). `allow` : la commande dépurée doit être couverte (règles `Bash`) ET chaque cible couverte par un `Redirect` allow ; une cible dynamique (`$ ` * ? [ ] { }`) n'est couverte que par `Redirect` sans motif ; analyse incertaine (guillemet non fermé, cible absente, `>(`) → `ask`. `deny` / `ask` : une cible qui correspond à un `Redirect(motif)` suffit, quelle que soit la commande autorisée. Sans règle `Redirect` : toute redirection vers un fichier repasse en `ask`. Les `deny` sont du « meilleur effort », pas un bac à sable. Les commandes `!` et les références `@` saisies par l'utilisateur **ne passent pas** par ces règles. @@ -527,7 +528,7 @@ La zone de saisie (cadre en pointillés) est toujours à l'écran : saisie activ | `./.maia/skills/` · `/.maia/skills/` | skills | | `./.maia/tools//TOOL.json` | outils personnalisés | | `./.maia/agents//AGENT.md` · `~/.maia/agents//AGENT.md` | sous-agents | -| `/.ressources/AGENT.md` | modèle d'agent de `/newAgent` (ressource privée de MAIA ; `MAIA_RESSOURCES` : autre dossier) | +| `/.ressources/AGENT.md` | modèle d'agent de `/createAgent` (ressource privée de MAIA ; `MAIA_RESSOURCES` : autre dossier) | | `./.maia/.sessions/.json` | sessions (et traces de sous-agents `-.json`) | | `./MAIA.md` sinon `./CLAUDE.md` | contexte de départ | | `MAIA_MODEL`, `MAIA_CWD`, `INIT_CWD`, `LITELLM_API_KEY`, `MAIA_NO_MARKDOWN`, `MAIA_RESSOURCES` | variables d'environnement | @@ -549,11 +550,11 @@ La zone de saisie (cadre en pointillés) est toujours à l'écran : saisie activ - Compactage automatique contrôlé aux frontières de tour uniquement (§ 14.3). - Parallélisme : tâches asynchrones, 8 simultanées, confirmations une par une. - Sous-agents : l'effort borne les étapes (pas de `reasoning_effort` envoyé au modèle) ; pas d'imbrication ; une trace de sous-agent rechargée avec `/load` reprend la conversation dans l'agent principal (prompt système différent) ; chaque sous-agent recharge ses outils/skills/permissions depuis le disque (une modification en cours de session n'est pas vue par l'agent principal avant `/clear`). -- Non réalisés : hooks, serveurs MCP, variables d'environnement et slash-commandes personnalisées sous `.maia` (code de `commands.ts` présent mais non appelé), `@fichier` dans le corps d'un prompt de skill, autocomplétion des chemins `@`, listes à cocher, icônes Nerd Fonts. +- Non réalisés : hooks, OAuth MCP, variables d'environnement et slash-commandes personnalisées sous `.maia` (code de `commands.ts` présent mais non appelé), `@fichier` dans le corps d'un prompt de skill, autocomplétion des chemins `@`, listes à cocher, icônes Nerd Fonts. ## 19. Tests -Suites vitest (fichiers `*.test.ts`) : sous-agents (`agents.test.ts` : en-tête, sections, chargement, modèle, outil, initialisation restreinte ; `core/src/subagent.test.ts` : résumé final, budget, trace ; `cli/src/subagent-parent.test.ts` : lanceur avec faux processus), boucle agent et permissions (`agent.test.ts`), exécuteur d'outils parallèle (`tool-runner.test.ts` : durée, ordre, confirmations séquentielles, refus, `lockKey`, plafond de 8), comptage et taille de contexte (`context.test.ts`), compactage et noms `__n` (`compact.test.ts`), sessions, `!` (`shell.test.ts`), `@` (`fileref.test.ts`), skills, outils (chemins, Glob/Grep, TodoWrite, WebFetch, CurrentModel), outils personnalisés (31 tests dont exécution réelle de scripts), initialisation de l'agent, provider LiteLLM (corps de requête, SSE), interface (store, barre de contexte, pixels, couleurs, spinner, markdown), dossier de lancement. +Suites vitest (fichiers `*.test.ts`) : serveurs MCP (`mcp.test.ts` : `mcp.json`, expansion `${VAR}`, règles `Mcp(...)`, faux serveur HTTP en JSON, SSE et ancien transport SSE, filtrage des outils, statuts OK/FAIL, aucune connexion si désactivé), sous-agents (`agents.test.ts` : en-tête, sections, chargement, modèle, outil, initialisation restreinte ; `core/src/subagent.test.ts` : résumé final, budget, trace ; `cli/src/subagent-parent.test.ts` : lanceur avec faux processus), boucle agent et permissions (`agent.test.ts`), exécuteur d'outils parallèle (`tool-runner.test.ts` : durée, ordre, confirmations séquentielles, refus, `lockKey`, plafond de 8), comptage et taille de contexte (`context.test.ts`), compactage et noms `__n` (`compact.test.ts`), sessions, `!` (`shell.test.ts`), `@` (`fileref.test.ts`), skills, outils (chemins, Glob/Grep, TodoWrite, WebFetch, CurrentModel), outils personnalisés (31 tests dont exécution réelle de scripts), initialisation de l'agent, provider LiteLLM (corps de requête, SSE), interface (store, barre de contexte, pixels, couleurs, spinner, markdown), dossier de lancement. Commandes : `pnpm install`, `pnpm test`, `pnpm typecheck`. Les suites ont été écrites et vérifiées par un lanceur de test minimal dans l'environnement de développement (sans vitest, Ink ni LiteLLM réels) ; leur exécution complète est à faire dans l'environnement du projet. @@ -579,9 +580,9 @@ En-tête YAML (sous-ensemble : clés simples, listes `- a` ou `[a, b]`, table im Corps : trois sections obligatoires, repérées par leurs titres Markdown (niveau libre, casse et accents ignorés ; « Instructions system(e) », « Missions », « Output ») ; une section s'arrête au titre suivant de même niveau ou plus haut, ou à une autre des trois sections ; les titres dans un bloc de code sont ignorés. Un agent invalide (nom, description, section manquante) est ignoré avec un avertissement précis. Journal au démarrage : « Agents chargés : » (un par ligne) ; commande `/agents`. -### 20.1b Création : `/newAgent` et le modèle `.ressources/AGENT.md` +### 20.1b Création : `/createAgent` et le modèle `.ressources/AGENT.md` -`/newAgent [nom]` (cli) : `ui.select` du niveau (« Projet » → `./.maia/agents`, « Global » → `~/.maia/agents`), nom en argument ou saisi (vide / Ctrl+C : annulation), `validateAgentName` (lettres, chiffres, `_ . -`, 64 caractères, pas de point initial), puis `createAgentFromTemplate` (agent) : lecture du modèle, `applyAgentName` (remplace uniquement la ligne `name:` de l'en-tête), création du dossier et écriture avec l'option `wx` (jamais d'écrasement : erreur « existe déjà »). Le modèle est cherché par `findResource` (cli/src/resources.ts) : `$MAIA_RESSOURCES`, sinon `/.ressources` (3 niveaux au-dessus de `packages/cli/dist` ou `src`), sinon à côté du programme. Le modèle est un `AGENT.md` valide tel quel (name, description, effort, omitMd, trace actifs ; model, tools, permissions, skills en exemples `#propriété:` à décommenter) avec une explication par propriété en commentaires `#` et, dans le corps, des commentaires HTML par section. +`/createAgent [nom]` (cli) : `ui.select` du niveau (« Projet » → `./.maia/agents`, « Global » → `~/.maia/agents`), nom en argument ou saisi (vide / Ctrl+C : annulation), `validateAgentName` (lettres, chiffres, `_ . -`, 64 caractères, pas de point initial), puis `createAgentFromTemplate` (agent) : lecture du modèle, `applyAgentName` (remplace uniquement la ligne `name:` de l'en-tête), création du dossier et écriture avec l'option `wx` (jamais d'écrasement : erreur « existe déjà »). Le modèle est cherché par `findResource` (cli/src/resources.ts) : `$MAIA_RESSOURCES`, sinon `/.ressources` (3 niveaux au-dessus de `packages/cli/dist` ou `src`), sinon à côté du programme. Le modèle est un `AGENT.md` valide tel quel (name, description, effort, omitMd, trace actifs ; model, tools, permissions, skills en exemples `#propriété:` à décommenter) avec une explication par propriété en commentaires `#` et, dans le corps, des commentaires HTML par section. Lecture de l'en-tête : les commentaires de fin de ligne (` # …`, hors guillemets, `#` précédé d'un espace) sont retirés (`stripComment`) ; les commentaires HTML `` des sections sont retirés avant l'envoi au modèle. Le nouvel agent est chargé au prochain démarrage, `/clear` ou `/new`. @@ -622,7 +623,25 @@ Confirmations : l'interface de l'enfant n'affiche rien ; `confirm` est relayé a Si `trace: true` (même en cas d'échec, s'il y a des messages) : `saveSubAgentTrace` écrit un `SavedSession` dans `./.maia/.sessions/` nommé `{session parente}-{agent}` (`subAgentSessionName` : `_2`, `_3`… si pris), avec les champs optionnels `agent` et `parentSession`. `/load` affiche « sous-agent x » dans la liste. Sans `trace`, rien n'est écrit. -## 21. Annexe — constantes +## 22. Serveurs MCP (`agent/src/mcp*.ts`) + +**Fichiers** : `mcp-config.ts` (lecture de `~/.maia/mcp.json`, `expandEnv`), `mcp-rules.ts` (règles `Mcp(...)`, noms d'outils, sélection des serveurs : fonctions pures), `mcp-client.ts` (client JSON-RPC 2.0 minimal, transports Streamable HTTP et HTTP+SSE, analyseur SSE), `mcp.ts` (`setupMcp`, `createMcpTool`). Aucune dépendance ajoutée (client écrit à la main sur `fetch`). + +**Déroulement** (`initializeAgent`, après le chargement des permissions) : +1. `loadMcpConfig(homeDir, env)` : `~/.maia/mcp.json` uniquement (ni projet, ni parent). Serveur invalide (clé hors `[A-Za-z0-9_-]` ou contenant `__`, `url` absente ou non http(s), `type` ≠ http/sse) : avertissement, ignoré. `enabled:false` : ignoré. `${VAR}` développé dans `url` et `headers`. +2. `selectMcpServers(rules, clés)` (le serveur `*` désigne tous les serveurs : `Mcp(*)`, `Mcp(*,outil)`) : un serveur est **activé** s'il a une règle allow `Mcp(clé…)` et aucune règle deny `Mcp(clé)` / `Mcp(clé,*)`. Règle sur une clé inconnue : avertissement. Aucune connexion avant ce tri. +3. Connexion **en parallèle** des serveurs activés (`initialize`, `notifications/initialized`, `tools/list` paginé). Échec (réseau, HTTP, délai, JSON-RPC) : statut `fail` + avertissement, les autres serveurs et MAIA continuent. +4. Outils : `mcp____` (nom nettoyé en `[A-Za-z0-9_-]`, ≤ 64 caractères, doublons ignorés avec avertissement), `permission: "ask"` ; retirés : `excludeTools`, hors `includeTools`, règle deny `Mcp(clé,outil)`. + +**Permissions** (`permissions.ts`) : une règle `Mcp(clé[,motif])` s'applique à tout appel d'outil `mcp__clé__outil` (allow = sans confirmation, ask, deny). L'ancienne forme `mcp__clé__outil(motif de valeur)` reste valide. + +**Client** : `Mcp-Session-Id` mémorisé et renvoyé, `MCP-Protocol-Version: 2025-03-26`, `Accept: application/json, text/event-stream` ; réponse JSON ou flux SSE lue jusqu'à l'id attendu. Transport `sse` : `GET url`, évènement `endpoint`, POST vers cet endpoint, réponses lues sur le flux. Délai `timeout` (s) par requête ; pour `sse`, jusqu'à la réception de `endpoint`. Résultat d'outil : parties `text` concaténées (autres types signalés), `isError` → exception, tronqué à 100 000 caractères. + +**Interface** : `/mcp` → `UIStore.showMcp` (`LogKind "mcp"`) : badge `OK` vert / `FAIL` rouge + nom ; cause de l'échec en grisé. `AgentInitResult.mcpServers` (états) et `close()` (fermeture des connexions : session suivante, `/exit`, Ctrl+D, SIGTERM/SIGHUP ; Streamable HTTP : `DELETE` de la session, SSE : fermeture du flux ; au plus 3 s en cas de signal). + +**Limites** : pas d'OAuth ; outils seulement ; les sous-agents (processus enfants) n'ont pas les outils MCP ; connexions refaites à chaque redémarrage de session (`/clear`, `/new`, `/load`, `/model`, `/compact`) ; pas de reconnexion automatique en cours de session ; notifications serveur (`tools/list_changed`) ignorées. + +## 23. Annexe — constantes | Constante | Valeur | Lieu | | --- | --- | --- | @@ -649,3 +668,4 @@ Si `trace: true` (même en cas d'échec, s'il y a des messages) : `saveSubAgentT | Délai maximal d'un sous-agent / délai avant `SIGKILL` | 30 min / 5 s | `subagent-parent.ts` | | Tokens du résumé final d'un sous-agent | 4000 | `core/src/subagent.ts` | | Aperçu d'un résultat d'outil / d'une sortie `!` | 600 / 4000 caractères | `store.ts` | +| MCP : délai par défaut / résultat d'outil / longueur d'un nom d'outil | 30 s / 100 000 caractères / 64 | `mcp-config.ts`, `mcp-client.ts`, `mcp-rules.ts` | diff --git a/packages/agent/src/agents.ts b/packages/agent/src/agents.ts index 62e2aae..c25b375 100644 --- a/packages/agent/src/agents.ts +++ b/packages/agent/src/agents.ts @@ -117,7 +117,7 @@ export function parseAgentSections(body: string): Partial/g, "").trim(); } return found; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index bd2b291..8bea177 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -13,3 +13,7 @@ export { createAgentTool, type SubAgentLauncher } from "./subagent-tool"; export { CONTEXT_FILES, loadContextFile } from "./memory"; export { customToolsDir, loadCustomTools } from "./custom-tools"; export { agentsDirFor, applyAgentName, createAgentFromTemplate, validateAgentName, type AgentScope } from "./new-agent"; +export { setupMcp, createMcpTool, type McpServerStatus, type McpSetup } from "./mcp"; +export { loadMcpConfig, expandEnv, mcpConfigPath, MCP_DEFAULT_TIMEOUT_S, type McpServerConfig } from "./mcp-config"; +export { McpClient, parseSse, resultToText, type McpToolDef } from "./mcp-client"; +export { selectMcpServers, mcpToolName, splitMcpToolName, parseMcpSpec, mcpSpecMatches, MCP_PREFIX } from "./mcp-rules"; diff --git a/packages/agent/src/init.test.ts b/packages/agent/src/init.test.ts index a0aa425..f776e22 100644 --- a/packages/agent/src/init.test.ts +++ b/packages/agent/src/init.test.ts @@ -94,7 +94,7 @@ describe("permissions au démarrage", () => { const { project, logs, ctx } = await setup(); await writeFile(join(project, "MAIA.md"), "x"); const init = await initializeAgent(ctx); - expect(init.permissions?.check("Read", { path: "a" }, { cwd: project }).decision).toBe("ask"); + expect(init.permissions?.check("Read", { path: "a" }, { cwd: project })?.decision).toBe("ask"); expect(logs.join("\n")).toContain("Aucune règle de permission"); }); @@ -106,7 +106,7 @@ describe("permissions au démarrage", () => { JSON.stringify({ permissions: { allow: ["Bash(allow stop *)"], ask: ["Bash(allow ask *)"], deny: ["Bash(allow deny *)", "Read(./.deny.md)"] } }), ); const init = await initializeAgent(ctx); - const d = (t: string, i: Record) => init.permissions?.check(t, i, { cwd: project }).decision; + const d = (t: string, i: Record) => init.permissions?.check(t, i, { cwd: project })?.decision; expect(d("Bash", { command: "allow stop" })).toBe("allow"); expect(d("Bash", { command: "allow ask now" })).toBe("ask"); expect(d("Bash", { command: "allow deny" })).toBe("deny"); diff --git a/packages/agent/src/init.ts b/packages/agent/src/init.ts index 95524eb..b4ea502 100644 --- a/packages/agent/src/init.ts +++ b/packages/agent/src/init.ts @@ -2,6 +2,7 @@ import type { InitContext, ModeInitResult, Tool } from "@maia/core"; import { buildSubAgentPrompt, loadAgents, type AgentDefinition, type SubAgentInit } from "./agents"; import { loadCustomTools } from "./custom-tools"; import { loadContextFile } from "./memory"; +import { setupMcp, type McpServerStatus } from "./mcp"; import { createPermissionPolicy } from "./permissions"; import { loadPermissionSettings } from "./settings"; import { createSkillTool, loadSkills, skillRoots, skillsPromptSection, toAgentSkills } from "./skills"; @@ -19,6 +20,10 @@ export interface AgentInitContext extends InitContext { export interface AgentInitResult extends ModeInitResult { /** Sous-agents définis (./.maia/agents et ~/.maia/agents). */ agents: AgentDefinition[]; + /** État des serveurs MCP activés (commande /mcp). */ + mcpServers: McpServerStatus[]; + /** Ferme les connexions MCP. */ + close(): Promise; } function buildSystemPrompt( @@ -94,13 +99,22 @@ export async function initializeAgent(ctx: AgentInitContext): Promise 0) ctx.log(["Agents chargés :", ...agents.map((a) => a.name)].join("\n")); const agentTools = ctx.launchSubAgent && agents.length > 0 ? [createAgentTool(agents, ctx.launchSubAgent)] : []; - // TODO : hooks, serveurs MCP. + // Serveurs MCP : déclarés dans ~/.maia/mcp.json, activés par les règles Mcp(...) ci-dessus, connectés ensuite + const mcp = await setupMcp(ctx.homeDir, settings.rules); + for (const w of mcp.warnings) ctx.warn(w); + if (mcp.statuses.length > 0) { + ctx.log(["Serveurs MCP :", ...mcp.statuses.map((s) => `${s.name} : ${s.status === "ok" ? `OK (${s.toolCount} outils)` : "FAIL"}`)].join("\n")); + } + + // TODO : hooks. return { agents, + mcpServers: mcp.statuses, + close: mcp.close, systemPrompt: buildSystemPrompt(ctx.cwd, context, skillsPromptSection(skills), ctx.startContext) + (agentTools.length > 0 ? agentsPromptSection(agents) : ""), - tools: [...builtinTools, ...(skills.length > 0 ? [createSkillTool(skills)] : []), ...agentTools, ...customTools], + tools: [...builtinTools, ...(skills.length > 0 ? [createSkillTool(skills)] : []), ...agentTools, ...customTools, ...mcp.tools], commands: [], skills: toAgentSkills(skills), permissions: createPermissionPolicy(settings.rules), @@ -156,6 +170,8 @@ async function initializeSubAgent(ctx: AgentInitContext, sub: SubAgentInit): Pro return { agents: [], + mcpServers: [], + close: async () => undefined, systemPrompt: buildSubAgentPrompt(sub, ctx.cwd, context, hasSkillTool ? skillsPromptSection(skills) : ""), tools, commands: [], diff --git a/packages/agent/src/lot2.test.ts b/packages/agent/src/lot2.test.ts index e827141..a237a56 100644 --- a/packages/agent/src/lot2.test.ts +++ b/packages/agent/src/lot2.test.ts @@ -76,7 +76,7 @@ describe("permissions", () => { expect(check("Write", { path: "a.txt" })?.decision).toBe("ask"); expect(check("Glob", { pattern: "*.ts" })?.decision).toBe("ask"); expect(check("OutilInconnu", {})?.decision).toBe("ask"); - expect(createPermissionPolicy({ allow: [], ask: [], deny: [] }).check("Read", { path: "a" }, { cwd: "/p" }).decision).toBe("ask"); + expect(createPermissionPolicy({ allow: [], ask: [], deny: [] }).check("Read", { path: "a" }, { cwd: "/p" })?.decision).toBe("ask"); }); it("commandes composées : deny/ask si une partie correspond, allow seulement si toutes sont couvertes", () => { @@ -101,7 +101,7 @@ describe("permissions", () => { it("outils non intégrés : règle sur le nom, ou motif comparé aux valeurs de l'entrée", () => { const p = createPermissionPolicy({ allow: ["mcp__db__query(SELECT *)", "Skill(pdf)"], ask: [], deny: ["mcp__db__query(DROP *)", "mcp__db__drop"] }); - const c = (t: string, i: Record) => p.check(t, i, { cwd: "/p" }).decision; + const c = (t: string, i: Record) => p.check(t, i, { cwd: "/p" })?.decision; expect(c("mcp__db__query", { sql: "SELECT 1" })).toBe("allow"); expect(c("mcp__db__query", { sql: "DROP TABLE x" })).toBe("deny"); expect(c("mcp__db__query", { sql: "DELETE FROM x" })).toBe("ask"); @@ -112,7 +112,7 @@ describe("permissions", () => { it("WebFetch par domaine", () => { const p = createPermissionPolicy({ allow: ["WebFetch(domain:docs.example.com)"], ask: [], deny: ["WebFetch(domain:*.evil.com)"] }); - const c = (url: string) => p.check("WebFetch", { url }, { cwd: "/p" }).decision; + const c = (url: string) => p.check("WebFetch", { url }, { cwd: "/p" })?.decision; expect(c("https://docs.example.com/a")).toBe("allow"); expect(c("https://x.evil.com/")).toBe("deny"); expect(c("https://autre.org/")).toBe("ask"); diff --git a/packages/agent/src/mcp-client.ts b/packages/agent/src/mcp-client.ts new file mode 100644 index 0000000..8d55091 --- /dev/null +++ b/packages/agent/src/mcp-client.ts @@ -0,0 +1,340 @@ +// Client MCP distant minimal (JSON-RPC 2.0) : initialize, tools/list, tools/call. Pas d'OAuth, outils uniquement. +// Transports : Streamable HTTP (réponse JSON ou flux SSE à chaque POST) et ancien HTTP+SSE. + +import type { McpServerConfig } from "./mcp-config"; + +const PROTOCOL_VERSION = "2025-03-26"; +const MAX_RESULT_CHARS = 100_000; + +export interface McpToolDef { + name: string; + description?: string; + inputSchema?: Record; +} + +interface RpcMessage { + jsonrpc?: string; + id?: number | string | null; + method?: string; + result?: unknown; + error?: { code?: number; message?: string }; +} + +type FetchLike = typeof fetch; + +// ───────────── SSE ───────────── + +export interface SseEvent { + event: string; + data: string; +} + +/** Découpe un flux `text/event-stream` en évènements. */ +export async function* parseSse(body: ReadableStream): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const take = (block: string): SseEvent | null => { + let event = "message"; + const data: string[] = []; + for (const line of block.split("\n")) { + if (line.startsWith(":")) continue; + const i = line.indexOf(":"); + const field = i === -1 ? line : line.slice(0, i); + const value = i === -1 ? "" : line.slice(i + 1).replace(/^ /, ""); + if (field === "event") event = value; + else if (field === "data") data.push(value); + } + return data.length > 0 ? { event, data: data.join("\n") } : null; + }; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n"); + let sep: number; + while ((sep = buffer.indexOf("\n\n")) !== -1) { + const ev = take(buffer.slice(0, sep)); + buffer = buffer.slice(sep + 2); + if (ev) yield ev; + } + } + const last = take(buffer); + if (last) yield last; + } finally { + reader.releaseLock(); + } +} + +// ───────────── Transports ───────────── + +interface Transport { + /** Envoie une requête et attend la réponse portant le même id. */ + request(message: object, id: number, signal: AbortSignal): Promise; + notify(message: object, signal: AbortSignal): Promise; + close(): Promise; +} + +function rpcError(msg: RpcMessage): Error { + return new Error(`MCP : ${msg.error?.message ?? "erreur inconnue"}${msg.error?.code !== undefined ? ` (code ${msg.error.code})` : ""}`); +} + +class StreamableHttpTransport implements Transport { + private sessionId?: string; + constructor( + private readonly cfg: McpServerConfig, + private readonly fetchFn: FetchLike, + ) {} + + private headers(): Record { + return { + ...this.cfg.headers, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "MCP-Protocol-Version": PROTOCOL_VERSION, + ...(this.sessionId ? { "Mcp-Session-Id": this.sessionId } : {}), + }; + } + + private async post(message: object, signal: AbortSignal): Promise { + const res = await this.fetchFn(this.cfg.url, { method: "POST", headers: this.headers(), body: JSON.stringify(message), signal }); + const sid = res.headers.get("mcp-session-id"); + if (sid) this.sessionId = sid; + if (!res.ok) { + const text = (await res.text().catch(() => "")).slice(0, 200); + throw new Error(`HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}${text ? ` : ${text}` : ""}`); + } + return res; + } + + async request(message: object, id: number, signal: AbortSignal): Promise { + const res = await this.post(message, signal); + const type = res.headers.get("content-type") ?? ""; + if (type.includes("text/event-stream") && res.body) { + for await (const ev of parseSse(res.body)) { + let msg: RpcMessage; + try { + msg = JSON.parse(ev.data) as RpcMessage; + } catch { + continue; + } + if (msg.id === id && (msg.result !== undefined || msg.error)) return msg; + } + throw new Error("flux SSE terminé sans réponse"); + } + const json = (await res.json()) as RpcMessage | RpcMessage[]; + const msg = Array.isArray(json) ? json.find((m) => m.id === id) : json; + if (!msg) throw new Error("réponse JSON-RPC absente"); + return msg; + } + + async notify(message: object, signal: AbortSignal): Promise { + const res = await this.post(message, signal); + await res.body?.cancel().catch(() => undefined); + } + + async close(): Promise { + if (!this.sessionId) return; + try { + await this.fetchFn(this.cfg.url, { method: "DELETE", headers: { ...this.cfg.headers, "Mcp-Session-Id": this.sessionId }, signal: AbortSignal.timeout(2000) }); + } catch { + // fermeture au mieux + } + } +} + +/** Ancien transport : GET (flux SSE permanent, évènement `endpoint`) puis POST des messages vers cet endpoint. */ +class LegacySseTransport implements Transport { + private endpoint?: string; + private readonly pending = new Map void>(); + private readonly abort = new AbortController(); + private streamError?: Error; + + constructor( + private readonly cfg: McpServerConfig, + private readonly fetchFn: FetchLike, + ) {} + + /** Ouvre le flux (qui reste ouvert ensuite) : le délai ne vaut que jusqu'à la réception de l'évènement `endpoint`. */ + async open(timeoutMs: number): Promise { + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + this.abort.abort(); + }, timeoutMs); + let events: AsyncGenerator; + try { + const res = await this.fetchFn(this.cfg.url, { + headers: { ...this.cfg.headers, Accept: "text/event-stream" }, + signal: this.abort.signal, + }); + if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`); + events = parseSse(res.body); + // Le premier évènement utile est `endpoint` ; la suite est lue en arrière-plan + for (;;) { + const next = await events.next(); + if (next.done) throw new Error("flux SSE fermé avant l'évènement endpoint"); + if (next.value.event === "endpoint") { + this.endpoint = new URL(next.value.data.trim(), this.cfg.url).toString(); + break; + } + } + } catch (err) { + throw timedOut ? new Error(`délai dépassé (${this.cfg.timeout} s)`) : err; + } finally { + clearTimeout(timer); + } + void (async () => { + try { + for await (const ev of events) { + if (ev.event !== "message") continue; + try { + const msg = JSON.parse(ev.data) as RpcMessage; + if (typeof msg.id === "number") this.pending.get(msg.id)?.(msg); + } catch { + // évènement illisible ignoré + } + } + this.streamError = new Error("flux SSE fermé par le serveur"); + } catch (err) { + this.streamError = err as Error; + } + for (const [id, resolve] of this.pending) resolve({ id, error: { message: this.streamError?.message ?? "flux SSE fermé" } }); + })(); + } + + private async post(message: object, signal: AbortSignal): Promise { + if (!this.endpoint) throw new Error("endpoint SSE inconnu"); + const res = await this.fetchFn(this.endpoint, { + method: "POST", + headers: { ...this.cfg.headers, "Content-Type": "application/json" }, + body: JSON.stringify(message), + signal, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`); + await res.body?.cancel().catch(() => undefined); + } + + async request(message: object, id: number, signal: AbortSignal): Promise { + if (this.streamError) throw this.streamError; + const answer = new Promise((resolve, reject) => { + this.pending.set(id, resolve); + signal.addEventListener("abort", () => reject(signal.reason ?? new Error("annulé")), { once: true }); + }); + try { + await this.post(message, signal); + return await answer; + } finally { + this.pending.delete(id); + } + } + + notify(message: object, signal: AbortSignal): Promise { + return this.post(message, signal); + } + + async close(): Promise { + this.abort.abort(); + } +} + +// ───────────── Client ───────────── + +export function resultToText(result: unknown): { text: string; isError: boolean } { + const r = (result ?? {}) as { content?: unknown; isError?: unknown; structuredContent?: unknown }; + const parts: string[] = []; + if (Array.isArray(r.content)) { + for (const item of r.content as Record[]) { + if (item.type === "text" && typeof item.text === "string") parts.push(item.text); + else if (item.type === "resource" && typeof item.resource === "object" && item.resource !== null) { + const res = item.resource as { text?: unknown; uri?: unknown }; + parts.push(typeof res.text === "string" ? res.text : `[ressource ${String(res.uri ?? "")}]`); + } else parts.push(`[contenu ${String(item.type)}${typeof item.mimeType === "string" ? ` ${item.mimeType}` : ""} non affichable]`); + } + } + if (parts.length === 0 && r.structuredContent !== undefined) parts.push(JSON.stringify(r.structuredContent)); + let text = parts.join("\n"); + if (text.length > MAX_RESULT_CHARS) text = `${text.slice(0, MAX_RESULT_CHARS)}\n… (résultat tronqué à ${MAX_RESULT_CHARS} caractères)`; + return { text, isError: r.isError === true }; +} + +export class McpClient { + private transport?: Transport; + private nextId = 0; + + constructor( + readonly config: McpServerConfig, + private readonly fetchFn: FetchLike = fetch, + ) {} + + private signal(extra?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(this.config.timeout * 1000); + return extra ? AbortSignal.any([timeout, extra]) : timeout; + } + + private async rpc(method: string, params: object | undefined, extra?: AbortSignal): Promise { + if (!this.transport) throw new Error("client MCP non connecté"); + const id = ++this.nextId; + const signal = this.signal(extra); + try { + const msg = await this.transport.request({ jsonrpc: "2.0", id, method, params }, id, signal); + if (msg.error) throw rpcError(msg); + return msg.result; + } catch (err) { + if (signal.aborted && !extra?.aborted) throw new Error(`délai dépassé (${this.config.timeout} s)`); + throw err; + } + } + + /** Négocie la session (initialize + notifications/initialized). */ + async connect(): Promise { + if (this.config.type === "sse") { + const t = new LegacySseTransport(this.config, this.fetchFn); + try { + await t.open(this.config.timeout * 1000); + } catch (err) { + await t.close(); + throw err; + } + this.transport = t; + } else { + this.transport = new StreamableHttpTransport(this.config, this.fetchFn); + } + try { + await this.rpc("initialize", { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "maia", version: "0.1.0" }, + }); + await this.transport.notify({ jsonrpc: "2.0", method: "notifications/initialized" }, this.signal()); + } catch (err) { + await this.close(); + throw err; + } + } + + async listTools(): Promise { + const tools: McpToolDef[] = []; + let cursor: string | undefined; + for (let page = 0; page < 20; page++) { + const res = (await this.rpc("tools/list", cursor ? { cursor } : {})) as { tools?: McpToolDef[]; nextCursor?: string }; + for (const t of res.tools ?? []) if (t && typeof t.name === "string") tools.push(t); + cursor = res.nextCursor; + if (!cursor) break; + } + return tools; + } + + async callTool(name: string, args: Record, signal?: AbortSignal): Promise { + const result = await this.rpc("tools/call", { name, arguments: args }, signal); + const { text, isError } = resultToText(result); + if (isError) throw new Error(text || "l'outil MCP a signalé une erreur"); + return text === "" ? "(aucune sortie)" : text; + } + + async close(): Promise { + const t = this.transport; + this.transport = undefined; + await t?.close(); + } +} diff --git a/packages/agent/src/mcp-config.ts b/packages/agent/src/mcp-config.ts new file mode 100644 index 0000000..1968411 --- /dev/null +++ b/packages/agent/src/mcp-config.ts @@ -0,0 +1,139 @@ +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { isValidServerKey } from "./mcp-rules"; + +/** Délai par défaut (secondes) de connexion et d'appel d'un serveur MCP. */ +export const MCP_DEFAULT_TIMEOUT_S = 30; + +export interface McpServerConfig { + /** Clé du serveur dans `mcpServers` : nom utilisé dans les règles `Mcp(clé)` et dans les noms d'outils. */ + key: string; + /** `http` : Streamable HTTP (défaut) ; `sse` : ancien transport HTTP+SSE. */ + type: "http" | "sse"; + url: string; + headers: Record; + /** Secondes. */ + timeout: number; + includeTools?: string[]; + excludeTools?: string[]; + description?: string; +} + +/** Chemin du fichier de déclaration des serveurs MCP : global uniquement. */ +export const mcpConfigPath = (homeDir: string): string => join(resolve(homeDir), ".maia", "mcp.json"); + +/** + * Remplace `${VARIABLE}` par la valeur de la variable d'environnement (chaîne vide si elle n'existe pas, + * le nom est alors ajouté à `missing`). + */ +export function expandEnv(text: string, env: Record, missing: string[] = []): string { + return text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name: string) => { + const v = env[name]; + if (v === undefined) { + if (!missing.includes(name)) missing.push(name); + return ""; + } + return v; + }); +} + +const isStringList = (v: unknown): v is string[] => Array.isArray(v) && v.every((x) => typeof x === "string"); + +/** + * Lit ~/.maia/mcp.json (`{ "mcpServers": { "": { url, type?, headers?, timeout?, includeTools?, excludeTools?, enabled?, description? } } }`). + * Fichier absent : normal. Serveur invalide : avertissement, il est ignoré. `enabled: false` : serveur ignoré sans avertissement. + */ +export async function loadMcpConfig( + homeDir: string, + env: Record = process.env, +): Promise<{ servers: McpServerConfig[]; warnings: string[]; path: string }> { + const path = mcpConfigPath(homeDir); + const servers: McpServerConfig[] = []; + const warnings: string[] = []; + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch { + return { servers, warnings, path }; + } + let json: unknown; + try { + json = JSON.parse(raw); + } catch (err) { + warnings.push(`${path} : JSON invalide, serveurs MCP ignorés (${(err as Error).message})`); + return { servers, warnings, path }; + } + const table = (json as { mcpServers?: unknown } | null)?.mcpServers; + if (table === undefined) return { servers, warnings, path }; + if (typeof table !== "object" || table === null || Array.isArray(table)) { + warnings.push(`${path} : "mcpServers" doit être un objet { "": { "url": "…" } }`); + return { servers, warnings, path }; + } + + for (const [key, value] of Object.entries(table)) { + const where = `${path} : serveur « ${key} »`; + if (!isValidServerKey(key)) { + warnings.push(`${where} ignoré : nom invalide (lettres, chiffres, - et _ uniquement, sans « __ »)`); + continue; + } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + warnings.push(`${where} ignoré : la déclaration doit être un objet`); + continue; + } + const c = value as Record; + if (c.enabled === false) continue; + const missing: string[] = []; + + if (typeof c.url !== "string" || c.url.trim() === "") { + warnings.push(`${where} ignoré : "url" manquante`); + continue; + } + const url = expandEnv(c.url.trim(), env, missing); + try { + const u = new URL(url); + if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("protocole"); + } catch { + warnings.push(`${where} ignoré : "url" invalide (http:// ou https:// attendu)`); + continue; + } + const type = c.type === undefined ? "http" : c.type; + if (type !== "http" && type !== "sse") { + warnings.push(`${where} ignoré : "type" doit valoir "http" ou "sse"`); + continue; + } + const headers: Record = {}; + if (c.headers !== undefined) { + if (typeof c.headers !== "object" || c.headers === null || Array.isArray(c.headers)) { + warnings.push(`${where} : "headers" doit être un objet, ignoré`); + } else { + for (const [h, v] of Object.entries(c.headers)) { + if (typeof v === "string") headers[h] = expandEnv(v, env, missing); + else warnings.push(`${where} : en-tête « ${h} » ignoré (texte attendu)`); + } + } + } + let timeout = MCP_DEFAULT_TIMEOUT_S; + if (c.timeout !== undefined) { + if (typeof c.timeout === "number" && c.timeout > 0) timeout = c.timeout; + else warnings.push(`${where} : "timeout" doit être un nombre de secondes > 0, défaut ${MCP_DEFAULT_TIMEOUT_S} s`); + } + const lists: Pick = {}; + for (const k of ["includeTools", "excludeTools"] as const) { + if (c[k] === undefined) continue; + if (isStringList(c[k])) lists[k] = c[k] as string[]; + else warnings.push(`${where} : "${k}" doit être une liste de noms d'outils, ignoré`); + } + for (const name of missing) warnings.push(`${where} : variable d'environnement ${name} non définie (remplacée par du vide)`); + + servers.push({ + key, + type, + url, + headers, + timeout, + ...lists, + description: typeof c.description === "string" ? c.description : undefined, + }); + } + return { servers, warnings, path }; +} diff --git a/packages/agent/src/mcp-rules.ts b/packages/agent/src/mcp-rules.ts new file mode 100644 index 0000000..e8bfc44 --- /dev/null +++ b/packages/agent/src/mcp-rules.ts @@ -0,0 +1,109 @@ +// Règles `Mcp(serveur[,outil])` des settings.json : pures (aucune E/S), évaluées AVANT toute connexion. +// +// - `Mcp(github)` : le serveur « github » (clé de ~/.maia/mcp.json), tous ses outils ; +// - `Mcp(github,delete_repository)`: un outil précis du serveur ; l'outil accepte `*` (`Mcp(github,delete_*)`) ; +// - `Mcp(github,*)` : tous les outils (équivaut à `Mcp(github)`) ; +// - `Mcp(*)` = `Mcp(*,*)` : tous les serveurs déclarés et tous leurs outils ; +// - `Mcp(*,outil)` : cet outil (motif accepté) sur tous les serveurs. + +import type { PermissionRules } from "./settings"; + +/** Préfixe des noms d'outils MCP exposés au modèle : `mcp____`. */ +export const MCP_PREFIX = "mcp__"; +/** Limite de longueur d'un nom d'outil côté API OpenAI. */ +export const MAX_TOOL_NAME = 64; + +/** Une clé de serveur est utilisable dans un nom d'outil : lettres, chiffres, `-` et `_` (jamais `__`). */ +export function isValidServerKey(key: string): boolean { + return /^[A-Za-z0-9_-]+$/.test(key) && !key.includes("__"); +} + +/** Nom d'outil compatible avec les API LLM : caractères hors `[A-Za-z0-9_-]` remplacés par `_`. */ +export function sanitizeToolName(name: string): string { + return name.replace(/[^A-Za-z0-9_-]/g, "_"); +} + +export const mcpToolName = (server: string, tool: string): string => `${MCP_PREFIX}${server}__${sanitizeToolName(tool)}`; + +/** `mcp__github__create_issue` → { server: "github", tool: "create_issue" } ; null si ce n'est pas un outil MCP. */ +export function splitMcpToolName(name: string): { server: string; tool: string } | null { + if (!name.startsWith(MCP_PREFIX)) return null; + const rest = name.slice(MCP_PREFIX.length); + const i = rest.indexOf("__"); + if (i <= 0) return null; + return { server: rest.slice(0, i), tool: rest.slice(i + 2) }; +} + +export interface McpSpec { + server: string; + /** Motif d'outil (`*` autorisé) ; absent : tous les outils du serveur. */ + tool?: string; +} + +/** `github` ou `github,delete_repository` (espaces tolérés autour de la virgule). */ +export function parseMcpSpec(spec: string | undefined): McpSpec | null { + if (!spec) return null; + const i = spec.indexOf(","); + const server = (i === -1 ? spec : spec.slice(0, i)).trim(); + const tool = i === -1 ? undefined : spec.slice(i + 1).trim(); + if (!server) return null; + return { server, tool: tool || undefined }; +} + +const escapeRe = (text: string): string => text.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + +function matchToolPattern(pattern: string, tool: string): boolean { + const re = new RegExp(`^${pattern.split("*").map((part) => escapeRe(sanitizeToolName(part))).join(".*")}$`); + return re.test(sanitizeToolName(tool)); +} + +/** La spécification `spec` (contenu de `Mcp(...)`) vise-t-elle cet appel d'outil `mcp__serveur__outil` ? */ +export function mcpSpecMatches(spec: string | undefined, toolName: string): boolean { + const call = splitMcpToolName(toolName); + if (!call) return false; + if (!spec) return true; // `Mcp` seul : tous les serveurs + const parsed = parseMcpSpec(spec); + if (!parsed || (parsed.server !== "*" && parsed.server !== call.server)) return false; + return parsed.tool === undefined || matchToolPattern(parsed.tool, call.tool); +} + +const mcpSpecs = (rules: string[]): McpSpec[] => + rules.flatMap((raw) => { + const m = /^Mcp\((.*)\)$/s.exec(raw.trim()); + const spec = m ? parseMcpSpec(m[1]) : null; + return spec ? [spec] : []; + }); + +/** Règle qui vise tout le serveur : `Mcp(s)` ou `Mcp(s,*)`. */ +const sameServer = (s: McpSpec, key: string): boolean => s.server === "*" || s.server === key; + +const wholeServer = (s: McpSpec): boolean => s.tool === undefined || s.tool === "*"; + +export interface McpServerSelection { + /** Serveurs à connecter : autorisés par une règle allow et non interdits en entier par une règle deny. */ + enabled: string[]; + /** Serveurs cités dans les règles Mcp(...) mais absents du fichier de déclaration. */ + unknown: string[]; + /** Outil retiré par une règle deny `Mcp(serveur,outil)` (jamais exposé au modèle). */ + isToolDenied(server: string, tool: string): boolean; +} + +/** + * Détermine, avant toute connexion, quels serveurs déclarés (clés de mcp.json) sont activés. + * Par défaut aucun : il faut une règle allow `Mcp(serveur)` (ou `Mcp(serveur,outil)`) dans les settings.json. + * Une règle deny `Mcp(serveur)` / `Mcp(serveur,*)` désactive le serveur : on ne cherche pas à s'y connecter. + */ +export function selectMcpServers(rules: PermissionRules, declared: string[]): McpServerSelection { + const allow = mcpSpecs(rules.allow); + const deny = mcpSpecs(rules.deny); + const mentioned = new Set([...allow, ...deny, ...mcpSpecs(rules.ask)].map((s) => s.server)); + const enabled = declared.filter( + (key) => allow.some((s) => sameServer(s, key)) && !deny.some((s) => sameServer(s, key) && wholeServer(s)), + ); + return { + enabled, + unknown: [...mentioned].filter((k) => k !== "*" && !declared.includes(k)), + isToolDenied: (server, tool) => + deny.some((s) => sameServer(s, server) && (wholeServer(s) || (s.tool !== undefined && matchToolPattern(s.tool, tool)))), + }; +} diff --git a/packages/agent/src/mcp.test.ts b/packages/agent/src/mcp.test.ts new file mode 100644 index 0000000..413eebe --- /dev/null +++ b/packages/agent/src/mcp.test.ts @@ -0,0 +1,316 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { expandEnv, loadMcpConfig } from "./mcp-config"; +import { mcpSpecMatches, mcpToolName, selectMcpServers, splitMcpToolName } from "./mcp-rules"; +import { setupMcp } from "./mcp"; +import { createPermissionPolicy } from "./permissions"; + +const none = { allow: [] as string[], ask: [] as string[], deny: [] as string[] }; + +// ───────────── Faux serveur MCP ───────────── + +type Mode = "json" | "sse-post" | "legacy" | "http500"; +interface Fake { + url: string; + hits: number; + auth: (string | undefined)[]; + calls: { name: string; args: unknown }[]; + close(): Promise; +} + +const TOOLS = [ + { name: "echo", description: "Renvoie le message", inputSchema: { type: "object", properties: { msg: { type: "string" } } } }, + { name: "delete_repository", description: "Dangereux", inputSchema: { type: "object", properties: {} } }, + { name: "boom", description: "Échoue", inputSchema: { type: "object", properties: {} } }, +]; + +function rpcResult(fake: Fake, msg: { id?: number; method?: string; params?: Record }): object | null { + if (msg.id === undefined) return null; // notification + if (msg.method === "initialize") return { jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fake", version: "1" } } }; + if (msg.method === "tools/list") return { jsonrpc: "2.0", id: msg.id, result: { tools: TOOLS } }; + if (msg.method === "tools/call") { + const name = String(msg.params?.name); + fake.calls.push({ name, args: msg.params?.arguments }); + if (name === "boom") return { jsonrpc: "2.0", id: msg.id, result: { isError: true, content: [{ type: "text", text: "kaboom" }] } }; + const text = `echo:${String((msg.params?.arguments as { msg?: string } | undefined)?.msg)}`; + return { jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text }] } }; + } + return { jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "méthode inconnue" } }; +} + +async function body(req: IncomingMessage): Promise { + let s = ""; + for await (const c of req) s += c; + return s; +} + +async function startFake(mode: Mode): Promise { + const streams: ServerResponse[] = []; + const fake: Fake = { url: "", hits: 0, auth: [], calls: [], close: async () => undefined }; + const server: Server = createServer(async (req, res) => { + fake.hits++; + fake.auth.push(req.headers.authorization); + if (mode === "http500") { + res.writeHead(500, { "Content-Type": "text/plain" }).end("panne"); + return; + } + if (mode === "legacy") { + if (req.method === "GET") { + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.write("event: endpoint\ndata: /messages?s=1\n\n"); + streams.push(res); + return; + } + const msg = JSON.parse(await body(req)); + res.writeHead(202).end("Accepted"); + const out = rpcResult(fake, msg); + if (out) streams[0]?.write(`event: message\ndata: ${JSON.stringify(out)}\n\n`); + return; + } + if (req.method !== "POST") { + res.writeHead(200).end(); + return; + } + const msg = JSON.parse(await body(req)); + const out = rpcResult(fake, msg); + if (!out) { + res.writeHead(202).end(); + return; + } + if (mode === "sse-post") { + res.writeHead(200, { "Content-Type": "text/event-stream", "Mcp-Session-Id": "abc" }); + res.end(`event: message\ndata: ${JSON.stringify(out)}\n\n`); + } else { + res.writeHead(200, { "Content-Type": "application/json", "Mcp-Session-Id": "abc" }).end(JSON.stringify(out)); + } + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = (server.address() as AddressInfo).port; + fake.url = `http://127.0.0.1:${port}${mode === "legacy" ? "/sse" : "/mcp"}`; + fake.close = () => + new Promise((r) => { + for (const s of streams) s.end(); + server.closeAllConnections(); + server.close(() => r()); + }); + return fake; +} + +async function home(servers: object): Promise { + const dir = await mkdtemp(join(tmpdir(), "maia-mcp-")); + await mkdir(join(dir, ".maia"), { recursive: true }); + await writeFile(join(dir, ".maia", "mcp.json"), JSON.stringify({ mcpServers: servers })); + return dir; +} + +// ───────────── Configuration ───────────── + +describe("mcp.json", () => { + it("expansion ${VAR} : valeur de l'environnement, vide + signalement si absente", () => { + const missing: string[] = []; + expect(expandEnv("Bearer ${TOKEN}", { TOKEN: "abc" }, missing)).toBe("Bearer abc"); + expect(expandEnv("x${NOPE}y", {}, missing)).toBe("xy"); + expect(missing).toEqual(["NOPE"]); + }); + + it("charge les serveurs avec valeurs par défaut et expansion dans url et headers", async () => { + const h = await home({ + github: { type: "http", url: "https://api.example.com/mcp/", headers: { Authorization: "Bearer ${GITHUB_TOKEN}" }, excludeTools: ["delete_repository"] }, + docs: { url: "https://docs.example.com/mcp", timeout: 60 }, + }); + const { servers, warnings } = await loadMcpConfig(h, { GITHUB_TOKEN: "t0k" }); + expect(warnings).toEqual([]); + expect(servers.length).toBe(2); + expect(servers[0]!.headers.Authorization).toBe("Bearer t0k"); + expect(servers[0]!.excludeTools).toEqual(["delete_repository"]); + expect(servers[0]!.timeout).toBe(30); + expect(servers[1]!.type).toBe("http"); + expect(servers[1]!.timeout).toBe(60); + }); + + it("fichier absent : rien, sans avertissement", async () => { + const dir = await mkdtemp(join(tmpdir(), "maia-mcp-")); + const r = await loadMcpConfig(dir, {}); + expect(r.servers).toEqual([]); + expect(r.warnings).toEqual([]); + }); + + it("serveurs invalides ignorés avec avertissement ; enabled:false ignoré en silence", async () => { + const h = await home({ + sansurl: { type: "http" }, + mauvais_type: { url: "https://a.example/mcp", type: "ws" }, + "a__b": { url: "https://a.example/mcp" }, + ftp: { url: "ftp://a.example/mcp" }, + off: { url: "https://a.example/mcp", enabled: false }, + bon: { url: "https://a.example/mcp", headers: { X: "${ABSENT}" } }, + }); + const { servers, warnings } = await loadMcpConfig(h, {}); + expect(servers.map((s) => s.key)).toEqual(["bon"]); + expect(warnings.length).toBe(5); // 4 serveurs refusés + variable absente + expect(warnings.some((w) => w.includes("ABSENT"))).toBe(true); + }); +}); + +// ───────────── Règles Mcp(...) ───────────── + +describe("règles Mcp(serveur[,outil])", () => { + it("noms d'outils : construction et découpage", () => { + expect(mcpToolName("github", "create_issue")).toBe("mcp__github__create_issue"); + expect(mcpToolName("github", "a.b c")).toBe("mcp__github__a_b_c"); + expect(splitMcpToolName("mcp__github__create_issue")).toEqual({ server: "github", tool: "create_issue" }); + expect(splitMcpToolName("Bash")).toBe(null); + }); + + it("par défaut aucun serveur n'est activé ; allow Mcp(s) active ; allow d'un autre serveur n'active pas", () => { + expect(selectMcpServers(none, ["github", "docs"]).enabled).toEqual([]); + expect(selectMcpServers({ ...none, allow: ["Mcp(github)"] }, ["github", "docs"]).enabled).toEqual(["github"]); + }); + + it("allow Mcp(github) + deny Mcp(github,*) : serveur désactivé ; deny d'un outil : serveur actif", () => { + const off = selectMcpServers({ ...none, allow: ["Mcp(github)"], deny: ["Mcp(github,*)"] }, ["github"]); + expect(off.enabled).toEqual([]); + const on = selectMcpServers({ ...none, allow: ["Mcp(github)"], deny: ["Mcp(github,delete_repository)"] }, ["github"]); + expect(on.enabled).toEqual(["github"]); + expect(on.isToolDenied("github", "delete_repository")).toBe(true); + expect(on.isToolDenied("github", "create_issue")).toBe(false); + }); + + it("règle sur un serveur non déclaré : signalée", () => { + expect(selectMcpServers({ ...none, allow: ["Mcp(inconnu)"] }, ["github"]).unknown).toEqual(["inconnu"]); + }); + + it("serveur * : Mcp(*) et Mcp(*,*) = tous les serveurs et outils ; Mcp(*,outil) = cet outil partout", () => { + for (const rule of ["Mcp(*)", "Mcp(*,*)"]) { + expect(selectMcpServers({ ...none, allow: [rule] }, ["github", "docs"]).enabled).toEqual(["github", "docs"]); + expect(selectMcpServers({ ...none, allow: ["Mcp(github)"], deny: [rule] }, ["github", "docs"]).enabled).toEqual([]); + } + expect(selectMcpServers({ ...none, allow: ["Mcp(*)"], deny: ["Mcp(docs)"] }, ["github", "docs"]).enabled).toEqual(["github"]); + expect(selectMcpServers({ ...none, allow: ["Mcp(*)"] }, ["github"]).unknown).toEqual([]); + const sel = selectMcpServers({ ...none, allow: ["Mcp(*)"], deny: ["Mcp(*,delete_repository)"] }, ["github", "docs"]); + expect(sel.enabled).toEqual(["github", "docs"]); + expect(sel.isToolDenied("docs", "delete_repository")).toBe(true); + expect(sel.isToolDenied("docs", "search")).toBe(false); + const policy = createPermissionPolicy({ allow: ["Mcp(*)"], ask: ["Mcp(*,merge_*)"], deny: ["Mcp(*,drop)"] }); + const check = (t: string): string => policy.check(t, {}, { cwd: "/p" })!.decision; + expect(check("mcp__a__search")).toBe("allow"); + expect(check("mcp__b__merge_pr")).toBe("ask"); + expect(check("mcp__b__drop")).toBe("deny"); + }); + + it("motifs d'outil avec *", () => { + expect(mcpSpecMatches("github,delete_*", "mcp__github__delete_repository")).toBe(true); + expect(mcpSpecMatches("github,delete_*", "mcp__github__create_issue")).toBe(false); + expect(mcpSpecMatches("github", "mcp__docs__search")).toBe(false); + }); + + it("politique : allow Mcp(s) autorise sans confirmation, deny d'un outil l'emporte, sans règle : confirmation", () => { + const policy = createPermissionPolicy({ allow: ["Mcp(github)"], ask: [], deny: ["Mcp(github,delete_repository)"] }); + const check = (t: string): string => policy.check(t, {}, { cwd: "/p" })!.decision; + expect(check("mcp__github__create_issue")).toBe("allow"); + expect(check("mcp__github__delete_repository")).toBe("deny"); + expect(check("mcp__docs__search")).toBe("ask"); + expect(check("Bash")).toBe("ask"); + }); + + it("politique : ask sur un outil précis l'emporte sur allow du serveur", () => { + const policy = createPermissionPolicy({ allow: ["Mcp(github)"], ask: ["Mcp(github,merge_pull_request)"], deny: [] }); + expect(policy.check("mcp__github__merge_pull_request", {}, { cwd: "/p" })!.decision).toBe("ask"); + }); +}); + +// ───────────── Connexion (faux serveur) ───────────── + +describe("connexion aux serveurs MCP", () => { + for (const mode of ["json", "sse-post", "legacy"] as const) { + it(`transport ${mode} : liste des outils, filtrage, appel d'outil, en-tête Authorization`, async () => { + const fake = await startFake(mode); + try { + const h = await home({ + fake: { type: mode === "legacy" ? "sse" : "http", url: fake.url, headers: { Authorization: "Bearer ${TOK}" }, excludeTools: ["delete_repository"] }, + }); + const mcp = await setupMcp(h, { ...none, allow: ["Mcp(fake)"] }, { env: { TOK: "secret" } }); + expect(mcp.warnings).toEqual([]); + expect(mcp.statuses).toEqual([{ name: "fake", status: "ok", toolCount: 2 }]); + expect(mcp.tools.map((t) => t.spec.name)).toEqual(["mcp__fake__echo", "mcp__fake__boom"]); + expect(fake.auth.every((a) => a === "Bearer secret")).toBe(true); + const echo = mcp.tools[0]!; + expect(echo.permission).toBe("ask"); + expect(await echo.execute({ msg: "salut" }, { cwd: "/p" })).toBe("echo:salut"); + expect(fake.calls).toEqual([{ name: "echo", args: { msg: "salut" } }]); + await mcp.close(); + } finally { + await fake.close(); + } + }); + } + + it("un outil qui renvoie isError lève une erreur avec le texte du serveur", async () => { + const fake = await startFake("json"); + try { + const h = await home({ fake: { url: fake.url } }); + const mcp = await setupMcp(h, { ...none, allow: ["Mcp(fake)"] }, { env: {} }); + let message = ""; + try { + await mcp.tools.find((t) => t.spec.name === "mcp__fake__boom")!.execute({}, { cwd: "/p" }); + } catch (err) { + message = (err as Error).message; + } + expect(message).toBe("kaboom"); + await mcp.close(); + } finally { + await fake.close(); + } + }); + + it("règle deny Mcp(s,outil) : l'outil n'est jamais exposé", async () => { + const fake = await startFake("json"); + try { + const h = await home({ fake: { url: fake.url } }); + const mcp = await setupMcp(h, { ...none, allow: ["Mcp(fake)"], deny: ["Mcp(fake,delete_repository)", "Mcp(fake,boom)"] }, { env: {} }); + expect(mcp.tools.map((t) => t.spec.name)).toEqual(["mcp__fake__echo"]); + await mcp.close(); + } finally { + await fake.close(); + } + }); + + it("serveur non autorisé ou désactivé par Mcp(s,*) : aucune connexion tentée, absent de la liste", async () => { + const fake = await startFake("json"); + try { + const h = await home({ fake: { url: fake.url } }); + const sansRegle = await setupMcp(h, none, { env: {} }); + expect(sansRegle.statuses).toEqual([]); + const desactive = await setupMcp(h, { ...none, allow: ["Mcp(fake)"], deny: ["Mcp(fake,*)"] }, { env: {} }); + expect(desactive.statuses).toEqual([]); + expect(desactive.tools).toEqual([]); + expect(fake.hits).toBe(0); + } finally { + await fake.close(); + } + }); + + it("échec (HTTP 500, serveur injoignable) : statut FAIL, avertissement, l'autre serveur reste OK", async () => { + const bad = await startFake("http500"); + const good = await startFake("json"); + try { + const h = await home({ + panne: { url: bad.url }, + absent: { url: "http://127.0.0.1:1/mcp" }, + bon: { url: good.url }, + }); + const mcp = await setupMcp(h, { ...none, allow: ["Mcp(panne)", "Mcp(absent)", "Mcp(bon)"] }, { env: {} }); + expect(mcp.statuses.map((s) => `${s.name}:${s.status}`)).toEqual(["panne:fail", "absent:fail", "bon:ok"]); + expect(mcp.statuses[0]!.error!.includes("500")).toBe(true); + expect(mcp.warnings.length).toBe(2); + expect(mcp.tools.length).toBe(3); + await mcp.close(); + } finally { + await bad.close(); + await good.close(); + } + }); +}); diff --git a/packages/agent/src/mcp.ts b/packages/agent/src/mcp.ts new file mode 100644 index 0000000..b454654 --- /dev/null +++ b/packages/agent/src/mcp.ts @@ -0,0 +1,104 @@ +import type { Tool } from "@maia/core"; +import { loadMcpConfig, type McpServerConfig } from "./mcp-config"; +import { McpClient, type McpToolDef } from "./mcp-client"; +import { MAX_TOOL_NAME, mcpToolName, sanitizeToolName, selectMcpServers } from "./mcp-rules"; +import type { PermissionRules } from "./settings"; + +export interface McpServerStatus { + /** Clé du serveur (mcp.json). */ + name: string; + status: "ok" | "fail"; + error?: string; + toolCount: number; +} + +export interface McpSetup { + tools: Tool[]; + /** Un état par serveur ACTIVÉ (les serveurs non activés n'apparaissent pas). */ + statuses: McpServerStatus[]; + warnings: string[]; + close(): Promise; +} + +const matchesAny = (patterns: string[], tool: string): boolean => + patterns.some((p) => new RegExp(`^${p.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`).test(tool)); + +/** Outil du modèle pour un outil MCP : `mcp__serveur__outil`, confirmation demandée sauf règle allow. */ +export function createMcpTool(client: McpClient, serverKey: string, def: McpToolDef): Tool { + return { + spec: { + name: mcpToolName(serverKey, def.name), + description: `[MCP ${serverKey}] ${def.description ?? def.name}`, + inputSchema: def.inputSchema ?? { type: "object", properties: {} }, + }, + permission: "ask", + describe: (input) => { + const text = JSON.stringify(input); + return `${serverKey} › ${def.name} ${text.length > 120 ? `${text.slice(0, 120)}…` : text}`; + }, + execute: (input, ctx) => client.callTool(def.name, input, ctx.signal), + }; +} + +/** Connecte un serveur ; ne lève jamais : l'échec est porté par le statut. */ +async function connectServer( + cfg: McpServerConfig, + isToolDenied: (server: string, tool: string) => boolean, + fetchFn: typeof fetch | undefined, +): Promise<{ client: McpClient; tools: Tool[]; status: McpServerStatus; warnings: string[] }> { + const client = new McpClient(cfg, fetchFn); + const warnings: string[] = []; + try { + await client.connect(); + const defs = await client.listTools(); + const tools: Tool[] = []; + const seen = new Set(); + for (const def of defs) { + if (cfg.includeTools && !matchesAny(cfg.includeTools, def.name)) continue; + if (cfg.excludeTools && matchesAny(cfg.excludeTools, def.name)) continue; + if (isToolDenied(cfg.key, def.name)) continue; + const name = mcpToolName(cfg.key, def.name); + if (name.length > MAX_TOOL_NAME) { + warnings.push(`MCP ${cfg.key} : outil « ${def.name} » ignoré (nom trop long : ${name.length} > ${MAX_TOOL_NAME} caractères)`); + continue; + } + if (seen.has(name)) { + warnings.push(`MCP ${cfg.key} : outil « ${def.name} » ignoré (nom en double après nettoyage : ${sanitizeToolName(def.name)})`); + continue; + } + seen.add(name); + tools.push(createMcpTool(client, cfg.key, def)); + } + return { client, tools, status: { name: cfg.key, status: "ok", toolCount: tools.length }, warnings }; + } catch (err) { + await client.close(); + const error = (err as Error).message; + warnings.push(`MCP ${cfg.key} : connexion impossible (${error})`); + return { client, tools: [], status: { name: cfg.key, status: "fail", error, toolCount: 0 }, warnings }; + } +} + +/** + * Serveurs MCP : déclarations globales (~/.maia/mcp.json) → activation par les règles Mcp(...) des settings.json + * (traitée AVANT toute connexion) → connexion en parallèle aux seuls serveurs activés. + */ +export async function setupMcp( + homeDir: string, + rules: PermissionRules, + opts: { env?: Record; fetch?: typeof fetch } = {}, +): Promise { + const { servers, warnings } = await loadMcpConfig(homeDir, opts.env ?? process.env); + const selection = selectMcpServers(rules, servers.map((s) => s.key)); + for (const key of selection.unknown) warnings.push(`Règle Mcp(${key}…) : serveur « ${key} » absent de ~/.maia/mcp.json`); + + const active = servers.filter((s) => selection.enabled.includes(s.key)); + const results = await Promise.all(active.map((cfg) => connectServer(cfg, selection.isToolDenied, opts.fetch))); + return { + tools: results.flatMap((r) => r.tools), + statuses: results.map((r) => r.status), + warnings: [...warnings, ...results.flatMap((r) => r.warnings)], + close: async () => { + await Promise.all(results.map((r) => r.client.close())); + }, + }; +} diff --git a/packages/agent/src/new-agent.test.ts b/packages/agent/src/new-agent.test.ts index dae05fe..5cff970 100644 --- a/packages/agent/src/new-agent.test.ts +++ b/packages/agent/src/new-agent.test.ts @@ -36,7 +36,7 @@ async function setup() { return { home, cwd, templatePath }; } -describe("/newAgent : création depuis le modèle", () => { +describe("/createAgent : création depuis le modèle", () => { it("dossiers : global = ~/.maia/agents, projet = ./.maia/agents", () => { expect(agentsDirFor("global", "/p", "/h")).toBe("/h/.maia/agents"); expect(agentsDirFor("projet", "/p", "/h")).toBe("/p/.maia/agents"); diff --git a/packages/agent/src/permissions-redirect.test.ts b/packages/agent/src/permissions-redirect.test.ts new file mode 100644 index 0000000..c8f59eb --- /dev/null +++ b/packages/agent/src/permissions-redirect.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { createPermissionPolicy, extractRedirections } from "./permissions"; + +const decide = (rules: { allow?: string[]; ask?: string[]; deny?: string[] }, command: string): string => + createPermissionPolicy({ allow: [], ask: [], deny: [], ...rules }).check("Bash", { command }, { cwd: "/p" })!.decision; + +describe("extractRedirections", () => { + it("repère >, >>, >|, &>, 2> et leurs cibles ; retire la redirection de la commande", () => { + expect(extractRedirections("ls > out.txt")).toMatchObject({ targets: ["out.txt"], unknown: false }); + expect(extractRedirections("ls >> logs/a.log").targets).toEqual(["logs/a.log"]); + expect(extractRedirections("ls >| f").targets).toEqual(["f"]); + expect(extractRedirections("cmd 2> err.txt").targets).toEqual(["err.txt"]); + expect(extractRedirections("cmd &> all.txt").targets).toEqual(["all.txt"]); + expect(extractRedirections("cmd >&all.txt").targets).toEqual(["all.txt"]); + expect(extractRedirections("ls>f").targets).toEqual(["f"]); + expect(extractRedirections("ls -la > out.txt").stripped.trim()).toBe("ls -la"); + expect(extractRedirections("cmd 2>/dev/null | wc").stripped.replace(/\s+/g, " ").trim()).toBe("cmd | wc"); + }); + it("duplication de descripteur (2>&1, >&2) : aucun fichier", () => { + expect(extractRedirections("cmd 2>&1")).toMatchObject({ targets: [], unknown: false }); + expect(extractRedirections("cmd >&2").targets).toEqual([]); + expect(extractRedirections("cmd > out.txt 2>&1").targets).toEqual(["out.txt"]); + }); + it("un > entre guillemets ou échappé n'est pas une redirection ; guillemets autour de la cible retirés", () => { + expect(extractRedirections(`echo "a > b" 'c > d'`).targets).toEqual([]); + expect(extractRedirections("echo a \\> b").targets).toEqual([]); + expect(extractRedirections(`ls > "mon fichier.txt"`).targets).toEqual(["mon fichier.txt"]); + expect(extractRedirections("echo 2 > f").targets).toEqual(["f"]); + expect(extractRedirections("echo 2 > f").stripped.trim()).toBe("echo 2"); + }); + it("analyse incertaine : guillemet non fermé, cible absente, >( ", () => { + expect(extractRedirections(`echo "abc > f`).unknown).toBe(true); + expect(extractRedirections("ls >").unknown).toBe(true); + expect(extractRedirections("ls > (cat)").unknown).toBe(true); + }); +}); + +describe("règles Redirect", () => { + it("sans règle Redirect : toute redirection de fichier repasse en confirmation (comportement par défaut)", () => { + expect(decide({ allow: ["Bash(ls *)"] }, "ls")).toBe("allow"); + expect(decide({ allow: ["Bash(ls *)"] }, "ls > out.txt")).toBe("ask"); + expect(decide({ allow: ["Bash(ls *)"] }, "ls >> out.txt")).toBe("ask"); + expect(decide({ allow: ["Bash(ls *)"] }, "ls 2> err.txt")).toBe("ask"); + }); + it("2>&1 et un > entre guillemets ne demandent rien de plus", () => { + expect(decide({ allow: ["Bash(ls *)"] }, "ls 2>&1")).toBe("allow"); + expect(decide({ allow: ["Bash(echo *)"] }, `echo "a > b"`)).toBe("allow"); + }); + it("allow Redirect(motif) : seules les cibles couvertes passent, la commande doit l'être aussi", () => { + const rules = { allow: ["Bash(ls *)", "Redirect(/dev/null)", "Redirect(logs/**)"] }; + expect(decide(rules, "ls > /dev/null")).toBe("allow"); + expect(decide(rules, "ls 2>/dev/null")).toBe("allow"); + expect(decide(rules, "ls >> logs/a.log 2>&1")).toBe("allow"); + expect(decide(rules, "ls > src/main.ts")).toBe("ask"); + expect(decide(rules, "ls > logs/a.log > src/x")).toBe("ask"); // toutes les cibles doivent être couvertes + expect(decide(rules, "cat f > logs/a.log")).toBe("ask"); // cat n'est pas autorisé + expect(decide({ allow: ["Redirect(logs/**)"] }, "ls > logs/a.log")).toBe("ask"); // Redirect seul n'autorise pas la commande + }); + it("allow Redirect (sans motif) : toutes les cibles ; cible dynamique ($VAR, glob) jamais couverte par un motif", () => { + expect(decide({ allow: ["Bash(ls *)", "Redirect"] }, "ls > n/importe/quoi")).toBe("allow"); + const rules = { allow: ["Bash(ls *)", "Redirect(logs/**)"] }; + expect(decide(rules, "ls > $HOME/x")).toBe("ask"); + expect(decide(rules, "ls > logs/*.txt")).toBe("ask"); + expect(decide(rules, "ls > logs/../src/a")).toBe("ask"); + }); + it("deny / ask Redirect(motif) : refus même si la commande est autorisée, même avec « Bash » complet", () => { + const rules = { allow: ["Bash", "Redirect"], deny: ["Redirect(.env)", "Redirect(.git/**)"], ask: ["Redirect(docs/**)"] }; + expect(decide(rules, "echo x > .env")).toBe("deny"); + expect(decide(rules, "echo x >> .git/config")).toBe("deny"); + expect(decide(rules, "echo x | tee a; echo y 2> .env")).toBe("deny"); + expect(decide(rules, "echo x > docs/a.md")).toBe("ask"); + expect(decide(rules, "echo x > notes.txt")).toBe("allow"); + }); + it("deny Bash existant toujours actif ; substitution $(…) toujours en confirmation", () => { + expect(decide({ allow: ["Bash(ls *)", "Redirect"], deny: ["Bash(rm *)"] }, "ls > f; rm f")).toBe("deny"); + expect(decide({ allow: ["Bash(ls *)", "Redirect"] }, "ls $(pwd) > f")).toBe("ask"); + expect(decide({ allow: ["Bash(ls *)", "Redirect"] }, "ls > >(cat)")).toBe("ask"); + }); + it("redirection seule sans commande, ou guillemet non fermé : confirmation", () => { + expect(decide({ allow: ["Bash(ls *)", "Redirect"] }, "> f")).toBe("ask"); + expect(decide({ allow: ["Bash(echo *)", "Redirect"] }, `echo "abc > f`)).toBe("ask"); + }); +}); diff --git a/packages/agent/src/permissions.ts b/packages/agent/src/permissions.ts index a20060d..c13f533 100644 --- a/packages/agent/src/permissions.ts +++ b/packages/agent/src/permissions.ts @@ -1,6 +1,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path"; import picomatch from "picomatch"; import type { PermissionPolicy } from "@maia/core"; +import { mcpSpecMatches } from "./mcp-rules"; import type { PermissionRules } from "./settings"; interface Rule { @@ -11,8 +12,101 @@ interface Rule { const RULE_RE = /^([A-Za-z0-9_.-]+)(?:\((.*)\))?$/s; const PATH_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "Grep"]); -/** Substitutions de commandes, redirections : une règle allow ne peut pas les couvrir de façon sûre. */ -const UNSAFE_SHELL = /\$\(|`|<\(|>/; +/** Substitutions de commandes et de processus : une règle allow ne peut pas les couvrir de façon sûre. */ +const UNSAFE_SHELL = /\$\(|`|<\(|>\(/; + +// ----- Redirections de sortie (>, >>, >|, &>, 2>…) : règles `Redirect(motif de chemin)` + +export interface Redirections { + /** Commande sans ses redirections (chaque redirection est remplacée par un espace). */ + stripped: string; + /** Cibles (fichiers) des redirections de sortie, sans guillemets. Les duplications `2>&1`, `>&2` n'en ont pas. */ + targets: string[]; + /** true si l'analyse n'est pas sûre (guillemet non fermé, cible absente ou `>(`) : rien n'est autorisé automatiquement. */ + unknown: boolean; +} + +/** + * Repère les redirections de sortie de la commande en respectant les guillemets simples et doubles et les `\` + * (un `>` entre guillemets n'est pas une redirection). `2>&1` / `>&2` (duplication d'un descripteur) ne désignent aucun fichier. + */ +export function extractRedirections(cmd: string): Redirections { + let stripped = ""; + const targets: string[] = []; + let unknown = false; + const n = cmd.length; + let i = 0; + + while (i < n) { + const ch = cmd[i]!; + if (ch === "\\") { + stripped += ch + (cmd[i + 1] ?? ""); + i += 2; + } else if (ch === "'") { + const j = cmd.indexOf("'", i + 1); + if (j === -1) return { stripped: stripped + cmd.slice(i), targets, unknown: true }; + stripped += cmd.slice(i, j + 1); + i = j + 1; + } else if (ch === '"') { + let j = i + 1; + while (j < n && cmd[j] !== '"') j += cmd[j] === "\\" ? 2 : 1; + if (j >= n) return { stripped: stripped + cmd.slice(i), targets, unknown: true }; + stripped += cmd.slice(i, j + 1); + i = j + 1; + } else if (ch === ">") { + // Préfixe du descripteur : « 2 » ou « & » (isolés, précédés d'un espace) + const m = /(?:^|\s)(\d+|&)$/.exec(stripped); + if (m) stripped = stripped.slice(0, stripped.length - m[1]!.length); + i++; + if (cmd[i] === ">" || cmd[i] === "|") i++; // >> et >| + if (cmd[i] === "&") { + const dup = /^&(\d+|-)/.exec(cmd.slice(i)); + if (dup) { + i += dup[0].length; // 2>&1, >&2, >&- : pas de fichier + stripped += " "; + continue; + } + i++; // >&fichier : sortie et erreur vers le fichier + } + while (i < n && (cmd[i] === " " || cmd[i] === "\t")) i++; + if (cmd[i] === "(") { + unknown = true; + break; + } + let target = ""; + let quoted = false; + while (i < n && !/[\s;|&<>()]/.test(cmd[i]!)) { + const c = cmd[i]!; + if (c === "\\") { + target += cmd[i + 1] ?? ""; + i += 2; + } else if (c === "'" || c === '"') { + const j = cmd.indexOf(c, i + 1); + if (j === -1) return { stripped, targets, unknown: true }; + target += cmd.slice(i + 1, j); + quoted = true; + i = j + 1; + } else { + target += c; + i++; + } + } + if (target === "" && !quoted) unknown = true; + else targets.push(target); + stripped += " "; + } else { + stripped += ch; + i++; + } + } + return { stripped, targets, unknown }; +} + +/** Cible dont le chemin réel dépend du shell (variable, glob, accolades) : jamais couverte par un motif de chemin. */ +const isDynamicTarget = (target: string): boolean => /[$`*?[\]{}]/.test(target); + +const redirectRuleMatches = (rule: Rule, target: string, cwd: string): boolean => + !rule.spec || matchPathSpec(rule.spec, targetPath({ path: target }, cwd)); function parseRules(raws: string[]): Rule[] { const rules: Rule[] = []; @@ -107,6 +201,8 @@ function matchGenericSpec(tool: string, spec: string, input: Record, cwd: string): boolean { + // Outils MCP (`mcp__serveur__outil`) : règles `Mcp(serveur[,outil])` + if (rule.tool === "Mcp") return mcpSpecMatches(rule.spec, tool); if (rule.tool !== tool) return false; if (!rule.spec) return true; if (tool === "Bash") { @@ -121,16 +217,33 @@ function ruleApplies(rule: Rule, tool: string, input: Record, c return matchGenericSpec(tool, rule.spec, input); } +/** Une redirection vers ce fichier est autorisée par une règle `Redirect` (ou `Redirect(motif)`). */ +function redirectAllowed(target: string, allow: Rule[], cwd: string): boolean { + return allow.some((r) => !r.spec || (!isDynamicTarget(target) && redirectRuleMatches(r, target, cwd))); +} + /** Pour allow : TOUTES les parties d'une commande Bash doivent être couvertes (et sans redirection ni substitution). */ -function allowApplies(rules: Rule[], tool: string, input: Record, cwd: string): boolean { +function allowApplies( + rules: Rule[], + tool: string, + input: Record, + cwd: string, + redirectAllow: Rule[] = [], +): boolean { + if (rules.some((r) => r.tool === "Mcp" && mcpSpecMatches(r.spec, tool))) return true; const mine = rules.filter((r) => r.tool === tool); if (mine.length === 0) return false; if (mine.some((r) => !r.spec)) return true; // ex. "Edit" : tous les usages de l'outil if (tool === "Bash") { const cmd = typeof input.command === "string" ? input.command : ""; - if (UNSAFE_SHELL.test(cmd)) return false; - const subs = splitCommands(cmd); - return subs.length > 0 && subs.every((sub) => mine.some((r) => matchBashSpec(r.spec!, sub))); + const redir = extractRedirections(cmd); + if (redir.unknown || UNSAFE_SHELL.test(cmd)) return false; + const subs = splitCommands(redir.stripped); + return ( + subs.length > 0 && + subs.every((sub) => mine.some((r) => matchBashSpec(r.spec!, sub))) && + redir.targets.every((t) => redirectAllowed(t, redirectAllow, cwd)) + ); } if (PATH_TOOLS.has(tool)) { const target = targetPath(input, cwd); @@ -153,14 +266,25 @@ export function createPermissionPolicy(rules: PermissionRules): PermissionPolicy const deny = parseRules(rules.deny); const ask = parseRules(rules.ask); const allow = parseRules(rules.allow); + const redirectOf = (list: Rule[]): Rule[] => list.filter((r) => r.tool === "Redirect"); + const redirectDeny = redirectOf(deny); + const redirectAsk = redirectOf(ask); + const redirectAllow = redirectOf(allow); + + /** Règle Redirect (deny ou ask) qui vise un fichier de redirection de la commande Bash. */ + const redirectHit = (list: Rule[], tool: string, input: Record, cwd: string): Rule | undefined => { + if (tool !== "Bash" || list.length === 0) return undefined; + const { targets } = extractRedirections(typeof input.command === "string" ? input.command : ""); + return list.find((r) => targets.some((t) => redirectRuleMatches(r, t, cwd))); + }; return { check(tool, input, { cwd }) { - const d = deny.find((r) => ruleApplies(r, tool, input, cwd)); + const d = deny.find((r) => r.tool !== "Redirect" && ruleApplies(r, tool, input, cwd)) ?? redirectHit(redirectDeny, tool, input, cwd); if (d) return { decision: "deny", reason: `règle deny ${d.raw}` }; - const a = ask.find((r) => ruleApplies(r, tool, input, cwd)); + const a = ask.find((r) => r.tool !== "Redirect" && ruleApplies(r, tool, input, cwd)) ?? redirectHit(redirectAsk, tool, input, cwd); if (a) return { decision: "ask", reason: `règle ask ${a.raw}` }; - if (allowApplies(allow, tool, input, cwd)) return { decision: "allow" }; + if (allowApplies(allow, tool, input, cwd, redirectAllow)) return { decision: "allow" }; return { decision: "ask", reason: "aucune règle : confirmation demandée par défaut" }; }, }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8092188..fd5eab4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,4 @@ import { homedir } from "node:os"; -import { join } from "node:path"; import { parseArgs } from "node:util"; import { SessionManager, @@ -33,6 +32,7 @@ import { validateAgentName, type AgentDefinition, type AgentScope, + type McpServerStatus, } from "@maia/agent"; import { createProvider, loadModelChoices, loadSettings, settingsPath } from "@maia/providers"; import { InkUI } from "@maia/ui"; @@ -87,11 +87,12 @@ Usage : maia [options] Sans cette option, la liste des modèles disponibles est proposée au démarrage. -h, --help Affiche cette aide -Commandes en session : /help /model /permissions /agents /newAgent /clear /compact /exit +Commandes en session : /help /model /permissions /mcp /agents /createAgent /clear /compact /exit Sessions : /rename /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions) Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills) Sous-agents : définis dans ./.maia/agents//AGENT.md ou ~/.maia/agents ; l'agent les lance (outil Agent) ; /agents : liste &nom [tâche] : vous lancez vous-même un sous-agent (& seul : liste) ; son résumé est ajouté au contexte +Serveurs MCP : déclarés dans ~/.maia/mcp.json, activés par "Mcp(nom)" dans les permissions du settings.json ; /mcp : état Commande shell : !commande ou !'commande avec paramètres' (exécutée directement ; résultat ajouté au contexte) Fichier référencé : @chemin/fichier ou @'chemin avec espaces' ou @ chemin/fichier (contenu injecté dans le contexte)`; @@ -177,6 +178,9 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { // Sous-agents : processus autonomes lancés par l'outil Agent (la notification INFO de fin passe par ui.info) let agents: AgentDefinition[] = []; + // Serveurs MCP activés (état affiché par /mcp) et fermeture de leurs connexions + let mcpServers: McpServerStatus[] = []; + let closeMcp: () => Promise = async () => undefined; const launchSubAgent = createSubAgentLauncher({ cwd, homeDir, @@ -207,6 +211,10 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { launchSubAgent, }); agents = init.agents; + const previousClose = closeMcp; + mcpServers = init.mcpServers; + closeMcp = init.close; + void previousClose().catch(() => undefined); // connexions de la session précédente const created = createSession({ provider: createProvider(modelSpec, settings), init, @@ -319,7 +327,12 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { const onExit = (): void => void saveSession(); const onSignal = (): void => { saveSession(); - process.exit(0); + // Ferme les connexions MCP (au plus 3 s), puis quitte + const timer = setTimeout(() => process.exit(0), 3000); + timer.unref?.(); + void closeMcp() + .catch(() => undefined) + .finally(() => process.exit(0)); }; process.on("exit", onExit); process.on("SIGTERM", onSignal); @@ -490,12 +503,16 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { ui.info([HELP, ...custom, ...skills].join("\n")); continue; } + if (name === "mcp") { + ui.showMcp(mcpServers.map((s) => ({ name: s.name, status: s.status, error: s.error }))); + continue; + } if (name === "permissions") { ui.showPermissions(session.permissionSources); continue; } - if (name.toLowerCase() === "newagent") { - // /newAgent [nom] : crée ./.maia/agents//AGENT.md (projet) ou ~/.maia/agents//AGENT.md (global) depuis le modèle + if (name.toLowerCase() === "createagent") { + // /createAgent [nom] : crée ./.maia/agents//AGENT.md (projet) ou ~/.maia/agents//AGENT.md (global) depuis le modèle const template = findResource("AGENT.md"); if (!template) { ui.error("Modèle d'agent introuvable : .ressources/AGENT.md (dans le dossier de MAIA, ou dossier indiqué par MAIA_RESSOURCES)."); @@ -742,6 +759,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { const finalName = saveSession(); if (finalName) ui.info(`Session sauvegardée : ${finalName}`); + await closeMcp().catch(() => undefined); // coupe les connexions aux serveurs MCP avant de quitter ui.outro("À bientôt"); } diff --git a/packages/core/src/extensions.ts b/packages/core/src/extensions.ts deleted file mode 100644 index 138b78e..0000000 --- a/packages/core/src/extensions.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { readdir, stat } from "node:fs/promises"; -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; -import type { Extension } from "./types"; -import type { ModeRegistry } from "./registry"; - -/** Active les extensions : chacune enregistre ses modes via l'API. */ -export async function activateExtensions( - registry: ModeRegistry, - extensions: Extension[], - onError: (message: string) => void = () => {}, -): Promise { - for (const ext of extensions) { - try { - await ext.activate({ - registerMode: (mode) => registry.registerMode(mode, ext.id), - }); - } catch (err) { - onError(`Extension "${ext.id}" : échec d'activation (${(err as Error).message})`); - } - } -} - -/** - * Découvre les extensions utilisateur : ~/.maia/extensions//index.js - * (export default d'un objet Extension). - */ -export async function discoverUserExtensions(dir: string): Promise { - let names: string[]; - try { - names = await readdir(dir); - } catch { - return []; - } - const found: Extension[] = []; - for (const name of names) { - const entry = join(dir, name, "index.js"); - try { - await stat(entry); - const mod = await import(pathToFileURL(entry).href); - const ext = (mod.default ?? mod.extension) as Extension | undefined; - if (ext && typeof ext.activate === "function") found.push(ext); - } catch { - // extension illisible : ignorée - } - } - return found; -} diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts deleted file mode 100644 index 9ff7f3d..0000000 --- a/packages/core/src/registry.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { AgentMode } from "./types"; - -export class ModeRegistry { - private modes = new Map(); - - registerMode(mode: AgentMode, source: string): void { - if (this.modes.has(mode.id)) { - throw new Error(`Mode "${mode.id}" déjà enregistré`); - } - this.modes.set(mode.id, { mode, source }); - } - - getMode(id: string): AgentMode | undefined { - return this.modes.get(id)?.mode; - } - - listModes(): AgentMode[] { - return [...this.modes.values()].map((e) => e.mode); - } -} diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index d874efc..90a58aa 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -11,6 +11,7 @@ import type { ConfirmPromptState, InputPromptState, LogItem, + McpServerView, PermissionFile, PromptState, SelectPromptState, @@ -20,6 +21,32 @@ import type { // ───────────── Journal ───────────── +/** Serveurs MCP activés : bandeau OK (vert) ou FAIL (rouge) suivi du nom. */ +function McpView({ servers }: { servers: McpServerView[] }) { + if (servers.length === 0) { + return ( + + Aucun serveur MCP activé. Déclarez-les dans ~/.maia/mcp.json, puis autorisez-les dans ./.maia/settings.json : "allow": ["Mcp(nom)"]. + + ); + } + return ( + + {servers.map((s) => ( + + + + {s.status === "ok" ? "OK" : "FAIL"} + + {s.name} + + {s.status === "fail" && s.error ? {` ${s.error}`} : null} + + ))} + + ); +} + const PERMISSION_KINDS = [ { key: "allow", label: "ALLOW", color: "green" }, { key: "ask", label: "ASK", color: "yellow" }, @@ -115,6 +142,8 @@ function LogLine({ item }: { item: LogItem }) { return ; case "permissions": return ; + case "mcp": + return ; case "shell": return ( diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ba8ec5a..8f6c281 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,2 +1,2 @@ export { InkUI } from "./ink-ui"; -export { UIStore, type Choice, type LogItem, type LogKind, type PermissionFile, type UIState } from "./store"; +export { UIStore, type Choice, type LogItem, type LogKind, type McpServerView, type PermissionFile, type UIState } from "./store"; diff --git a/packages/ui/src/ink-ui.tsx b/packages/ui/src/ink-ui.tsx index c39127f..b651eaa 100644 --- a/packages/ui/src/ink-ui.tsx +++ b/packages/ui/src/ink-ui.tsx @@ -2,7 +2,7 @@ import React from "react"; import { render, type Instance } from "ink"; import type { AgentUI } from "@maia/core"; import { App } from "./components"; -import { UIStore, type Choice, type PermissionFile } from "./store"; +import { UIStore, type Choice, type McpServerView, type PermissionFile } from "./store"; /** * Interface Ink (React pour CLI). Façade impérative au-dessus d'un UIStore : le CLI appelle des méthodes @@ -96,6 +96,11 @@ export class InkUI implements AgentUI { this.start().showPermissions(files); } + /** Serveurs MCP activés : nom et état (OK en vert, FAIL en rouge). */ + showMcp(servers: McpServerView[]): void { + this.start().showMcp(servers); + } + /** Commande "!" de l'utilisateur et son résultat. */ onShell(command: string, output: string, status: string | null): void { this.start().shell(command, output, status); diff --git a/packages/ui/src/store.test.ts b/packages/ui/src/store.test.ts index 68f1ff7..377fac4 100644 --- a/packages/ui/src/store.test.ts +++ b/packages/ui/src/store.test.ts @@ -23,6 +23,15 @@ describe("UIStore", () => { expect(item.permissions).toEqual(files); }); + it("showMcp : un élément avec les serveurs MCP activés et leur état", () => { + const s = new UIStore(); + const servers = [{ name: "github", status: "ok" as const }, { name: "docs", status: "fail" as const, error: "HTTP 500" }]; + s.showMcp(servers); + const item = s.getSnapshot().items[0]!; + expect(item.kind).toBe("mcp"); + expect(item.mcp).toEqual(servers); + }); + it("notifie les abonnés et fournit un instantané stable entre deux changements", () => { const s = new UIStore(); let calls = 0; diff --git a/packages/ui/src/store.ts b/packages/ui/src/store.ts index ed749d6..1034523 100644 --- a/packages/ui/src/store.ts +++ b/packages/ui/src/store.ts @@ -14,7 +14,8 @@ export type LogKind = | "user" | "assistant" | "shell" - | "permissions"; + | "permissions" + | "mcp"; export interface LogItem { id: number; @@ -26,6 +27,15 @@ export interface LogItem { failed?: boolean; /** Règles de permissions par fichier (kind "permissions"). */ permissions?: PermissionFile[]; + /** Serveurs MCP activés et leur état (kind "mcp"). */ + mcp?: McpServerView[]; +} + +export interface McpServerView { + name: string; + status: "ok" | "fail"; + /** Cause de l'échec (affichée en grisé sous le bandeau FAIL). */ + error?: string; } export interface PermissionFile { @@ -146,6 +156,11 @@ export class UIStore { this.commit({ items: this.withItem("permissions", "Permissions", { permissions: files }) }); } + /** Serveurs MCP activés et leur état (commande /mcp). */ + showMcp(servers: McpServerView[]): void { + this.commit({ items: this.withItem("mcp", "Serveurs MCP", { mcp: servers }) }); + } + /** Contexte actuel de la conversation, en tokens. */ setContextUsed(tokens: number): void { this.commit({ contextUsed: Math.max(0, Math.round(tokens)) }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba16664..72a2f3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,7 +171,7 @@ importers: version: 22.20.5 tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.30)(supports-color@10.2.2)(tsx@4.23.15)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.31)(supports-color@10.2.2)(tsx@4.23.15)(typescript@5.9.3) tsx: specifier: ^4.19.0 version: 4.23.15 @@ -767,129 +767,143 @@ packages: engines: {node: ^22.20 || ^24.12 || >=25} cpu: [x64] os: [linux] + libc: [glibc] - '@rollup/rollup-android-arm-eabi@4.64.6': - resolution: {integrity: sha512-FkO19Fowv/OQeJZPVfdetJaI3/VpbZmBsnmAlR/rQ+b3QwfiGQiOpVegpSRH331kelvHTVw9hXYG3DwjAaRzyQ==} + '@rollup/rollup-android-arm-eabi@4.64.12': + resolution: {integrity: sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.64.6': - resolution: {integrity: sha512-si5/Etm6MphPc2xYHnk455Shls/hoMjTy3mhXge4OPmvCw7TwC4KP4fKokAlKLV4XaQvLvgSHfafPcblLMYYdQ==} + '@rollup/rollup-android-arm64@4.64.12': + resolution: {integrity: sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.64.6': - resolution: {integrity: sha512-PQCl3q7b0BgvVt72i2xrWM6I5KliwwVe4GpWW5QDGyUQawDS9aPZss/aGyFqBmxdRxlE8wDc9CaxiQ5DHRSp/Q==} + '@rollup/rollup-darwin-arm64@4.64.12': + resolution: {integrity: sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.64.6': - resolution: {integrity: sha512-l9DFl1Itb1YYdSrP1K3U8ih44AOHa/p+Q4WMMDfGvH5qVWp+ajfgS1DlYo4yCRh8wJ2ZJv0Fqccf+ULAmMFjmg==} + '@rollup/rollup-darwin-x64@4.64.12': + resolution: {integrity: sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.64.6': - resolution: {integrity: sha512-bR4GR3205N9+od0+l5Dd9/iZTDpJkIe6d6Ydp2qbcQZJLmKBdYQQRp8gbWpbMt92twKJY66o1vK4NkoKQODIrA==} + '@rollup/rollup-freebsd-arm64@4.64.12': + resolution: {integrity: sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.64.6': - resolution: {integrity: sha512-XTVNaDzcFl2TeBglvxdGgsCv9JGtAGRAoJENmGBR/XvDH2mA2K0IXc2aMfP0dR8mexSXSKifJ85FixoXX1wfTw==} + '@rollup/rollup-freebsd-x64@4.64.12': + resolution: {integrity: sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.64.6': - resolution: {integrity: sha512-qWc6qbc+25ugG9vqPR9P422KJBbVfZ05mjcMggoq8tsfWP+eRfC+/tiDdIlRF5VL2pDVZWN9zAhWsmu7R1+7Dw==} + '@rollup/rollup-linux-arm-gnueabihf@4.64.12': + resolution: {integrity: sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w==} cpu: [arm] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.64.6': - resolution: {integrity: sha512-V4Nwkp8844X/Xnww3PI1LYS0YPei8XQEVG70R+5MPZGO7AhYoun4hCN7Svs4U3wbQLZbwiz+9xRFhUZZdRl8Ww==} + '@rollup/rollup-linux-arm-musleabihf@4.64.12': + resolution: {integrity: sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA==} cpu: [arm] os: [linux] + libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.64.6': - resolution: {integrity: sha512-xb72A1aPaaIsp+a88VtRae/ovIEXislbUMyp7b9AQQU7ZkP2m/+ik6DW9TQcJyw5R8/RIailF9zExYwkjR++UA==} + '@rollup/rollup-linux-arm64-gnu@4.64.12': + resolution: {integrity: sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A==} cpu: [arm64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.64.6': - resolution: {integrity: sha512-pbcs1BVBwC43hZkjhjlF8Rx8m5G0KnhLZs9Azy/gqccP6UsNSzJr0hKGIAVPqzAH1O/NdPxxC9GVGb4msQg/aw==} + '@rollup/rollup-linux-arm64-musl@4.64.12': + resolution: {integrity: sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA==} cpu: [arm64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.64.6': - resolution: {integrity: sha512-EKOC8os6Vsg487ZLKUqzLF99Jsmnq6tu7O7mAELakNpcA+8KadktFf/JYdRuHe/DzCUXmN38pUKu4XBcKlqGbQ==} + '@rollup/rollup-linux-loong64-gnu@4.64.12': + resolution: {integrity: sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ==} cpu: [loong64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.64.6': - resolution: {integrity: sha512-v/SQ68GnSlr/HyjB79sNXesGDwkOPu9JjbHfmWaymCiM+nZsQ9TstG1KfA4MuEvTr2d3PoY8PhFxj9bt0e32JA==} + '@rollup/rollup-linux-loong64-musl@4.64.12': + resolution: {integrity: sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q==} cpu: [loong64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.64.6': - resolution: {integrity: sha512-G3pmliMQ7r15zFFNwBlaT/xTdSLi4lfgI7CUrQueyKYheNVhrd8JrWCE6Ll8xf4AA1RZFqq+mNJYntiElsVOyA==} + '@rollup/rollup-linux-ppc64-gnu@4.64.12': + resolution: {integrity: sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ==} cpu: [ppc64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.64.6': - resolution: {integrity: sha512-XQTB/IPoB/wG6x/wu9uomAt8YH61IAr5iZTubMiad/lu88sotq2ocz9hJd556+Jz2kIkF7ZQZIsUuWE3OWqtcA==} + '@rollup/rollup-linux-ppc64-musl@4.64.12': + resolution: {integrity: sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg==} cpu: [ppc64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.64.6': - resolution: {integrity: sha512-O5mpokth6+ydMuwiGtY8fIjvOBfH7u6SzmDNmZ3k6GK5C6iEAC2q2ZN/T5L9jYILx0IcCT0KnfMDihQ5yQFSBw==} + '@rollup/rollup-linux-riscv64-gnu@4.64.12': + resolution: {integrity: sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A==} cpu: [riscv64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.64.6': - resolution: {integrity: sha512-aIFxbAD9v3TvIaEDsKmukjcDg1JS4/tFXw8SHyQQgzOwzWCQ4cyDK7Ju1pQKqUXDFkEu/BZCFDCDATsKhQcB/Q==} + '@rollup/rollup-linux-riscv64-musl@4.64.12': + resolution: {integrity: sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A==} cpu: [riscv64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.64.6': - resolution: {integrity: sha512-fuNo1UwBc6BxnHDOxxguQL/InhheMN3RJcJMb0b4xP8r5Q3CNCrJbVg0eCIFuJ4e/zAfjR2gYbOfbzO/G7CIyw==} + '@rollup/rollup-linux-s390x-gnu@4.64.12': + resolution: {integrity: sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A==} cpu: [s390x] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.64.6': - resolution: {integrity: sha512-05v6wFKDKJPZs9xlytVrrRu6IbRuoSvlH9xNwEOafXsKyjBFZPvAcZz2VIAB/M+lblYeKa9iOYC4RDVdfcAAaw==} + '@rollup/rollup-linux-x64-gnu@4.64.12': + resolution: {integrity: sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q==} cpu: [x64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.64.6': - resolution: {integrity: sha512-wJXKcdMjvkc/FaavZXNP1yW4i0pfOmhZAIWRSLG2yJ0PB0TWi7dkLErhX0u0vptDcXKKO/m1Y/YTeuyvb2MXwg==} + '@rollup/rollup-linux-x64-musl@4.64.12': + resolution: {integrity: sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw==} cpu: [x64] os: [linux] + libc: [musl] - '@rollup/rollup-openbsd-x64@4.64.6': - resolution: {integrity: sha512-cwg7/h0ThvJwy8CBkmACdiHAxRWpbsCmIsjOt+Zzx0hTnIcSUmKYEV03bGg+hD827OnwUsbKh1ZZDW754xWyBA==} + '@rollup/rollup-openbsd-x64@4.64.12': + resolution: {integrity: sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.64.6': - resolution: {integrity: sha512-T0RkYIJbdYXJEQFw+tMBO09KDFdgmNxkHsof3wWAABrvnx15Yh8RytmrgYy9+q9cBeBNZF66MMQZC+5nzEz6vg==} + '@rollup/rollup-openharmony-arm64@4.64.12': + resolution: {integrity: sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.64.6': - resolution: {integrity: sha512-KlfpmF1AAIdAWxt8QQNClbpLFlAUfYOzqIuJ7IXEStMHpFRuH78RG6FjqPb3Rqhh0K89F5npvG+/i3Fjl6IveQ==} + '@rollup/rollup-win32-arm64-msvc@4.64.12': + resolution: {integrity: sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.64.6': - resolution: {integrity: sha512-MrTLjd7Jlt9/ZavJZYG61KQv9Iy9bzbY+yXy73Xvwoa2uP66wvDShkyBr5Chz/yrE6lbD/zHMuTmKQVNQqBApg==} + '@rollup/rollup-win32-ia32-msvc@4.64.12': + resolution: {integrity: sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.64.6': - resolution: {integrity: sha512-8fWDH1CkPI8GdBKAxzHDza7UmgficsKo2+MVbvUgOGhB0629w5sHMg3Qu5y7TZsrYKHHWpz8fla4KvoT7WsO3Q==} + '@rollup/rollup-win32-x64-gnu@4.64.12': + resolution: {integrity: sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.64.6': - resolution: {integrity: sha512-9/c9PWdr5GCIHkkxiHM1HjNBQMVdftP4O8QTcoUAaRza2FSqJ4axbf8QAnBuE6BIGQDl71SaRPeDdDYid5ecTw==} + '@rollup/rollup-win32-x64-msvc@4.64.12': + resolution: {integrity: sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw==} cpu: [x64] os: [win32] @@ -1191,8 +1205,8 @@ packages: js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + lilconfig@3.1.4: + resolution: {integrity: sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==} engines: {node: '>=14'} lines-and-columns@1.2.4: @@ -1415,8 +1429,8 @@ packages: yaml: optional: true - postcss@8.5.30: - resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} react-reconciler@0.33.0: @@ -1441,8 +1455,8 @@ packages: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - rollup@4.64.6: - resolution: {integrity: sha512-fX1Vy+UNKdXn+xKDnu/HBXbHVr1AY8cSdQj9BU4kLv5aFCMqGpvrVbyUMmjN3DdzWhSAuTbcX8PCNEVaumthYA==} + rollup@4.64.12: + resolution: {integrity: sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -1948,79 +1962,79 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@rollup/rollup-android-arm-eabi@4.64.6': + '@rollup/rollup-android-arm-eabi@4.64.12': optional: true - '@rollup/rollup-android-arm64@4.64.6': + '@rollup/rollup-android-arm64@4.64.12': optional: true - '@rollup/rollup-darwin-arm64@4.64.6': + '@rollup/rollup-darwin-arm64@4.64.12': optional: true - '@rollup/rollup-darwin-x64@4.64.6': + '@rollup/rollup-darwin-x64@4.64.12': optional: true - '@rollup/rollup-freebsd-arm64@4.64.6': + '@rollup/rollup-freebsd-arm64@4.64.12': optional: true - '@rollup/rollup-freebsd-x64@4.64.6': + '@rollup/rollup-freebsd-x64@4.64.12': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.64.6': + '@rollup/rollup-linux-arm-gnueabihf@4.64.12': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.64.6': + '@rollup/rollup-linux-arm-musleabihf@4.64.12': optional: true - '@rollup/rollup-linux-arm64-gnu@4.64.6': + '@rollup/rollup-linux-arm64-gnu@4.64.12': optional: true - '@rollup/rollup-linux-arm64-musl@4.64.6': + '@rollup/rollup-linux-arm64-musl@4.64.12': optional: true - '@rollup/rollup-linux-loong64-gnu@4.64.6': + '@rollup/rollup-linux-loong64-gnu@4.64.12': optional: true - '@rollup/rollup-linux-loong64-musl@4.64.6': + '@rollup/rollup-linux-loong64-musl@4.64.12': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.64.6': + '@rollup/rollup-linux-ppc64-gnu@4.64.12': optional: true - '@rollup/rollup-linux-ppc64-musl@4.64.6': + '@rollup/rollup-linux-ppc64-musl@4.64.12': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.64.6': + '@rollup/rollup-linux-riscv64-gnu@4.64.12': optional: true - '@rollup/rollup-linux-riscv64-musl@4.64.6': + '@rollup/rollup-linux-riscv64-musl@4.64.12': optional: true - '@rollup/rollup-linux-s390x-gnu@4.64.6': + '@rollup/rollup-linux-s390x-gnu@4.64.12': optional: true - '@rollup/rollup-linux-x64-gnu@4.64.6': + '@rollup/rollup-linux-x64-gnu@4.64.12': optional: true - '@rollup/rollup-linux-x64-musl@4.64.6': + '@rollup/rollup-linux-x64-musl@4.64.12': optional: true - '@rollup/rollup-openbsd-x64@4.64.6': + '@rollup/rollup-openbsd-x64@4.64.12': optional: true - '@rollup/rollup-openharmony-arm64@4.64.6': + '@rollup/rollup-openharmony-arm64@4.64.12': optional: true - '@rollup/rollup-win32-arm64-msvc@4.64.6': + '@rollup/rollup-win32-arm64-msvc@4.64.12': optional: true - '@rollup/rollup-win32-ia32-msvc@4.64.6': + '@rollup/rollup-win32-ia32-msvc@4.64.12': optional: true - '@rollup/rollup-win32-x64-gnu@4.64.6': + '@rollup/rollup-win32-x64-gnu@4.64.12': optional: true - '@rollup/rollup-win32-x64-msvc@4.64.6': + '@rollup/rollup-win32-x64-msvc@4.64.12': optional: true '@types/debug@4.1.13': @@ -2295,7 +2309,7 @@ snapshots: dependencies: magic-string: 0.30.21 mlly: 1.8.2 - rollup: 4.64.6 + rollup: 4.64.12 fsevents@2.3.3: optional: true @@ -2356,7 +2370,7 @@ snapshots: dependencies: base64-js: 1.5.1 - lilconfig@3.1.3: {} + lilconfig@3.1.4: {} lines-and-columns@1.2.4: {} @@ -2734,14 +2748,14 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1(postcss@8.5.30)(tsx@4.23.15): + postcss-load-config@6.0.1(postcss@8.5.31)(tsx@4.23.15): dependencies: - lilconfig: 3.1.3 + lilconfig: 3.1.4 optionalDependencies: - postcss: 8.5.30 + postcss: 8.5.31 tsx: 4.23.15 - postcss@8.5.30: + postcss@8.5.31: dependencies: nanoid: 3.3.20 picocolors: 1.1.1 @@ -2763,36 +2777,36 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - rollup@4.64.6: + rollup@4.64.12: dependencies: '@types/estree': 1.0.9 optionalDependencies: '@napi-rs/lzma-linux-x64-gnu': 1.5.1 - '@rollup/rollup-android-arm-eabi': 4.64.6 - '@rollup/rollup-android-arm64': 4.64.6 - '@rollup/rollup-darwin-arm64': 4.64.6 - '@rollup/rollup-darwin-x64': 4.64.6 - '@rollup/rollup-freebsd-arm64': 4.64.6 - '@rollup/rollup-freebsd-x64': 4.64.6 - '@rollup/rollup-linux-arm-gnueabihf': 4.64.6 - '@rollup/rollup-linux-arm-musleabihf': 4.64.6 - '@rollup/rollup-linux-arm64-gnu': 4.64.6 - '@rollup/rollup-linux-arm64-musl': 4.64.6 - '@rollup/rollup-linux-loong64-gnu': 4.64.6 - '@rollup/rollup-linux-loong64-musl': 4.64.6 - '@rollup/rollup-linux-ppc64-gnu': 4.64.6 - '@rollup/rollup-linux-ppc64-musl': 4.64.6 - '@rollup/rollup-linux-riscv64-gnu': 4.64.6 - '@rollup/rollup-linux-riscv64-musl': 4.64.6 - '@rollup/rollup-linux-s390x-gnu': 4.64.6 - '@rollup/rollup-linux-x64-gnu': 4.64.6 - '@rollup/rollup-linux-x64-musl': 4.64.6 - '@rollup/rollup-openbsd-x64': 4.64.6 - '@rollup/rollup-openharmony-arm64': 4.64.6 - '@rollup/rollup-win32-arm64-msvc': 4.64.6 - '@rollup/rollup-win32-ia32-msvc': 4.64.6 - '@rollup/rollup-win32-x64-gnu': 4.64.6 - '@rollup/rollup-win32-x64-msvc': 4.64.6 + '@rollup/rollup-android-arm-eabi': 4.64.12 + '@rollup/rollup-android-arm64': 4.64.12 + '@rollup/rollup-darwin-arm64': 4.64.12 + '@rollup/rollup-darwin-x64': 4.64.12 + '@rollup/rollup-freebsd-arm64': 4.64.12 + '@rollup/rollup-freebsd-x64': 4.64.12 + '@rollup/rollup-linux-arm-gnueabihf': 4.64.12 + '@rollup/rollup-linux-arm-musleabihf': 4.64.12 + '@rollup/rollup-linux-arm64-gnu': 4.64.12 + '@rollup/rollup-linux-arm64-musl': 4.64.12 + '@rollup/rollup-linux-loong64-gnu': 4.64.12 + '@rollup/rollup-linux-loong64-musl': 4.64.12 + '@rollup/rollup-linux-ppc64-gnu': 4.64.12 + '@rollup/rollup-linux-ppc64-musl': 4.64.12 + '@rollup/rollup-linux-riscv64-gnu': 4.64.12 + '@rollup/rollup-linux-riscv64-musl': 4.64.12 + '@rollup/rollup-linux-s390x-gnu': 4.64.12 + '@rollup/rollup-linux-x64-gnu': 4.64.12 + '@rollup/rollup-linux-x64-musl': 4.64.12 + '@rollup/rollup-openbsd-x64': 4.64.12 + '@rollup/rollup-openharmony-arm64': 4.64.12 + '@rollup/rollup-win32-arm64-msvc': 4.64.12 + '@rollup/rollup-win32-ia32-msvc': 4.64.12 + '@rollup/rollup-win32-x64-gnu': 4.64.12 + '@rollup/rollup-win32-x64-msvc': 4.64.12 fsevents: 2.3.3 scheduler@0.27.0: {} @@ -2875,7 +2889,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(postcss@8.5.30)(supports-color@10.2.2)(tsx@4.23.15)(typescript@5.9.3): + tsup@8.5.1(postcss@8.5.31)(supports-color@10.2.2)(tsx@4.23.15)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -2886,16 +2900,16 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.30)(tsx@4.23.15) + postcss-load-config: 6.0.1(postcss@8.5.31)(tsx@4.23.15) resolve-from: 5.0.0 - rollup: 4.64.6 + rollup: 4.64.12 source-map: 0.7.6 sucrase: 3.35.1 tinyexec: 0.3.2 tinyglobby: 0.2.17 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.30 + postcss: 8.5.31 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -2963,8 +2977,8 @@ snapshots: vite@5.4.21(@types/node@22.20.5): dependencies: esbuild: 0.21.5 - postcss: 8.5.30 - rollup: 4.64.6 + postcss: 8.5.31 + rollup: 4.64.12 optionalDependencies: '@types/node': 22.20.5 fsevents: 2.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee51e9..70ed17e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,4 @@ packages: - "packages/*" +allowBuilds: + esbuild: set this to true or false