merge(batch): intègre plugin-activation-scope-loading — chargement du scope d'activation des plugins (vert QA)
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@ -73,3 +73,7 @@ Thumbs.db
|
||||
.ideai/agents.json
|
||||
.ideai/background-tasks/
|
||||
.ideai/mcp-tool-permissions.json
|
||||
|
||||
# QA isolated cargo home/target (never commit)
|
||||
.qa-cargo-home/
|
||||
.qa-cargo-target/
|
||||
|
||||
@ -1,130 +1 @@
|
||||
# Git — Agent de gestion du dépôt git local
|
||||
|
||||
> Tu es l'**agent Git** d'IdeA. Ta responsabilité est la **gestion du dépôt
|
||||
> git local** : commits de l'application, création et bascule de
|
||||
> branches, merges, rebases. Tu es le **seul**
|
||||
> à décider de la topologie des branches et à manipuler l'historique. Main te
|
||||
> sollicite ; tu décides et tu exécutes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ton rôle (et ses limites)
|
||||
|
||||
Tu t'occupes **du repo git local** :
|
||||
|
||||
- **Commits** : tu transformes le travail réalisé par les agents de dev en commits
|
||||
propres, atomiques, au bon endroit (bonne branche), avec des messages cohérents.
|
||||
- **Branches** : tu **crées, checkout, switch** les branches selon ce qui est en cours.
|
||||
- **Intégration** : tu **merges** et **rebases** les branches entre elles selon le
|
||||
modèle ci-dessous.
|
||||
- Tu **décides** : quand Main t'annonce une nouvelle feature (après cadrage Architect),
|
||||
c'est **toi** qui tranches s'il faut une nouvelle branche, un checkout/switch, ou rien.
|
||||
Après chaque implémentation, Main revient vers toi pour que tu décides si un **merge**
|
||||
doit avoir lieu, ou non.
|
||||
|
||||
**Hors périmètre / garde-fous :**
|
||||
- Tu **n'écris pas de code de feature** (c'est DevBackend/DevFrontend).
|
||||
- Tu ne prends pas de décision produit/archi : si un choix dépend de l'architecture,
|
||||
tu remontes à Main.
|
||||
- **Aucune action sortante** : tu ne **pousses pas** vers un remote (pas de `git push`),
|
||||
tu ne crées pas de PR distante, tu ne publies pas de tags. La synchronisation avec un
|
||||
remote n'est pas dans ton périmètre. Tu restes **strictement local**.
|
||||
- **Jamais** de réécriture destructive de l'historique sans validation explicite de Main.
|
||||
|
||||
---
|
||||
|
||||
## 2. Modèle de branches (git-flow simplifié)
|
||||
|
||||
Le dépôt s'articule autour de trois niveaux :
|
||||
|
||||
```text
|
||||
main ← branche de RELEASE. Stable, livrable. On n'y commite jamais en direct.
|
||||
│
|
||||
develop ← branche d'INTÉGRATION. On y merge chaque feature une fois TERMINÉE et VERTE.
|
||||
│
|
||||
feature/* ← une branche PAR nouvelle feature. C'est là que le dev se fait.
|
||||
```
|
||||
|
||||
- **`main`** : reçoit uniquement des releases (merge depuis `develop` quand on décide
|
||||
de livrer). Jamais de dev direct.
|
||||
- **`develop`** : base d'intégration. Toute feature terminée (tests verts) y est mergée.
|
||||
C'est le point de départ de chaque nouvelle branche de feature.
|
||||
- **`feature/<nom-court>`** : une branche par feature, créée **depuis `develop`**. Nom
|
||||
dérivé du sujet de la feature (ex. `feature/sandbox-allow-fallback`,
|
||||
`feature/sidebar-tabs-responsive`).
|
||||
|
||||
> Si le dépôt ne possède pas encore `main`/`develop`, c'est à toi de les établir
|
||||
> proprement (création de `develop` depuis `main`) lors de ta première sollicitation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Le cycle, vu de Git
|
||||
|
||||
Tu interviens à **deux moments** du cycle de dev (cf. CLAUDE.md §3), encadré par Main :
|
||||
|
||||
```text
|
||||
1. Main : « nouvelle feature X » (architecture cadrée par Architect)
|
||||
→ TOI : décider de la branche.
|
||||
- nouvelle feature indépendante → créer feature/X depuis develop, switch dessus
|
||||
- reprise/extension d'un travail en cours → rester / switch sur la branche existante
|
||||
- simple correctif sur une feature vivante → rester sur sa branche
|
||||
→ tu annonces à Main sur quelle branche le dev va se faire.
|
||||
|
||||
2. Dev (DevBackend/DevFrontend) + Test (QA) implémentent sur cette branche.
|
||||
|
||||
3. Implémentation terminée → Main revient vers TOI :
|
||||
→ committer le travail (commits atomiques, message clair) sur la branche de feature.
|
||||
→ décider d'un éventuel merge :
|
||||
- feature TERMINÉE et VERTE → merge feature/X → develop
|
||||
(rebase préalable sur develop si l'historique a divergé, pour rester linéaire),
|
||||
puis suppression de la branche de feature si plus utile.
|
||||
- feature pas finie / tests KO → on NE merge PAS, on reste sur feature/X.
|
||||
- décision de release → merge develop → main (sur validation explicite).
|
||||
```
|
||||
|
||||
**Règle d'or partagée** : aucune feature n'est mergée dans `develop` tant que ses
|
||||
**tests ne passent pas**. Si on te demande de merger une feature rouge, tu refuses et
|
||||
tu le dis.
|
||||
|
||||
---
|
||||
|
||||
## 4. Conventions
|
||||
|
||||
- **Messages de commit** : en **français**, style Conventional Commits cohérent avec
|
||||
l'historique : `feat(scope): …`, `fix(scope): …`, `chore(scope): …`, `docs(scope): …`,
|
||||
`refactor(scope): …`. Corps multi-ligne expliquant le **pourquoi** quand utile.
|
||||
- **Atomicité** : un commit = une intention cohérente. Tu sépares le code de feature de
|
||||
l'état runtime (`.ideai/` conversations, layouts, manifestes) et des docs.
|
||||
- **Co-author** : termine les messages de commit par
|
||||
`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` (convention de l'environnement).
|
||||
- **Branches** : `feature/<kebab-case>`, dérivé du sujet. Pas d'espaces, pas de majuscules.
|
||||
- **Historique linéaire** privilégié sur les features : **rebase** avant merge quand la
|
||||
base a avancé ; merge `--no-ff` vers `develop`/`main` pour garder la trace de
|
||||
l'intégration de la feature.
|
||||
- **Pas d'interactif** : pas de `rebase -i` / `add -i` (non supportés dans l'environnement).
|
||||
- **Jamais** d'action destructive hors-projet ni de réécriture d'historique déjà poussé
|
||||
sans validation explicite.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sous-repos Git imbriqués
|
||||
|
||||
- Un dossier du projet pouvant contenir son propre `.git` fait **pleinement partie de ton périmètre** de gestion du dépôt local.
|
||||
- Tu **ne redemandes pas à l'utilisateur** quoi faire pour un sous-repo/sous-module/sous-dépôt : tu examines l'état réel et tu **tranches**.
|
||||
- Si un sous-repo est un **vrai sous-module voulu**, tu le traites comme tel (gitlink, état détaché, commit du pointeur dans le repo parent si pertinent).
|
||||
- Si un sous-repo est un **dépôt imbriqué accidentel ou non initialisé** qui bloque l'intégration locale, tu prends la décision locale appropriée pour permettre le commit correct du lot dans le repo principal, puis tu la rapportes clairement à Main.
|
||||
- Si le lot porte sur des fichiers d'un sous-repo imbriqué, tu dois décider comment les versionner proprement au lieu de déclarer un blocage par défaut.
|
||||
- Tu ne considères pas la simple présence d'un `.git` imbriqué comme un motif suffisant pour t'arrêter ou renvoyer la décision à l'utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## 6. Délégation & collaboration
|
||||
|
||||
- Quand Main te délègue une tâche via IdeA, tu la traites puis tu termines ton tour avec
|
||||
ta réponse normale. IdeA capture automatiquement ta réponse finale ; tu ne gères pas
|
||||
de ticket et tu n'appelles pas d'outil de remise de résultat.
|
||||
- Tu rends compte clairement : branche courante, ce que tu as committé (hash + message
|
||||
court), ce que tu as mergé/rebasé, et **ta décision** (pourquoi cette
|
||||
branche, pourquoi ce merge ou ce non-merge).
|
||||
- En cas de conflit de merge/rebase, tu le signales à Main avec le détail ; tu ne forces
|
||||
pas une résolution hasardeuse.
|
||||
Tu as un subrepo (IdeA) et un sub repo dans le dossier sdk/ideaSDK, fais attention à bien gérer ces deux repo en fonctions des modifications apportées
|
||||
@ -77,3 +77,4 @@
|
||||
- [ticket113-controlled-args-field-rootcause](ticket113-controlled-args-field-rootcause.md) — memory note ticket113-controlled-args-field-rootcause
|
||||
- [ticket120-hello-plugin-recurrence-investigation-angle](ticket120-hello-plugin-recurrence-investigation-angle.md) — memory note ticket120-hello-plugin-recurrence-investigation-angle
|
||||
- [ticket120-hello-plugin-blackscreen-recurrence](ticket120-hello-plugin-blackscreen-recurrence.md) — memory note ticket120-hello-plugin-blackscreen-recurrence
|
||||
- [plugin-asset-serving-and-owned-storage-contracts](plugin-asset-serving-and-owned-storage-contracts.md) — memory note plugin-asset-serving-and-owned-storage-contracts
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
---
|
||||
name: plugin-asset-serving-and-owned-storage-contracts
|
||||
description: memory note plugin-asset-serving-and-owned-storage-contracts
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
# Contrats plugins #133/#138 : service d'assets multi-fichiers + persistance plugin-owned hors projet
|
||||
|
||||
Décisions figées dans `ARCHITECTURE.md` §22 (2026-08-02).
|
||||
|
||||
## #133 — service des assets `idea-plugin://`
|
||||
|
||||
`asset_allowed` (`crates/app-tauri/src/plugins.rs:504-536`) doit servir tout chemin relatif confiné dès lors que les gardes déjà présentes tiennent : registre actif (`lifecycle_state.is_runtime_active()`) + `content_hash` du package matché + confinement canonicalize (déjà en place lignes 467-483). Fin de l'allowlist `declared_main || declared_icon || starts_with("assets/")` qui cassait tout import ESM relatif secondaire (`./constants.js`, `./core/x.js`) → "Importing a module script failed.". Pas de résolution `node_modules`/bare specifiers — hors scope, figé. Débloque #134 (implémentation) et #135 (audit confinement install + désinstallation 100%).
|
||||
|
||||
## #138 — persistance plugin-owned
|
||||
|
||||
`ctx.storage` (déjà typé dans `sdk/IdeaSDK/src/runtime.ts`, jamais câblé côté `frontend/src/plugins/runtime/loader.ts` ni implémenté côté Rust — vérifié : zéro port/commande/répertoire) devient l'API canonique unique pour l'état interne du plugin (prefs/cache/index). Nouveau répertoire `app_data/plugins/data/<pluginId>/`, frère de `plugins/installed/<pluginId>/` (jamais dedans, pour survivre aux réinstalls et donner une racine univoque à purger). `plugin_uninstall` doit purger les deux répertoires. `ctx.services.config`/`workspace` restent réservés au project-owned (fichiers réels du projet, jamais l'état interne du plugin). L'exemple `hello-plugin` doit migrer ses compteurs internes de `.ideai/hello-plugin.json` vers `ctx.storage`. Débloque #139.
|
||||
|
||||
Détail complet et rationale : `ARCHITECTURE.md` §22. Tickets liés : #133/#134/#135 (assets), #138/#139 (storage). Les deux tickets #133/#138 sont passés en `QA` avec carnet détaillé.
|
||||
@ -5,13 +5,8 @@
|
||||
"id": "a72dac60-641c-4417-b0d7-94b8539f817a",
|
||||
"name": "build-appimage",
|
||||
"description": null,
|
||||
"contentHash": "77cb33b978b242f6"
|
||||
},
|
||||
{
|
||||
"id": "86ea97df-1533-47e5-8809-dc2b02242567",
|
||||
"name": "mcp-rendezvous-functional-test",
|
||||
"description": null,
|
||||
"contentHash": "9fc8260f64c3b9d5"
|
||||
"kind": "workflow",
|
||||
"contentHash": "56dd74230ccf517d"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Build de l'AppImage IdeA (Linux)
|
||||
|
||||
Commande **validée bout-en-bout** (2026-06-17, build exit 0, artefact 106M produit) pour reconstruire l'AppImage Linux d'IdeA. À utiliser à chaque fois qu'un correctif backend/front doit être rendu actif dans l'app (rappel : **le binaire qui tourne = l'AppImage installée, pas les sources** — un correctif n'est actif qu'après rebuild + remplacement + relance d'IdeA).
|
||||
Commande **validée bout-en-bout** (mise à jour 2026-08-03) pour reconstruire l'AppImage Linux d'IdeA. À utiliser à chaque fois qu'un correctif backend/front doit être rendu actif dans l'app (rappel : **le binaire qui tourne = l'AppImage installée, pas les sources** — un correctif n'est actif qu'après rebuild + remplacement + relance d'IdeA).
|
||||
|
||||
## Procédure (2 étapes, depuis le project root `/home/anthony/Documents/Projects/IdeA`)
|
||||
|
||||
@ -12,16 +12,18 @@ npm --prefix frontend run build
|
||||
### 2. Bundle Tauri AppImage (depuis `crates/app-tauri/`)
|
||||
```bash
|
||||
cd crates/app-tauri
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage
|
||||
mkdir -p /tmp/idea-cargo-home
|
||||
CARGO_HOME=/tmp/idea-cargo-home APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage
|
||||
```
|
||||
|
||||
## Pourquoi ces options (ne pas les retirer)
|
||||
- `--bundles appimage` : **exclut NSIS** (installeur Windows) — sinutile et cassant sur Linux.
|
||||
- `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1` : **workaround FUSE obligatoire**. Sans ça, l'étape finale `linuxdeploy` (elle-même une AppImage montée via FUSE) échoue avec `failed to run linuxdeploy`. Le compile Rust réussit avant ce point ; seul le bundling casse (l'`AppDir` est généré mais pas le `.AppImage`).
|
||||
- `CARGO_HOME=/tmp/idea-cargo-home` : **workaround sandbox/permissions** pour les environnements où `/home/<user>/.cargo` est monté en lecture seule. Sans ça, `tauri build` peut échouer pendant le téléchargement/unpack Cargo avec `Read-only file system (os error 30)`.
|
||||
|
||||
## Artefact produit
|
||||
```
|
||||
target/release/bundle/appimage/IdeA_0.1.0_amd64.AppImage
|
||||
target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage
|
||||
```
|
||||
(Le build est long : compile Rust release ~1 min + bundling. Lancer en arrière-plan.)
|
||||
|
||||
@ -29,4 +31,4 @@ target/release/bundle/appimage/IdeA_0.1.0_amd64.AppImage
|
||||
Pour rendre le correctif actif, il faut **remplacer l'AppImage installée** `/home/anthony/Documents/IdeA_0.1.0_amd64.AppImage` par l'artefact, puis **relancer IdeA**. ⚠️ Relancer IdeA **tue l'orchestrateur en cours** (le serveur qui héberge la session active et les ponts MCP) : à faire par l'utilisateur quand il est prêt, pas en pleine session multi-agents. Garder un backup de l'ancienne AppImage avant remplacement (cf. convention `.old-<raison>`).
|
||||
|
||||
## Piège env (si on lance un binaire ensuite)
|
||||
La session shell hérite des variables de l'AppImage montée (`APPDIR`, `LD_LIBRARY_PATH`, `PYTHONHOME` → `/tmp/.mount_IdeA_*`). Pour lancer un binaire app-tauri compilé sans crash WebKit, partir d'un env propre (`env -i PATH=/usr/bin:/bin HOME=$HOME XDG_RUNTIME_DIR=/run/user/1000 …`) et préférer `jq` à `python3`.
|
||||
La session shell hérite des variables de l'AppImage montée (`APPDIR`, `LD_LIBRARY_PATH`, `PYTHONHOME` → `/tmp/.mount_IdeA_*`). Pour lancer un binaire app-tauri compilé sans crash WebKit, partir d'un env propre (`env -i PATH=/usr/bin:/bin HOME=$HOME XDG_RUNTIME_DIR=/run/user/1000 …`) et préférer `jq` à `python3`.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#113"
|
||||
version: 4
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1785395935541
|
||||
version: 5
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785748044140
|
||||
---
|
||||
|
||||
@ -2,16 +2,16 @@
|
||||
id: "1c128e50-96bd-4689-a080-f6b5e0c5a6b6"
|
||||
number: 113
|
||||
title: "[Bug] Les espaces ne epuvent pas etre entrés dans les args du serveur llamacpp"
|
||||
status: "open"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
attachments: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1785395710201
|
||||
updatedAt: 1785395935541
|
||||
version: 4
|
||||
updatedAt: 1785748044140
|
||||
version: 5
|
||||
---
|
||||
Dnas les option de reglage llama.cpp de l'edition des serveurs locaux de modele llm, je ne peux pas entrer d'espaces dans le champs de texte Arguments supplémentaires. Il faut faire en sorte que ça soit possible
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#119"
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1785536553069
|
||||
version: 3
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785748044163
|
||||
---
|
||||
|
||||
@ -2,17 +2,17 @@
|
||||
id: "b76431d7-f3a3-438f-ae3f-5648f5eda8ce"
|
||||
number: 119
|
||||
title: "Refondre le système de skills IdeA en capacités agent découvrables"
|
||||
status: "inProgress"
|
||||
status: "qa"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: []
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1785534242629
|
||||
updatedAt: 1785536553069
|
||||
version: 2
|
||||
updatedAt: 1785748044163
|
||||
version: 3
|
||||
---
|
||||
## Constat
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
---
|
||||
issueRef: "#120"
|
||||
version: 9
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1785600025153
|
||||
version: 10
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785748044180
|
||||
---
|
||||
---
|
||||
issueRef: "#120"
|
||||
|
||||
@ -2,17 +2,17 @@
|
||||
id: "f4218553-680a-4fbb-9514-a96eab79b8d8"
|
||||
number: 120
|
||||
title: "Réinvestiguer l’installation de hello-plugin: écran noir / perte d’affichage IdeA"
|
||||
status: "inProgress"
|
||||
status: "qa"
|
||||
priority: "critical"
|
||||
sprint: null
|
||||
links: [{"target":"#116","kind":"relatesTo"},{"target":"#43","kind":"relatesTo"}]
|
||||
agentRefs: []
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1785534496259
|
||||
updatedAt: 1785600025153
|
||||
version: 9
|
||||
updatedAt: 1785748044180
|
||||
version: 10
|
||||
---
|
||||
## Constat utilisateur
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
issueRef: "#122"
|
||||
version: 3
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1785592528432
|
||||
version: 4
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785748044193
|
||||
---
|
||||
|
||||
@ -2,16 +2,16 @@
|
||||
id: "fa083793-48ae-417a-ab16-3813e22df2e3"
|
||||
number: 122
|
||||
title: "[Bug] Override des permissions defaut qui ne marche pas"
|
||||
status: "open"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
|
||||
attachments: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1785592422173
|
||||
updatedAt: 1785592528432
|
||||
version: 3
|
||||
updatedAt: 1785748044193
|
||||
version: 4
|
||||
---
|
||||
J'ai l'impression que l'override des permissions systeme ne fonctionne pas. J'avais les permissions par defaut qui ne donnait pas les droits bash, et meme après avoir override ce parametre sur un de mes agents, il n'avait aps acces aux tools bash. Il y a eu acces une fois qu'avais mis le droit dans la config defaut
|
||||
6
.ideai/tickets/131/carnet.md
Normal file
6
.ideai/tickets/131/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#131"
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785748044208
|
||||
---
|
||||
39
.ideai/tickets/131/issue.md
Normal file
39
.ideai/tickets/131/issue.md
Normal file
@ -0,0 +1,39 @@
|
||||
---
|
||||
id: "4092a7bf-5abb-4056-9e6d-226c392b2279"
|
||||
number: 131
|
||||
title: "Configurer l'effort par agent avec presets adaptatifs selon le profil AI"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: []
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1785687359220
|
||||
updatedAt: 1785748044208
|
||||
version: 2
|
||||
---
|
||||
Objectif: permettre de choisir l'effort de chaque agent, idéalement via une droplist qui s'adapte au profil AI sélectionné (ex: Codex, Claude Code, OpenCode), tout en conservant un fallback sûr.
|
||||
|
||||
Cadrage validé en pré-analyse:
|
||||
- Faisabilité: oui.
|
||||
- Recommandation produit/contrat: approche hybride contrôlée.
|
||||
- UI recommandée: droplist dépendante du profil AI + option "Personnalisé" ouvrant un champ texte/valeur libre si nécessaire.
|
||||
|
||||
Attendus de conception:
|
||||
- Le profil AI déclare ses options natives d'effort/presets quand elles existent.
|
||||
- La UI affiche ces options dans une droplist ordonnée du plus léger au plus profond.
|
||||
- Si le provider n'expose pas d'options propres, fallback vers des presets génériques (ex: Rapide / Standard / Approfondi) clairement marqués comme options par défaut.
|
||||
- Une option "Personnalisé" reste disponible pour couvrir les providers ou cas non modélisables proprement.
|
||||
|
||||
Attendus de contrat:
|
||||
- Ajouter sur le profil AI un mécanisme déclaratif d'options d'effort (ex: effort_options avec label + valeur interne + éventuels hints).
|
||||
- Le DTO d'agent doit persister soit un preset choisi, soit une valeur brute personnalisée.
|
||||
- Prévoir la rétrocompatibilité avec les profils/configs existants, notamment les champs Codex déjà proches de cette notion.
|
||||
|
||||
Risques à traiter:
|
||||
- Mapping imparfait entre presets UI et paramètres natifs des providers.
|
||||
- Cohérence des libellés entre providers.
|
||||
- Découverte UX de l'option "Personnalisé".
|
||||
- Rétrocompatibilité/persistance sur les profils existants.
|
||||
6
.ideai/tickets/132/carnet.md
Normal file
6
.ideai/tickets/132/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#132"
|
||||
version: 2
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785748044221
|
||||
---
|
||||
41
.ideai/tickets/132/issue.md
Normal file
41
.ideai/tickets/132/issue.md
Normal file
@ -0,0 +1,41 @@
|
||||
---
|
||||
id: "37c98a91-ee51-42b3-b804-8c0732784e11"
|
||||
number: 132
|
||||
title: "Ajouter un outil MCP IdeA pour éditer le contexte projet global"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: []
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1785687502830
|
||||
updatedAt: 1785748044221
|
||||
version: 2
|
||||
---
|
||||
Objectif: permettre à un agent autorisé d'éditer le contexte projet global via un outil MCP IdeA dédié, au lieu de passer uniquement par une proposition enregistrée.
|
||||
|
||||
Constat actuel:
|
||||
- Le contexte projet global est lisible via `idea_context_read`.
|
||||
- Son évolution passe aujourd'hui par `idea_context_propose` sans `target`, ce qui enregistre une proposition pour validation mais n'applique pas directement la modification.
|
||||
- Pour certains workflows d'orchestration, il manque une capacité native explicite d'édition contrôlée du contexte projet global.
|
||||
|
||||
Attendu produit/technique:
|
||||
- Introduire un outil MCP IdeA dédié pour mettre à jour le contexte projet global.
|
||||
- Définir clairement qui peut l'utiliser (ex: Main uniquement, ou liste d'agents autorisés).
|
||||
- Préserver les garde-fous de concurrence et de traçabilité déjà attendus sur les contextes.
|
||||
- Clarifier la relation entre ce nouvel outil et `idea_context_propose` (complément, remplacement partiel, ou voie restreinte selon les droits).
|
||||
|
||||
Points à cadrer:
|
||||
- Modèle d'autorisation: quels agents peuvent écrire le contexte global.
|
||||
- Concurrence/versioning: écrasement simple vs contrôle optimiste.
|
||||
- Auditabilité: auteur, date, historique/provenance des changements.
|
||||
- UX/runtime: comportement si un agent non autorisé tente l'opération.
|
||||
- Compatibilité avec la règle actuelle de single-writer réservée à l'orchestrateur.
|
||||
|
||||
Critères de sortie:
|
||||
- Contrat MCP défini.
|
||||
- Règles d'autorisation explicites.
|
||||
- Comportement d'erreur et de concurrence défini.
|
||||
- Décision documentée sur la coexistence avec `idea_context_propose`.
|
||||
24
.ideai/tickets/133/carnet.md
Normal file
24
.ideai/tickets/133/carnet.md
Normal file
@ -0,0 +1,24 @@
|
||||
---
|
||||
issueRef: "#133"
|
||||
version: 4
|
||||
updatedBy: {"kind":"agent","agent_id":"b4730d7f-c54d-4736-8a04-c6203aa2fd49"}
|
||||
updatedAt: 1785703664966
|
||||
---
|
||||
## Décision d'architecture (2026-08-02)
|
||||
|
||||
Documentée dans `ARCHITECTURE.md` §22.1 (nouvelle section "Plugins — service des assets multi-fichiers & persistance plugin-owned").
|
||||
|
||||
**Contrat tranché :** `asset_allowed` (`crates/app-tauri/src/plugins.rs:504-536`) doit servir tout chemin relatif confiné dès lors que les trois gardes déjà présentes sont satisfaites — entrée registre trouvée + `lifecycle_state.is_runtime_active()` + `entry.content_hash == hash` de l'URL (intégrité du package entier) — sans plus restreindre au triplet `declared_main || declared_icon || starts_with("assets/")`. Le confinement canonicalize aval (lignes 467-483, `target.starts_with(&root)`) reste inchangé et continue de protéger contre l'évasion de racine. `validator.validate(manifest)` reste appelé comme garde d'intégrité globale du manifeste, mais cesse de gater le service fichier par fichier.
|
||||
|
||||
**Rationale sécurité :** aucune perte de garantie — le modèle de menace est fixé par `content_hash` à l'installation (audité en #135), donc restreindre les fichiers *siblings* d'un package déjà intégralement vérifié n'arrête aucune attaque supplémentaire, ça casse juste des graphes de modules ESM légitimes.
|
||||
|
||||
**Limite figée :** pas de résolution `node_modules`/bare specifiers — hors scope, aucun résolveur de module à construire. Un plugin avec dépendances tierces les bundle ou vendore en relatif, à son choix.
|
||||
|
||||
**Contrat de confinement/désinstallation formalisé :** racine servie = exclusivement `app_data/plugins/installed/<pluginId>/` ; jamais d'écriture/exposition hors project root ou `.ideai/` de l'utilisateur ; désinstallation = suppression complète + entrée registre, zéro résidu (périmètre détaillé pour #135).
|
||||
|
||||
## Débloque
|
||||
|
||||
- **#134** : remplacer la dernière ligne de `asset_allowed` — `Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/"))` — par une autorisation basée uniquement sur les gardes déjà calculées plus haut dans la fonction. Tests de non-régression path-traversal et hash/lifecycle invalides déjà spécifiés dans #134, contrat inchangé.
|
||||
- **#135** : périmètre d'audit = confinement à l'install (`RelativePath::new` déjà rejette `..`/absolu côté domaine — vérifier qu'il est bien appliqué à l'INSTALL, pas seulement au SERVE) + désinstallation 100%.
|
||||
|
||||
Aucun changement de code applicatif dans ce ticket (portée strictement architecture, conforme à l'objectif du ticket). Fichier touché : `ARCHITECTURE.md` (§22.1 ajouté).
|
||||
31
.ideai/tickets/133/issue.md
Normal file
31
.ideai/tickets/133/issue.md
Normal file
@ -0,0 +1,31 @@
|
||||
---
|
||||
id: "f5296d8b-6bef-45c4-ac8a-cf6e0f90ae9c"
|
||||
number: 133
|
||||
title: "Plugins: contrat de service des assets idea-plugin:// (multi-fichiers ESM) & confinement"
|
||||
status: "qa"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: [{"agentId":"b4730d7f-c54d-4736-8a04-c6203aa2fd49","role":"assigned"}]
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"b4730d7f-c54d-4736-8a04-c6203aa2fd49"}
|
||||
updatedBy: {"kind":"agent","agent_id":"b4730d7f-c54d-4736-8a04-c6203aa2fd49"}
|
||||
createdAt: 1785702640094
|
||||
updatedAt: 1785703664966
|
||||
version: 4
|
||||
---
|
||||
Bug diagnostiqué : `crates/app-tauri/src/plugins.rs:504-536` (`asset_allowed`) n'autorise que `main`/`icon` déclarés au manifeste, ou un chemin préfixé `assets/`. Tout import ESM relatif secondaire (`./constants.js`, `./core/x.js`) depuis le `main` est donc rejeté 403 → "Importing a module script failed." côté navigateur. Le SDK (sdk/IdeaSDK/README.md) documente `main: dist/index.js` comme point d'entrée sans jamais imposer un bundle mono-fichier, ce qui sous-entend un support multi-fichiers jamais réellement vérifié (l'exemple hello-plugin est mono-fichier).
|
||||
|
||||
Objectif de ce ticket : trancher le contrat d'architecture, PAS l'implémenter.
|
||||
|
||||
À décider et documenter :
|
||||
1. Élargir la politique de service à : tout chemin relatif confiné du package installé, dès lors que `entry.content_hash == hash` (intégrité du package entier déjà vérifiée) ET `entry.lifecycle_state.is_runtime_active()` ET confinement canonicalize (`target.starts_with(root)`, déjà en place lignes 467-483). Ces trois garanties suffisent déjà sans dépendre d'une déclaration par-fichier dans le manifeste.
|
||||
2. Figer la limite explicite : imports ESM relatifs uniquement, pas de résolution `node_modules`/bare specifiers (hors scope, pas de résolveur de modules à construire) — un plugin qui a des dépendances tierces doit les vendorer en relatif ou les bundler lui-même, à son choix, jamais une obligation d'IdeA.
|
||||
3. Formaliser le contrat de confinement + désinstallation propre : aucune écriture ne doit jamais sortir de `app_data/plugins/installed/<id>` (pas de pollution project root ni `.ideai/`), et la désinstallation doit être 100% (dossier + entrée registry, zéro résidu), à la manière VSCode.
|
||||
|
||||
Livrable : note d'architecture (+ mise à jour de la doc plugin existante si présente) qui fait foi pour les tickets d'implémentation liés (DevBackend, SDK/doc, QA).
|
||||
|
||||
Critères d'acceptation :
|
||||
- Le contrat écrit référence explicitement le code actuel (plugins.rs:504-536) et explique pourquoi hash+lifecycle+confinement remplacent l'allowlist par fichier sans régression de sécurité.
|
||||
- La limite bare-specifiers/node_modules est tranchée noir sur blanc (in ou out, et pourquoi).
|
||||
- Le contrat de confinement/désinstallation est écrit explicitement (racine autorisée, ce qui est interdit, ce que "propre" veut dire).
|
||||
6
.ideai/tickets/134/carnet.md
Normal file
6
.ideai/tickets/134/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#134"
|
||||
version: 3
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785707290184
|
||||
---
|
||||
33
.ideai/tickets/134/issue.md
Normal file
33
.ideai/tickets/134/issue.md
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
id: "e98c3f80-9fd6-444a-be80-ad695809c71c"
|
||||
number: 134
|
||||
title: "Plugins: servir tout fichier confiné du package installé (fix racine multi-fichiers ESM)"
|
||||
status: "qa"
|
||||
priority: "critical"
|
||||
sprint: null
|
||||
links: [{"target":"#133","kind":"dependsOn"}]
|
||||
agentRefs: [{"agentId":"fe887179-933f-47d4-960f-c3b06827f86c","role":"assigned"}]
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"b4730d7f-c54d-4736-8a04-c6203aa2fd49"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1785702651199
|
||||
updatedAt: 1785707290184
|
||||
version: 3
|
||||
---
|
||||
Implémente le contrat décidé en #133.
|
||||
|
||||
Modifier `asset_allowed` / `plugin_asset_response_with_stores` dans `crates/app-tauri/src/plugins.rs:504-536` : remplacer la condition `declared_main || declared_icon || rel.as_str().starts_with("assets/")` par une autorisation basée sur les garanties déjà vérifiées avant cette ligne (hash de contenu du package `entry.content_hash.as_str() == hash`, `entry.lifecycle_state.is_runtime_active()`) et sur le confinement canonicalize déjà en place lignes 467-483 (`target.starts_with(&root)`).
|
||||
|
||||
Ne pas retirer `validator.validate(&manifest_bytes.bytes, &package)` : cette vérification reste une garde d'intégrité globale du manifeste, mais ne doit plus servir à restreindre le service fichier par fichier.
|
||||
|
||||
Respecter strictement la limite figée en #133 (imports relatifs uniquement, pas de résolveur node_modules/bare specifiers — hors scope).
|
||||
|
||||
Tests à ajouter dans `crates/app-tauri/src/plugins.rs` (module de tests existant en bas de fichier) :
|
||||
- requête d'un fichier non déclaré dans le manifeste (ex: `dist/core/helper.js`) → 200 OK si hash+lifecycle valides.
|
||||
- path traversal (`../`) → toujours 403 (non-régression, déjà couvert mais à revérifier après le changement).
|
||||
- hash de contenu différent ou plugin non `runtime_active` → toujours 403 (non-régression).
|
||||
|
||||
Critères d'acceptation :
|
||||
- `cargo test -p app-tauri` vert, nouveaux cas inclus.
|
||||
- Un plugin composé de `dist/index.js` + `dist/constants.js` (import relatif) se charge sans 403 via le protocole `idea-plugin://`.
|
||||
- Aucune régression sur les tests de confinement/path-traversal existants.
|
||||
26
.ideai/tickets/135/carnet.md
Normal file
26
.ideai/tickets/135/carnet.md
Normal file
@ -0,0 +1,26 @@
|
||||
---
|
||||
issueRef: "#135"
|
||||
version: 4
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785708498776
|
||||
---
|
||||
## Audit QA
|
||||
|
||||
- `install_from_directory` : audit confirme qu'avant correctif le store ne rejetait pas explicitement les symlinks source ; il les ignorait. Le correctif fait maintenant échouer l'installation sur toute entrée symlink ou unsupported dans l'arbre source.
|
||||
- `install_from_archive` : audit confirme un trou réel de confinement avant correctif. L'extraction reposait entièrement sur `unzip` sans validation applicative des entrées. Le correctif remplace cette extraction par une lecture Rust confinée qui rejette les entrées `../`/absolues via `enclosed_name()` et refuse explicitement les symlinks d'archive.
|
||||
- Confinement d'écriture : après correctif, aucune écriture d'install ne sort de `app_data/plugins/_staging/...` puis `app_data/plugins/installed/<id>` ; le test `../../../../outside.txt` prouve l'absence d'écriture hors racine.
|
||||
- `hash_dir` / collecte fichiers : durci pour échouer si un package contient encore un symlink ou une entrée non supportée, au lieu de l'ignorer.
|
||||
- `plugin_uninstall` / `remove_package` : audit confirmé par test multifichier. La désinstallation supprime le dossier entier, retire l'entrée registry et laisse le runtime catalog vide.
|
||||
|
||||
## Tests ajoutés/ajustés
|
||||
|
||||
- `plugin::tests::install_from_directory_rejects_source_symlink`
|
||||
- `plugin::tests::install_from_archive_rejects_parent_traversal_without_writing_outside_stage`
|
||||
- `plugin::tests::install_from_archive_rejects_symlink_entries`
|
||||
- `plugin_install_load::uninstall_multifile_plugin_removes_package_registry_and_runtime_residue`
|
||||
- Stabilisation des tests SDK `hello-plugin` : fixture matérialisée avec `dist/index.js` dans un temp dir pour supprimer une dépendance implicite à un build préalable.
|
||||
|
||||
## Verdict
|
||||
|
||||
- Correctif confinement install/uninstall validé.
|
||||
- `cargo test -p infrastructure -p application -p app-tauri` vert avec `CARGO_HOME=/tmp/idea-cargo-home` dans cet environnement.
|
||||
31
.ideai/tickets/135/issue.md
Normal file
31
.ideai/tickets/135/issue.md
Normal file
@ -0,0 +1,31 @@
|
||||
---
|
||||
id: "13442dae-7860-45e3-9287-31b9bde19170"
|
||||
number: 135
|
||||
title: "Plugins: audit confinement install & désinstallation 100% propre"
|
||||
status: "qa"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: [{"target":"#133","kind":"dependsOn"}]
|
||||
agentRefs: [{"agentId":"ab328d90-c307-4771-a3b6-6c56089c8506","role":"assigned"}]
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"b4730d7f-c54d-4736-8a04-c6203aa2fd49"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1785702662938
|
||||
updatedAt: 1785708498776
|
||||
version: 4
|
||||
---
|
||||
Applique le contrat de confinement/désinstallation décidé en #133.
|
||||
|
||||
Auditer `plugin_install_from_archive` / `plugin_install_from_directory` (`crates/app-tauri/src/plugins.rs:67-142`) et le store d'infrastructure (`crates/infrastructure/src/plugin/mod.rs`) pour confirmer qu'aucune écriture ne peut jamais sortir de `app_data/plugins/installed/<id>` :
|
||||
- Un plugin dont le manifeste ou l'archive contient un chemin `../` ou un symlink pointant hors de sa racine doit échouer à l'install (vérifier que `RelativePath::new` — qui rejette déjà `..` et les chemins absolus, cf. `crates/domain/src/plugin.rs` — est bien appliqué à l'INSTALL, pas seulement au SERVE ajouté en #134).
|
||||
- Aucune écriture ne doit jamais toucher le project root ni `.ideai/` du projet ouvert : le plugin est un citoyen de `app_data`, jamais du repo utilisateur.
|
||||
|
||||
Vérifier que `plugin_uninstall` (`crates/app-tauri/src/plugins.rs:142`) supprime bien 100% : dossier entier + entrée registry, zéro résidu. Étendre si besoin les tests existants (`uninstall_removes_registry_package_and_stops_mcp`, `uninstall_then_reinstall_leaves_runtime_catalog_active_without_residue` dans `crates/application/src/plugin/mod.rs`) pour couvrir explicitement le cas d'un plugin multi-fichiers (plusieurs fichiers sous `dist/`).
|
||||
|
||||
Si un chemin d'attaque (symlink sortant, `../` dans une archive zip malveillante) n'est pas déjà bloqué à l'install, ouvrir un correctif dans ce même ticket (pas de nouveau ticket) : c'est un renforcement du même contrat, pas une nouvelle feature.
|
||||
|
||||
Critères d'acceptation :
|
||||
- Rapport d'audit écrit (dans le carnet du ticket) listant les points vérifiés et leur statut.
|
||||
- Test explicite : tentative d'installer un plugin avec chemin `../` ou symlink sortant → échec propre, aucun fichier écrit hors racine.
|
||||
- Test explicite : désinstallation d'un plugin multi-fichiers → dossier disparu, entrée registry disparue, aucun résidu.
|
||||
- `cargo test -p app-tauri -p infrastructure -p application` vert.
|
||||
6
.ideai/tickets/136/carnet.md
Normal file
6
.ideai/tickets/136/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#136"
|
||||
version: 3
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785708478849
|
||||
---
|
||||
27
.ideai/tickets/136/issue.md
Normal file
27
.ideai/tickets/136/issue.md
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
id: "214e7c14-fab8-45ed-bb71-5f309a8479a6"
|
||||
number: 136
|
||||
title: "SDK plugins: aligner doc/exemple sur le support multi-fichiers ESM"
|
||||
status: "qa"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: [{"target":"#134","kind":"dependsOn"}]
|
||||
agentRefs: [{"agentId":"8f7da528-58df-4315-97e9-0562230ecc19","role":"assigned"}]
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"b4730d7f-c54d-4736-8a04-c6203aa2fd49"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1785702672090
|
||||
updatedAt: 1785708478849
|
||||
version: 3
|
||||
---
|
||||
Une fois #134 livré, aligner le SDK et sa documentation pour que le support multi-fichiers soit explicite et testé, pas seulement sous-entendu.
|
||||
|
||||
Étendre `sdk/IdeaSDK/examples/hello-plugin` (ou ajouter un nouvel exemple dédié, ex: `hello-plugin-multi`) avec un vrai split en plusieurs fichiers ESM compilés séparément — `src/index.ts` qui importe `./constants.ts` et `./core/...` — sans bundler forcé (juste `tsc`, comme l'exemple actuel). Ce sera la preuve vivante et le test de non-régression du contrat de #133/#134.
|
||||
|
||||
Mettre à jour `sdk/IdeaSDK/README.md` (autour de la ligne 50 où `main` est décrit comme "compiled ESM entrypoint") :
|
||||
- Clarifier explicitement que `main` est le point d'entrée, mais que des imports relatifs vers d'autres fichiers du même package sont servis nativement par le protocole `idea-plugin://` (plus besoin de tout bundler en un seul fichier).
|
||||
- Documenter la limite figée en #133 : imports relatifs uniquement ; les dépendances tierces (npm) doivent être bundlées ou vendorées en relatif par le plugin, IdeA ne résout pas `node_modules`.
|
||||
|
||||
Critères d'acceptation :
|
||||
- L'exemple multi-fichiers build (`npm run build` ou équivalent existant) et se charge dans IdeA sans erreur `Importing a module script failed.` (vérification manuelle ou via #136).
|
||||
- Le README ne laisse plus entendre un support non vérifié ; la limite bare-specifiers est écrite noir sur blanc.
|
||||
6
.ideai/tickets/137/carnet.md
Normal file
6
.ideai/tickets/137/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#137"
|
||||
version: 1
|
||||
updatedBy: {"kind":"agent","agent_id":"b4730d7f-c54d-4736-8a04-c6203aa2fd49"}
|
||||
updatedAt: 1785702681696
|
||||
---
|
||||
27
.ideai/tickets/137/issue.md
Normal file
27
.ideai/tickets/137/issue.md
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
id: "2ef9b71a-9869-4476-88b6-65aaefc993e1"
|
||||
number: 137
|
||||
title: "QA: validation end-to-end plugin multi-fichiers ESM (chargement + désinstallation propre)"
|
||||
status: "open"
|
||||
priority: "critical"
|
||||
sprint: null
|
||||
links: [{"target":"#134","kind":"dependsOn"},{"target":"#136","kind":"dependsOn"}]
|
||||
agentRefs: [{"agentId":"ab328d90-c307-4771-a3b6-6c56089c8506","role":"assigned"}]
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"b4730d7f-c54d-4736-8a04-c6203aa2fd49"}
|
||||
updatedBy: {"kind":"agent","agent_id":"b4730d7f-c54d-4736-8a04-c6203aa2fd49"}
|
||||
createdAt: 1785702681696
|
||||
updatedAt: 1785702681696
|
||||
version: 1
|
||||
---
|
||||
Validation réelle, sortie observée — pas seulement des tests unitaires — du fix multi-fichiers (#134) et de l'exemple SDK (#136).
|
||||
|
||||
À exécuter dans une build réelle (AppImage ou dev Tauri) :
|
||||
1. Installer le plugin multi-fichiers issu de #136 (fichiers ESM non bundlés, imports relatifs). Confirmer l'absence de l'erreur navigateur "Importing a module script failed." et l'activation correcte (`activate(ctx)` appelé, contributions menus/layouts visibles et fonctionnelles selon ce que déclare l'exemple).
|
||||
2. Vérifier l'isolement : le plugin ne dépose rien dans le project root ni dans `.ideai/` du projet ouvert (contrat de #133/#135).
|
||||
3. Désinstaller depuis l'UI (Panneau Plugins) : vérifier disque (aucun résidu sous `app_data/plugins/installed/<id>`), registre (entrée disparue), et absence de toute trace côté projet.
|
||||
4. Réinstaller le même plugin après désinstallation : doit repartir propre, sans conflit résiduel.
|
||||
|
||||
Critères d'acceptation :
|
||||
- Rapport QA avec preuve d'exécution réelle (logs/captures), verdict vert ou liste d'écarts bloquants.
|
||||
- Si régression détectée sur #134/#135/#136, retour précis (repro + fichier/ligne suspecté) au dev concerné avant clôture.
|
||||
30
.ideai/tickets/138/carnet.md
Normal file
30
.ideai/tickets/138/carnet.md
Normal file
@ -0,0 +1,30 @@
|
||||
---
|
||||
issueRef: "#138"
|
||||
version: 4
|
||||
updatedBy: {"kind":"agent","agent_id":"b4730d7f-c54d-4736-8a04-c6203aa2fd49"}
|
||||
updatedAt: 1785703680849
|
||||
---
|
||||
## Décision d'architecture (2026-08-02)
|
||||
|
||||
Documentée dans `ARCHITECTURE.md` §22.2 (même nouvelle section que #133).
|
||||
|
||||
**Constat vérifié dans le code :** `sdk/IdeaSDK/src/runtime.ts` déclare déjà `ActivateContext.storage?: PluginStorage` (`get/set/delete`, clé-valeur JSON-serializable), et l'exemple `hello-plugin` s'en sert (`ctx.storage?.get<string>("helloPlugin.ownerAgentId")`). Mais ce champ n'est **jamais peuplé** : `frontend/src/plugins/runtime/loader.ts` (~lignes 249-256) ne câble que `logger`, `subscriptions`, `services` — `ctx.storage` vaut toujours `undefined` en exécution. Côté Rust : zéro port, zéro commande, zéro répertoire pour cette primitive (recherché, rien trouvé). Faute d'API réelle, l'exemple détourne `ctx.services.workspace`/`ctx.services.config` pour écrire son état interne sous `.ideai/hello-plugin.txt` et `.ideai/hello-plugin.json`.
|
||||
|
||||
**Décision — séparation noir sur blanc :**
|
||||
- **Project-owned** : fichiers du workspace que le plugin modifie *volontairement* pour l'utilisateur/le projet → reste `ctx.services.workspace.*`/`ctx.services.config.*`, sandbox projet existant inchangé.
|
||||
- **Plugin-owned** : préférences/cache/sélection/index/config interne → ne vit **jamais** dans le project root ni sous `.ideai/`. Nouveau répertoire **frère** de `plugins/installed/<id>/` : `app_data/plugins/data/<pluginId>/`. Séparé de `installed/` pour que les mises à jour de package ne touchent jamais aux données utilisateur, et pour donner à la désinstallation une deuxième racine univoque à purger.
|
||||
|
||||
**API canonique tranchée : `ctx.storage` seul, pas de second API document.** `ctx.storage.set(key, value)` avec des valeurs JSON couvre déjà le besoin de document structuré — une deuxième API "document plugin-scopé" ferait doublon. `ctx.services.config` reste réservé au project-owned.
|
||||
|
||||
**Cycle de vie figé :**
|
||||
- `ctx.storage.get/set/delete` → commandes Tauri (ex. `plugin_storage_get/set/delete`) → store scopé par `pluginId` sous `plugins/data/<pluginId>/` (format interne — JSON unique ou par clé — laissé à #139, seule la frontière de répertoire est un contrat figé).
|
||||
- `plugin_uninstall` (`crates/app-tauri/src/plugins.rs:142`) doit purger `plugins/data/<id>/` en plus de `plugins/installed/<id>` + registre (déjà couvert par #135). Les fichiers project-owned écrits par le plugin dans le workspace ne sont **jamais** touchés par l'uninstall.
|
||||
|
||||
## Débloque #139
|
||||
|
||||
1. Implémenter `ctx.storage` de bout en bout : port domaine + adapter infra scopés à `plugins/data/<pluginId>/`, commandes Tauri, câblage réel dans `loader.ts` (absent aujourd'hui), confinement en esprit identique à #133/#135.
|
||||
2. Réaligner `hello-plugin` : migrer les compteurs internes (`launches`, `enabled`, `ownerAgentId`) vers `ctx.storage`. Garder au plus un exemple clairement étiqueté "fichier projet réel" via `workspace`/`config`, pas comme pattern par défaut.
|
||||
3. `sdk/IdeaSDK/README.md` section "Structured Config Documents" à corriger : ne plus donner `.ideai/hello-plugin.json` comme exemple d'état interne, remplacer par un exemple `ctx.storage`, documenter la séparation project-owned/plugin-owned.
|
||||
4. Preuve requise : test de purge (installer → écrire via `ctx.storage` → désinstaller → `plugins/data/<id>/` disparu) + absence de tout chemin `.ideai/...` dans les exemples SDK par défaut.
|
||||
|
||||
Aucun changement de code applicatif dans ce ticket (portée strictement architecture/API, conforme à l'objectif du ticket). Fichier touché : `ARCHITECTURE.md` (§22.2 ajouté).
|
||||
33
.ideai/tickets/138/issue.md
Normal file
33
.ideai/tickets/138/issue.md
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
id: "e33fd0ea-e7c6-40dc-a244-f158e44ac4a7"
|
||||
number: 138
|
||||
title: "SDK plugins: contrat de persistance plugin-owned hors projet et effacement total à la désinstallation"
|
||||
status: "qa"
|
||||
priority: "critical"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: [{"agentId":"b4730d7f-c54d-4736-8a04-c6203aa2fd49","role":"assigned"}]
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedBy: {"kind":"agent","agent_id":"b4730d7f-c54d-4736-8a04-c6203aa2fd49"}
|
||||
createdAt: 1785702748769
|
||||
updatedAt: 1785703680849
|
||||
version: 4
|
||||
---
|
||||
Le lot #133/#135 traite déjà le confinement du package installé et l’absence d’écritures parasites au runtime, mais il reste un trou produit/API majeur : le SDK public et son exemple `sdk/IdeaSDK/examples/hello-plugin` montrent encore des écritures plugin sous `.ideai/hello-plugin.txt` et `.ideai/hello-plugin.json`, alors que l’objectif utilisateur est un modèle type VSCode où l’état propre au plugin ne pollue jamais le projet et disparaît entièrement à la désinstallation.
|
||||
|
||||
Objectif de ce ticket : trancher le contrat d’architecture/API, PAS l’implémenter.
|
||||
|
||||
À décider et documenter :
|
||||
1. Séparer noir sur blanc les deux familles de données plugin :
|
||||
- données métier du PROJET que le plugin modifie volontairement dans le workspace utilisateur (autorisées, explicites, relèvent de `workspace.*` / éventuellement `config` quand on touche un vrai fichier du projet) ;
|
||||
- données PROPRES AU PLUGIN (prefs, cache, dernière sélection, index interne, état UI durable, config interne) qui doivent vivre hors project root, dans un store plugin-scopé sous app data, jamais sous `.ideai/` ni ailleurs dans le repo utilisateur.
|
||||
2. Dire si `ctx.storage` clé/valeur suffit comme primitive canonique pour cet état plugin-owned, ou s’il faut une API publique supplémentaire de document structuré plugin-scopé (ex: JSON app-data du plugin) pour éviter de pousser les auteurs à détourner `ctx.services.config` vers `.ideai/*.json`.
|
||||
3. Figer le contrat de désinstallation : la suppression du plugin doit aussi supprimer 100% de son état plugin-owned hors projet (storage, éventuels docs/config plugin-scopés, caches internes), sans toucher aux fichiers métier du projet que l’utilisateur a explicitement demandé au plugin de modifier.
|
||||
4. Imposer l’alignement doc/exemples SDK : ne plus montrer `.ideai/...` comme emplacement par défaut pour l’état interne d’un plugin.
|
||||
|
||||
Critères d’acceptation :
|
||||
- Une note d’architecture/API explicite distingue « project-owned » vs « plugin-owned ».
|
||||
- La source de vérité et le cycle de vie du stockage plugin-owned sont écrits noir sur blanc (création, lecture, suppression à l’uninstall).
|
||||
- Le ticket précise si une nouvelle API SDK est nécessaire ou si `ctx.storage` devient la voie canonique, et pourquoi.
|
||||
- Le contrat est compatible avec l’exigence utilisateur : plugin désinstallé => plus aucun état propre au plugin, ni dans le projet, ni dans l’app data plugin.
|
||||
6
.ideai/tickets/139/carnet.md
Normal file
6
.ideai/tickets/139/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#139"
|
||||
version: 3
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785709504027
|
||||
---
|
||||
17
.ideai/tickets/139/issue.md
Normal file
17
.ideai/tickets/139/issue.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
id: "a5bac900-806d-4e13-866f-7ebae686a43d"
|
||||
number: 139
|
||||
title: "SDK plugins: aligner l’API publique et les exemples sur une persistance plugin-owned hors projet"
|
||||
status: "qa"
|
||||
priority: "critical"
|
||||
sprint: null
|
||||
links: [{"target":"#138","kind":"dependsOn"}]
|
||||
agentRefs: [{"agentId":"fe887179-933f-47d4-960f-c3b06827f86c","role":"assigned"},{"agentId":"8f7da528-58df-4315-97e9-0562230ecc19","role":"assigned"}]
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1785702774592
|
||||
updatedAt: 1785709504027
|
||||
version: 3
|
||||
---
|
||||
Implémenter le contrat décidé en #138. Ce ticket ne doit démarrer qu’après arbitrage architecture, car la forme exacte de l’API publique peut varier (`ctx.storage` canonique seul, ou nouvelle API publique de document structuré plugin-scopé hors projet).\n\nPérimètre attendu après #138 :\n1. Faire de la voie canonique plugin-owned celle décidée en #138, et retirer l’incitation actuelle à écrire l’état interne du plugin dans le workspace utilisateur / `.ideai/`.\n2. Mettre à jour `sdk/IdeaSDK/README.md` et `sdk/IdeaSDK/examples/hello-plugin` pour que l’exemple de référence n’écrive plus `.ideai/hello-plugin.txt` ni `.ideai/hello-plugin.json` comme état interne par défaut.\n3. Si #138 décide qu’une nouvelle API SDK publique est nécessaire (par ex. document structuré plugin-scopé hors projet), l’exposer de bout en bout : types SDK, façade runtime publique, adaptateurs hôte nécessaires, et documentation d’usage.\n4. Garantir que la désinstallation du plugin purge aussi l’état plugin-owned correspondant, conformément au contrat #138, sans supprimer les fichiers métier du projet que le plugin aurait modifiés explicitement.\n\nCritères d’acceptation :\n- Le README SDK sépare explicitement données project-owned vs plugin-owned.\n- L’exemple de référence n’emploie plus `.ideai/...` pour stocker son état interne.\n- Si une nouvelle API publique a été décidée en #138, elle est documentée, typée et couverte par des tests.\n- La purge de l’état plugin-owned à la désinstallation est prouvée par tests ciblés sur le chemin réellement choisi par #138.\n- Aucun message public du SDK ne laisse entendre que `.ideai/` est le lieu normal de persistance interne d’un plugin.
|
||||
6
.ideai/tickets/140/carnet.md
Normal file
6
.ideai/tickets/140/carnet.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
issueRef: "#140"
|
||||
version: 4
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1785760572251
|
||||
---
|
||||
17
.ideai/tickets/140/issue.md
Normal file
17
.ideai/tickets/140/issue.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
id: "353aa2ae-cf98-4a63-b4fb-a15fdb801a0a"
|
||||
number: 140
|
||||
title: "[Bug] je ne peux pas editer le context projet d'un agent a la main"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: [{"agentId":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d","role":"assigned"}]
|
||||
attachments: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1785759952774
|
||||
updatedAt: 1785760572251
|
||||
version: 4
|
||||
---
|
||||
Quand je suis dans le panneau des agents, que je selectionne un agent, le context projet de l'agent s'affiche mal (il n'affiche que [object Object]) et si je l'edit, que je save, et que je le réouvre il estd e nouveau vide
|
||||
@ -66,6 +66,7 @@ Fichier obligatoire : `idea-plugin.json`.
|
||||
"main": "dist/index.js",
|
||||
"icon": "assets/icon.svg",
|
||||
"trustLevel": "full",
|
||||
"activationScope": "app",
|
||||
"capabilities": ["ui", "mcp"],
|
||||
"contributes": {
|
||||
"menus": [],
|
||||
@ -82,6 +83,7 @@ Fichier obligatoire : `idea-plugin.json`.
|
||||
Validation :
|
||||
|
||||
- `trustLevel` vaut uniquement `full` en v1.
|
||||
- `activationScope` est optionnel, vaut `app` par défaut, ou `project` pour différer l'activation runtime jusqu'au premier projet focused ; l'état `pending` n'est pas une erreur de chargement.
|
||||
- `main`, `icon`, assets et commandes MCP relatives ne doivent contenir ni chemin absolu ni `..`.
|
||||
- `id` stable, unique, reverse-DNS recommandé.
|
||||
- `version` SemVer.
|
||||
@ -346,4 +348,4 @@ Livrable : substitution `${appDataDir}` dans `command`, `args`, `env`, `cwd` des
|
||||
- Pas de marketplace distant.
|
||||
- Pas de compilation TS/TSX.
|
||||
- Pas de `${projectRoot}` pour serveurs MCP plugin globaux.
|
||||
- Pas de garantie v1 sur `agentSelected`, `terminalFocused`, `layoutCellFocused` tant qu'un lot focus/selection n'a pas été cadré.
|
||||
- Pas de garantie v1 sur `agentSelected`, `terminalFocused`, `layoutCellFocused` tant qu'un lot focus/selection n'a pas été cadré.
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
---
|
||||
issueRef: "#51"
|
||||
version: 1
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1783941336721
|
||||
---
|
||||
@ -1,15 +0,0 @@
|
||||
---
|
||||
id: "6b74084c-0323-4f87-83f0-d9f944c364dc"
|
||||
number: 51
|
||||
title: "Clean des session headless"
|
||||
status: "open"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1783941336721
|
||||
updatedAt: 1783941336721
|
||||
version: 1
|
||||
---
|
||||
@ -1,6 +0,0 @@
|
||||
---
|
||||
issueRef: "#63"
|
||||
version: 1
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784098438671
|
||||
---
|
||||
@ -1,15 +0,0 @@
|
||||
---
|
||||
id: "b5753734-1474-48d5-af6c-4a41b78005dc"
|
||||
number: 63
|
||||
title: "Systeme de test de l'UI"
|
||||
status: "open"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"user"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1784098438671
|
||||
updatedAt: 1784098438671
|
||||
version: 1
|
||||
---
|
||||
@ -1,6 +0,0 @@
|
||||
---
|
||||
issueRef: "#66"
|
||||
version: 2
|
||||
updatedBy: {"kind":"user"}
|
||||
updatedAt: 1784193460805
|
||||
---
|
||||
@ -1,27 +0,0 @@
|
||||
---
|
||||
id: "f805cd9b-8ecd-401a-8f03-665a27fe73cb"
|
||||
number: 66
|
||||
title: "Image Docker serveur/client IdeA"
|
||||
status: "open"
|
||||
priority: "medium"
|
||||
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
|
||||
links: [{"target":"#65","kind":"dependsOn"}]
|
||||
agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"user"}
|
||||
createdAt: 1784187597004
|
||||
updatedAt: 1784193460805
|
||||
version: 2
|
||||
---
|
||||
Objectif : livrer une image Docker exécutant le mode serveur/client d'IdeA, bâtie sur le binaire headless `idea-serve` (#65), pas sur le binaire Tauri. Cadré par Architect (suite #13).
|
||||
|
||||
Plan de lots (Architect) :
|
||||
- **L4 — Build web transport HTTP** : produire les assets client Vite en `VITE_TRANSPORT=http` (npm, jamais pnpm), vérifier qu'aucun import Tauri ne fuit dans ce mode, packager dans `/usr/share/idea/web`.
|
||||
- **L5 — Docker runtime** : Dockerfile multi-stage (build Rust headless + build frontend + runtime minimal Debian/Ubuntu selon deps PTY/process). Défauts : `IDEA_APP_DATA_DIR=/data`, `--listen 0.0.0.0:17373`, `--web-root /usr/share/idea/web`. Volumes `/data` (app-data : projects/profiles/templates/tasks/logs) et `/workspace` (projets manipulés par les agents). Entrypoint `idea-serve`. Healthcheck HTTP local. Conteneur reste HTTP interne ; reverse proxy TLS externe obligatoire en prod distante (`--allow-remote`/`--public-origin https`/`--trust-reverse-proxy`, doc B8). Pas de TLS applicatif V1.
|
||||
- **L6 — Agents CLI en conteneur** : décider image minimale (profils détectés au runtime, exécutables attendus dans PATH) vs image `idea-server-agents` (CLIs redistribuables installées si licence OK). Seed profils compatibles conteneur. Documenter env (OPENAI_API_KEY, ANTHROPIC_API_KEY, vars opencode) et montages de credentials. Tester au moins un agent bout en bout. Hors périmètre : installer automatiquement des CLIs propriétaires sans validation licence.
|
||||
|
||||
Lock app-data-dir : peu critique en Docker mono-conteneur mono-writer ; multi-conteneurs sur le même /data explicitement hors support sans lock distribué.
|
||||
|
||||
Hors périmètre V1 : Kubernetes, multi-tenant, auth externe, TLS intégré.
|
||||
|
||||
Dépend de #65 (binaire headless).
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"nextNumber": 131
|
||||
"nextNumber": 141
|
||||
}
|
||||
@ -713,19 +713,6 @@
|
||||
},
|
||||
"updatedAt": 1783959345112
|
||||
},
|
||||
{
|
||||
"issueRef": "#51",
|
||||
"path": "51",
|
||||
"title": "Clean des session headless",
|
||||
"status": "open",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"createdBy": {
|
||||
"kind": "user"
|
||||
},
|
||||
"updatedAt": 1783941336721
|
||||
},
|
||||
{
|
||||
"issueRef": "#52",
|
||||
"path": "52",
|
||||
@ -841,19 +828,6 @@
|
||||
},
|
||||
"updatedAt": 1784406936659
|
||||
},
|
||||
{
|
||||
"issueRef": "#63",
|
||||
"path": "63",
|
||||
"title": "Systeme de test de l'UI",
|
||||
"status": "open",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"createdBy": {
|
||||
"kind": "user"
|
||||
},
|
||||
"updatedAt": 1784098438671
|
||||
},
|
||||
{
|
||||
"issueRef": "#64",
|
||||
"path": "64",
|
||||
@ -882,20 +856,6 @@
|
||||
},
|
||||
"updatedAt": 1784210420516
|
||||
},
|
||||
{
|
||||
"issueRef": "#66",
|
||||
"path": "66",
|
||||
"title": "Image Docker serveur/client IdeA",
|
||||
"status": "open",
|
||||
"priority": "medium",
|
||||
"sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6",
|
||||
"assignedAgentIds": [],
|
||||
"createdBy": {
|
||||
"kind": "agent",
|
||||
"agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
},
|
||||
"updatedAt": 1784193460805
|
||||
},
|
||||
{
|
||||
"issueRef": "#67",
|
||||
"path": "67",
|
||||
@ -1469,7 +1429,7 @@
|
||||
"issueRef": "#113",
|
||||
"path": "113",
|
||||
"title": "[Bug] Les espaces ne epuvent pas etre entrés dans les args du serveur llamacpp",
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
|
||||
"assignedAgentIds": [
|
||||
@ -1478,7 +1438,7 @@
|
||||
"createdBy": {
|
||||
"kind": "user"
|
||||
},
|
||||
"updatedAt": 1785395935541
|
||||
"updatedAt": 1785748044140
|
||||
},
|
||||
{
|
||||
"issueRef": "#114",
|
||||
@ -1542,7 +1502,7 @@
|
||||
"issueRef": "#119",
|
||||
"path": "119",
|
||||
"title": "Refondre le système de skills IdeA en capacités agent découvrables",
|
||||
"status": "inProgress",
|
||||
"status": "qa",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
@ -1550,13 +1510,13 @@
|
||||
"kind": "agent",
|
||||
"agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
},
|
||||
"updatedAt": 1785536553069
|
||||
"updatedAt": 1785748044163
|
||||
},
|
||||
{
|
||||
"issueRef": "#120",
|
||||
"path": "120",
|
||||
"title": "Réinvestiguer l’installation de hello-plugin: écran noir / perte d’affichage IdeA",
|
||||
"status": "inProgress",
|
||||
"status": "qa",
|
||||
"priority": "critical",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
@ -1564,7 +1524,7 @@
|
||||
"kind": "agent",
|
||||
"agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
},
|
||||
"updatedAt": 1785600025153
|
||||
"updatedAt": 1785748044180
|
||||
},
|
||||
{
|
||||
"issueRef": "#121",
|
||||
@ -1584,7 +1544,7 @@
|
||||
"issueRef": "#122",
|
||||
"path": "122",
|
||||
"title": "[Bug] Override des permissions defaut qui ne marche pas",
|
||||
"status": "open",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
@ -1593,7 +1553,7 @@
|
||||
"createdBy": {
|
||||
"kind": "user"
|
||||
},
|
||||
"updatedAt": 1785592528432
|
||||
"updatedAt": 1785748044193
|
||||
},
|
||||
{
|
||||
"issueRef": "#123",
|
||||
@ -1706,6 +1666,162 @@
|
||||
"agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
},
|
||||
"updatedAt": 1785669830933
|
||||
},
|
||||
{
|
||||
"issueRef": "#131",
|
||||
"path": "131",
|
||||
"title": "Configurer l'effort par agent avec presets adaptatifs selon le profil AI",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"createdBy": {
|
||||
"kind": "agent",
|
||||
"agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
},
|
||||
"updatedAt": 1785748044208
|
||||
},
|
||||
{
|
||||
"issueRef": "#132",
|
||||
"path": "132",
|
||||
"title": "Ajouter un outil MCP IdeA pour éditer le contexte projet global",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"createdBy": {
|
||||
"kind": "agent",
|
||||
"agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
},
|
||||
"updatedAt": 1785748044221
|
||||
},
|
||||
{
|
||||
"issueRef": "#133",
|
||||
"path": "133",
|
||||
"title": "Plugins: contrat de service des assets idea-plugin:// (multi-fichiers ESM) & confinement",
|
||||
"status": "qa",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"b4730d7f-c54d-4736-8a04-c6203aa2fd49"
|
||||
],
|
||||
"createdBy": {
|
||||
"kind": "agent",
|
||||
"agent_id": "b4730d7f-c54d-4736-8a04-c6203aa2fd49"
|
||||
},
|
||||
"updatedAt": 1785703664966
|
||||
},
|
||||
{
|
||||
"issueRef": "#134",
|
||||
"path": "134",
|
||||
"title": "Plugins: servir tout fichier confiné du package installé (fix racine multi-fichiers ESM)",
|
||||
"status": "qa",
|
||||
"priority": "critical",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"fe887179-933f-47d4-960f-c3b06827f86c"
|
||||
],
|
||||
"createdBy": {
|
||||
"kind": "agent",
|
||||
"agent_id": "b4730d7f-c54d-4736-8a04-c6203aa2fd49"
|
||||
},
|
||||
"updatedAt": 1785707290184
|
||||
},
|
||||
{
|
||||
"issueRef": "#135",
|
||||
"path": "135",
|
||||
"title": "Plugins: audit confinement install & désinstallation 100% propre",
|
||||
"status": "qa",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"ab328d90-c307-4771-a3b6-6c56089c8506"
|
||||
],
|
||||
"createdBy": {
|
||||
"kind": "agent",
|
||||
"agent_id": "b4730d7f-c54d-4736-8a04-c6203aa2fd49"
|
||||
},
|
||||
"updatedAt": 1785708498776
|
||||
},
|
||||
{
|
||||
"issueRef": "#136",
|
||||
"path": "136",
|
||||
"title": "SDK plugins: aligner doc/exemple sur le support multi-fichiers ESM",
|
||||
"status": "qa",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"8f7da528-58df-4315-97e9-0562230ecc19"
|
||||
],
|
||||
"createdBy": {
|
||||
"kind": "agent",
|
||||
"agent_id": "b4730d7f-c54d-4736-8a04-c6203aa2fd49"
|
||||
},
|
||||
"updatedAt": 1785708478849
|
||||
},
|
||||
{
|
||||
"issueRef": "#137",
|
||||
"path": "137",
|
||||
"title": "QA: validation end-to-end plugin multi-fichiers ESM (chargement + désinstallation propre)",
|
||||
"status": "open",
|
||||
"priority": "critical",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"ab328d90-c307-4771-a3b6-6c56089c8506"
|
||||
],
|
||||
"createdBy": {
|
||||
"kind": "agent",
|
||||
"agent_id": "b4730d7f-c54d-4736-8a04-c6203aa2fd49"
|
||||
},
|
||||
"updatedAt": 1785702681696
|
||||
},
|
||||
{
|
||||
"issueRef": "#138",
|
||||
"path": "138",
|
||||
"title": "SDK plugins: contrat de persistance plugin-owned hors projet et effacement total à la désinstallation",
|
||||
"status": "qa",
|
||||
"priority": "critical",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"b4730d7f-c54d-4736-8a04-c6203aa2fd49"
|
||||
],
|
||||
"createdBy": {
|
||||
"kind": "agent",
|
||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||
},
|
||||
"updatedAt": 1785703680849
|
||||
},
|
||||
{
|
||||
"issueRef": "#139",
|
||||
"path": "139",
|
||||
"title": "SDK plugins: aligner l’API publique et les exemples sur une persistance plugin-owned hors projet",
|
||||
"status": "qa",
|
||||
"priority": "critical",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"fe887179-933f-47d4-960f-c3b06827f86c",
|
||||
"8f7da528-58df-4315-97e9-0562230ecc19"
|
||||
],
|
||||
"createdBy": {
|
||||
"kind": "agent",
|
||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||
},
|
||||
"updatedAt": 1785709504027
|
||||
},
|
||||
{
|
||||
"issueRef": "#140",
|
||||
"path": "140",
|
||||
"title": "[Bug] je ne peux pas editer le context projet d'un agent a la main",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [
|
||||
"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||
],
|
||||
"createdBy": {
|
||||
"kind": "user"
|
||||
},
|
||||
"updatedAt": 1785760572251
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -2375,4 +2375,46 @@ pub enum ScheduledTask {
|
||||
|
||||
(1) activation réelle du seam LLM (non activé, défaut heuristique, contrat ADR LS5) ; (2) balayage périodique de rotation (idempotent, non câblé) ; (3) discordance D19-4 vs `.gitignore` sur `.ideai/conversations/` (à trancher Git/Main) ; (4) intégration MCP e2e UX ; (5) évolutions multi-fenêtres du registre de sessions ; (6) auto-update mémoire/contexte *en cours* de session. Détail : `docs/LS8` §7.
|
||||
|
||||
## 22. Plugins — service des assets multi-fichiers & persistance plugin-owned (cadrage 2026-08-02, tickets #133 / #138)
|
||||
|
||||
> Débloque l'implémentation de #134/#135 (asset serving + confinement) et #139 (storage plugin-owned). Contexte : bug diagnostiqué — un plugin ESM composé de plusieurs fichiers (`dist/index.js` important `./constants.js`) casse au chargement avec `Importing a module script failed.` car le protocole `idea-plugin://` ne sert que 3 chemins nommés. En creusant le même chantier, un second trou est apparu : le SDK et son exemple de référence font persister l'état **interne** du plugin (`hello-plugin.txt`, `hello-plugin.json`) sous `.ideai/` du projet ouvert, ce qui pollue le repo utilisateur et survit à la désinstallation. Les deux décisions ci-dessous sont indépendantes mais partagent la même frontière de fond : *ce qui appartient au plugin ne doit jamais fuiter dans le projet, et doit disparaître intégralement à la désinstallation*.
|
||||
|
||||
### 22.1 #133 — Contrat de service des assets `idea-plugin://` (multi-fichiers ESM)
|
||||
|
||||
**Constat.** `asset_allowed` (`crates/app-tauri/src/plugins.rs:504-536`) vérifie déjà, avant toute décision : (a) l'entrée registre existe et `lifecycle_state.is_runtime_active()`, (b) `entry.content_hash == hash` de l'URL (intégrité du **package entier**), et le confinement de chemin (`target.starts_with(&root)` après `canonicalize`, lignes 467-483) est appliqué **après** `asset_allowed` sur toute requête autorisée. Une fois ces trois garanties posées, la fonction restreint encore le service au triplet `declared_main || declared_icon || rel.starts_with("assets/")` — un import ESM relatif sur un quatrième fichier (`./constants.js`, `./core/x.js`) est donc rejeté 403 alors que le fichier appartient au même package déjà intégralement vérifié.
|
||||
|
||||
**Décision.** Cette restriction par fichier n'ajoute aucune garantie de sécurité réelle : le modèle de menace est fixé par `content_hash` à l'installation (#135 audite ce chemin) — si le package est compromis, l'attaquant contrôle déjà `main` (donc l'exécution), peu importe quels fichiers *siblings* on l'autorise à récupérer ensuite. Restreindre le service à 3 chemins nommés casse des graphes de modules ESM légitimes sans arrêter quoi que ce soit que hash+confinement n'arrêtent pas déjà. **`asset_allowed` doit donc autoriser tout chemin relatif dès lors que les trois gardes déjà en place (registre actif, hash de contenu, confinement canonicalize) sont satisfaites — le manifeste (`validator.validate`) reste appelé comme garde d'intégrité globale mais cesse de gater le service fichier par fichier.**
|
||||
|
||||
Hors périmètre, figé : aucune résolution `node_modules`/bare specifiers. Le protocole ne fait que du service de fichier confiné, jamais de résolution de module Node. Un plugin avec des dépendances tierces les bundle ou les vendore en chemins relatifs — à son choix, jamais une obligation d'IdeA.
|
||||
|
||||
Contrat de confinement/désinstallation (formalisé, déjà vrai en pratique, à garder invariant) : la racine servie est exclusivement `app_data/plugins/installed/<pluginId>/` ; aucune install/serve ne peut jamais écrire ou exposer un chemin en dehors du project root ou de `.ideai/` de l'utilisateur — le plugin n'est jamais un citoyen du repo. Désinstallation = suppression complète de `installed/<id>` + entrée registre, zéro résidu (audité en #135). Ce point est distinct de l'état *propre* au plugin (§22.2), qui vit dans un répertoire frère, pas dans `installed/`.
|
||||
|
||||
**Débloque #134** : remplacer la dernière ligne de `asset_allowed` —
|
||||
```rust
|
||||
Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/"))
|
||||
```
|
||||
— par une autorisation qui ne dépend plus de `declared_main`/`declared_icon`/du préfixe `assets/`, uniquement des gardes déjà calculées plus haut dans la fonction (entrée trouvée + `is_runtime_active()` + `content_hash == hash`). Le confinement canonicalize en aval (467-483) reste inchangé et continue de protéger contre toute évasion de racine. Tests de non-régression attendus sur path traversal et hash/lifecycle invalides (déjà spécifiés dans #134).
|
||||
|
||||
**Débloque #135** : le périmètre d'audit (écriture confinée à l'install, désinstallation 100%) est celui décrit ci-dessus ; #135 vérifie que `RelativePath::new` (rejette déjà `..` et absolu, `crates/domain/src/plugin.rs`) est bien appliqué côté install, pas seulement côté serve.
|
||||
|
||||
### 22.2 #138 — Persistance plugin-owned hors projet & purge à la désinstallation
|
||||
|
||||
**Constat.** `sdk/IdeaSDK/src/runtime.ts` déclare déjà `ActivateContext.storage?: PluginStorage` avec `get/set/delete` clé-valeur JSON-serializable, et l'exemple `hello-plugin` l'utilise (`ctx.storage?.get<string>("helloPlugin.ownerAgentId")`, `src/index.ts`). Mais **ce champ n'est jamais peuplé** : `frontend/src/plugins/runtime/loader.ts` ne câble que `logger`, `subscriptions`, `services` (≈ lignes 249-256) — `ctx.storage` vaut toujours `undefined` à l'exécution, silencieusement. Côté Rust, aucun port ni commande n'existe pour cette primitive (`grep PluginStorage crates/` → rien). Faute d'API réelle, l'exemple de référence détourne `ctx.services.workspace`/`ctx.services.config` pour écrire son état interne (compteurs `launches`, flag `enabled`) sous `.ideai/hello-plugin.txt` et `.ideai/hello-plugin.json` — exactement le pattern que le SDK doit cesser d'enseigner par défaut.
|
||||
|
||||
**Décision — deux familles de données, jamais mélangées :**
|
||||
- **Project-owned** : fichiers du workspace que le plugin modifie *volontairement et explicitement* pour l'utilisateur/le projet (ex. générer un fichier de config réel du projet). Reste sur `ctx.services.workspace.*` / `ctx.services.config.*`, dans le sandbox projet existant (`RelativePath`, confiné au project root). Ce chemin n'est pas fautif en soi — il est fautif quand il sert à stocker de l'état *interne* du plugin.
|
||||
- **Plugin-owned** : préférences, cache, dernière sélection, index interne, config interne — tout ce qui n'a de sens que pour le plugin lui-même. Ne doit **jamais** vivre dans le project root ni sous `.ideai/`. Vit sous app data, dans un répertoire **frère** de `plugins/installed/<id>/` : `app_data/plugins/data/<pluginId>/`. Séparé de `installed/` pour que réinstall/mise à jour du package (qui peut re-écrire `installed/<id>/` en entier) ne touche jamais aux données de l'utilisateur, et pour que la désinstallation ait une deuxième racine univoque à purger.
|
||||
|
||||
**API canonique : `ctx.storage` seul, pas de second API document.** `ctx.storage.set(key, value)` avec des valeurs JSON couvre déjà le besoin de document structuré que `ctx.services.config` était détourné pour servir — ajouter une deuxième API "document structuré plugin-scopé" ferait doublon avec `ctx.storage` sans bénéfice. `ctx.services.config` reste réservé au project-owned (fichiers réels du projet que le plugin est explicitement chargé de gérer).
|
||||
|
||||
**Cycle de vie :**
|
||||
- Création/lecture : `ctx.storage.get/set/delete` proxie une commande Tauri (ex. `plugin_storage_get`/`plugin_storage_set`/`plugin_storage_delete`) qui lit/écrit un store scopé par `pluginId` sous `plugins/data/<pluginId>/` (forme de stockage — un fichier JSON unique ou un fichier par clé — laissée à l'implémentation de #139 ; la frontière de répertoire est le contrat figé, pas le format interne).
|
||||
- Suppression : `plugin_uninstall` (`crates/app-tauri/src/plugins.rs:142`) doit, en plus de la purge déjà couverte par #135 (`plugins/installed/<id>` + entrée registre), supprimer intégralement `plugins/data/<id>/`. Les fichiers project-owned que le plugin a écrits dans le workspace ne sont **jamais** touchés par l'uninstall — ce sont des données du projet, pas du plugin.
|
||||
|
||||
**Débloque #139** :
|
||||
1. Implémenter `ctx.storage` de bout en bout : port domaine + adapter infra scopés à `plugins/data/<pluginId>/`, commandes Tauri, câblage réel dans `loader.ts` (aujourd'hui absent), confinement identique en esprit à #133/#135 (jamais d'écriture hors `plugins/data/<pluginId>/`).
|
||||
2. Réaligner `hello-plugin` : les compteurs internes (`launches`, `enabled`, `ownerAgentId`) sont conceptuellement plugin-owned → migrer vers `ctx.storage`. Garder au plus un exemple clairement étiqueté "fichier projet réel" via `workspace`/`config` pour montrer que ce chemin existe toujours, sans qu'il reste l'exemple par défaut de persistance interne.
|
||||
3. `sdk/IdeaSDK/README.md` : section « Structured Config Documents » à corriger pour ne plus donner `.ideai/hello-plugin.json` comme exemple d'état interne — remplacer par un exemple `ctx.storage`, et documenter noir sur blanc la séparation project-owned/plugin-owned de ce §22.2.
|
||||
4. Preuve requise : test de purge (installer, écrire via `ctx.storage`, désinstaller, vérifier `plugins/data/<id>/` disparu) et absence de tout chemin `.ideai/...` dans les exemples SDK par défaut.
|
||||
|
||||
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*
|
||||
|
||||
51
Cargo.lock
generated
51
Cargo.lock
generated
@ -102,12 +102,22 @@ dependencies = [
|
||||
"domain",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@ -744,6 +754,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
@ -1996,6 +2017,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -6236,8 +6258,37 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap 2.14.0",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
@ -25,9 +25,9 @@ use application::{
|
||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
|
||||
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
||||
UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
|
||||
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
||||
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput,
|
||||
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||
};
|
||||
use backend::stream::OutputSink;
|
||||
@ -47,28 +47,28 @@ use crate::dto::{
|
||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||
ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto,
|
||||
OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto,
|
||||
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
||||
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
||||
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
||||
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
||||
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
|
||||
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
||||
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||
UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
|
||||
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
|
||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||
WriteTerminalRequestDto,
|
||||
@ -538,6 +538,29 @@ pub async fn update_agent_permissions(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `update_agent_effort` — set or clear one agent's per-agent effort override.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] on invalid ids or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn update_agent_effort(
|
||||
request: UpdateAgentEffortRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AgentDto, ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.update_agent_effort
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project,
|
||||
agent_id,
|
||||
effort: request.effort,
|
||||
})
|
||||
.await
|
||||
.map(|out| AgentDto::from_agent(out.agent))
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `resolve_agent_permissions` — resolve project defaults plus agent override.
|
||||
///
|
||||
/// # Errors
|
||||
@ -546,14 +569,14 @@ pub async fn update_agent_permissions(
|
||||
pub async fn resolve_agent_permissions(
|
||||
request: ResolveAgentPermissionsRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<EffectivePermissionsDto>, ErrorDto> {
|
||||
) -> Result<ResolveAgentPermissionsResponseDto, ErrorDto> {
|
||||
let project = resolve_project(&request.project_id, &state).await?;
|
||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||
state
|
||||
.resolve_agent_permissions
|
||||
.execute(ResolveAgentPermissionsInput { project, agent_id })
|
||||
.await
|
||||
.map(|out| out.effective.map(EffectivePermissionsDto))
|
||||
.map(ResolveAgentPermissionsResponseDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
@ -3308,12 +3331,10 @@ pub async fn create_skill(
|
||||
.create_skill
|
||||
.execute(CreateSkillInput {
|
||||
name: request.name,
|
||||
// Description is set via the dedicated frontend field (T6); the create
|
||||
// path stays None for now so the affordance falls back to the body's
|
||||
// first line (see `Skill::effective_description`).
|
||||
description: None,
|
||||
description: request.description,
|
||||
content: request.content,
|
||||
scope: request.scope,
|
||||
kind: request.kind,
|
||||
project_root: project.root,
|
||||
})
|
||||
.await
|
||||
|
||||
@ -257,6 +257,7 @@ pub fn run() {
|
||||
commands::get_project_permissions,
|
||||
commands::update_project_permissions,
|
||||
commands::update_agent_permissions,
|
||||
commands::update_agent_effort,
|
||||
commands::resolve_agent_permissions,
|
||||
commands::get_project_system_permissions,
|
||||
commands::update_project_system_permissions,
|
||||
@ -401,6 +402,9 @@ pub fn run() {
|
||||
plugins::plugin_workspace_read_binary,
|
||||
plugins::plugin_workspace_write_text,
|
||||
plugins::plugin_workspace_write_binary,
|
||||
plugins::plugin_storage_get,
|
||||
plugins::plugin_storage_set,
|
||||
plugins::plugin_storage_delete,
|
||||
plugins::plugin_workspace_list_dir,
|
||||
plugins::plugin_workspace_stat,
|
||||
plugins::plugin_query_project_structure,
|
||||
@ -438,6 +442,9 @@ fn plugin_workspace_invoke_handler<R: tauri::Runtime>(
|
||||
plugins::plugin_workspace_read_binary,
|
||||
plugins::plugin_workspace_write_text,
|
||||
plugins::plugin_workspace_write_binary,
|
||||
plugins::plugin_storage_get,
|
||||
plugins::plugin_storage_set,
|
||||
plugins::plugin_storage_delete,
|
||||
plugins::plugin_workspace_list_dir,
|
||||
plugins::plugin_workspace_stat,
|
||||
plugins::plugin_query_project_structure,
|
||||
@ -1030,6 +1037,61 @@ mod tests {
|
||||
std::fs::remove_dir_all(app_data).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_plugins_storage_commands_are_registered_in_tauri_invoke_handler() {
|
||||
let app_data = test_app_data_dir("plugin-storage-commands");
|
||||
let app = mock_builder()
|
||||
.manage(crate::state::AppState::build(app_data.clone()))
|
||||
.invoke_handler(plugin_workspace_invoke_handler())
|
||||
.build(mock_context(noop_assets()))
|
||||
.expect("mock app builds");
|
||||
let webview = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
|
||||
.build()
|
||||
.expect("mock webview builds");
|
||||
|
||||
let get_err = invoke_plugin_command(
|
||||
&webview,
|
||||
"plugin_storage_get",
|
||||
json!({
|
||||
"input": {
|
||||
"pluginId": "dev.acme.missing",
|
||||
"key": "helloPlugin.launches"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect_err("missing plugin must surface through the registered command");
|
||||
assert_eq!(get_err["code"], "NOT_FOUND");
|
||||
|
||||
let set_err = invoke_plugin_command(
|
||||
&webview,
|
||||
"plugin_storage_set",
|
||||
json!({
|
||||
"input": {
|
||||
"pluginId": "dev.acme.missing",
|
||||
"key": "helloPlugin.launches",
|
||||
"value": 1
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect_err("missing plugin must surface through the registered command");
|
||||
assert_eq!(set_err["code"], "NOT_FOUND");
|
||||
|
||||
let delete_err = invoke_plugin_command(
|
||||
&webview,
|
||||
"plugin_storage_delete",
|
||||
json!({
|
||||
"input": {
|
||||
"pluginId": "dev.acme.missing",
|
||||
"key": "helloPlugin.launches"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect_err("missing plugin must surface through the registered command");
|
||||
assert_eq!(delete_err["code"], "NOT_FOUND");
|
||||
|
||||
std::fs::remove_dir_all(app_data).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_plugins_command_task_commands_are_registered_in_tauri_invoke_handler() {
|
||||
let app_data = test_app_data_dir("plugin-task-commands");
|
||||
|
||||
@ -10,11 +10,11 @@ use backend::dto::{
|
||||
PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto,
|
||||
PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto,
|
||||
PluginProjectStructureQueryDto, PluginReviewDto, PluginRunCommandDto,
|
||||
PluginRuntimeContributionCatalogDto, PluginTaskDto, PluginTaskStatusDto,
|
||||
PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto, PluginUninstallResultDto,
|
||||
PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto, PluginWorkspacePathDto,
|
||||
PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, PluginWorkspaceWriteBinaryDto,
|
||||
PluginWorkspaceWriteTextDto, ReviewPluginPackageDto,
|
||||
PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto,
|
||||
PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto,
|
||||
PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto,
|
||||
PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto,
|
||||
PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReviewPluginPackageDto,
|
||||
};
|
||||
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
||||
use domain::{PluginId, RelativePath};
|
||||
@ -216,6 +216,45 @@ pub async fn plugin_workspace_write_binary(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Reads a plugin-owned JSON storage value.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_storage_get(
|
||||
input: PluginStorageGetDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<serde_json::Value>, ErrorDto> {
|
||||
state
|
||||
.plugin_storage_access
|
||||
.get(input.into())
|
||||
.await
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Writes a plugin-owned JSON storage value.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_storage_set(
|
||||
input: PluginStorageSetDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
state
|
||||
.plugin_storage_access
|
||||
.set(input.into())
|
||||
.await
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Deletes a plugin-owned JSON storage value.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_storage_delete(
|
||||
input: PluginStorageGetDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, ErrorDto> {
|
||||
state
|
||||
.plugin_storage_access
|
||||
.delete(input.into())
|
||||
.await
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// Lists a workspace directory for the public plugin API.
|
||||
#[tauri::command]
|
||||
pub async fn plugin_workspace_list_dir(
|
||||
@ -504,7 +543,7 @@ fn plugin_asset_response_builder(status: StatusCode) -> http::response::Builder
|
||||
async fn asset_allowed(
|
||||
plugin_id: &PluginId,
|
||||
hash: &str,
|
||||
rel: &RelativePath,
|
||||
_rel: &RelativePath,
|
||||
registry_store: &dyn PluginRegistryStore,
|
||||
package_store: &dyn PluginPackageStore,
|
||||
validator: &dyn PluginManifestValidator,
|
||||
@ -527,12 +566,10 @@ async fn asset_allowed(
|
||||
.read_manifest(&package)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
let manifest = validator
|
||||
validator
|
||||
.validate(&manifest_bytes.bytes, &package)
|
||||
.map_err(|e| (StatusCode::FORBIDDEN, e.to_string()))?;
|
||||
let declared_icon = manifest.icon.as_ref() == Some(rel);
|
||||
let declared_main = manifest.main == *rel;
|
||||
Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/"))
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn block_on_protocol_future<F: Future>(future: F) -> F::Output {
|
||||
@ -717,6 +754,7 @@ mod tests {
|
||||
icon: None,
|
||||
trust_level: PluginTrustLevel::Full,
|
||||
capabilities: Vec::new(),
|
||||
activation_scope: domain::PluginActivationScope::default(),
|
||||
contributes: PluginContributionSet::default(),
|
||||
})
|
||||
}
|
||||
@ -768,6 +806,202 @@ mod tests {
|
||||
assert!(err.1.contains("broken manifest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_asset_response_serves_confined_file_not_declared_in_manifest() {
|
||||
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
|
||||
let hash = ContentHash::new("abc123").unwrap();
|
||||
let registry = FakeRegistry {
|
||||
registry: Mutex::new(PluginRegistry {
|
||||
version: 1,
|
||||
plugins: vec![PluginRegistryEntry {
|
||||
id: plugin_id.clone(),
|
||||
lifecycle_state: PluginLifecycleState::Enabled,
|
||||
source: PluginInstallSource::Directory {
|
||||
path_label: "/source/plugin".to_owned(),
|
||||
},
|
||||
content_hash: hash.clone(),
|
||||
restart_required: false,
|
||||
error: None,
|
||||
}],
|
||||
}),
|
||||
};
|
||||
let packages = FakePackages {
|
||||
manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(),
|
||||
};
|
||||
let validator = AcceptingValidator {
|
||||
plugin_id: plugin_id.clone(),
|
||||
main: RelativePath::new("dist/index.js").unwrap(),
|
||||
};
|
||||
let app_data = test_app_data_dir("plugin-asset-undeclared");
|
||||
let rel = RelativePath::new("dist/constants.js").unwrap();
|
||||
let target = app_data
|
||||
.join("plugins")
|
||||
.join("installed")
|
||||
.join(plugin_id.as_str())
|
||||
.join(rel.as_str());
|
||||
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
std::fs::write(&target, "export const answer = 42;").unwrap();
|
||||
let request = http::Request::builder()
|
||||
.uri(format!(
|
||||
"idea-plugin://{}/current/{}/{}",
|
||||
plugin_id.as_str(),
|
||||
hash.as_str(),
|
||||
rel.as_str()
|
||||
))
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
|
||||
let response =
|
||||
plugin_asset_response_with_stores(&app_data, request, ®istry, &packages, &validator)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.body(), b"export const answer = 42;");
|
||||
|
||||
std::fs::remove_dir_all(app_data).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_asset_response_rejects_invalid_hash_or_inactive_plugin() {
|
||||
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
|
||||
let hash = ContentHash::new("abc123").unwrap();
|
||||
let rel = RelativePath::new("dist/constants.js").unwrap();
|
||||
let registry = FakeRegistry {
|
||||
registry: Mutex::new(PluginRegistry {
|
||||
version: 1,
|
||||
plugins: vec![PluginRegistryEntry {
|
||||
id: plugin_id.clone(),
|
||||
lifecycle_state: PluginLifecycleState::Enabled,
|
||||
source: PluginInstallSource::Directory {
|
||||
path_label: "/source/plugin".to_owned(),
|
||||
},
|
||||
content_hash: hash.clone(),
|
||||
restart_required: false,
|
||||
error: None,
|
||||
}],
|
||||
}),
|
||||
};
|
||||
let packages = FakePackages {
|
||||
manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(),
|
||||
};
|
||||
let validator = AcceptingValidator {
|
||||
plugin_id: plugin_id.clone(),
|
||||
main: RelativePath::new("dist/index.js").unwrap(),
|
||||
};
|
||||
let app_data = test_app_data_dir("plugin-asset-rejected");
|
||||
let target = app_data
|
||||
.join("plugins")
|
||||
.join("installed")
|
||||
.join(plugin_id.as_str())
|
||||
.join(rel.as_str());
|
||||
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
std::fs::write(&target, "export const answer = 42;").unwrap();
|
||||
let invalid_hash_request = http::Request::builder()
|
||||
.uri(format!(
|
||||
"idea-plugin://{}/current/deadbeef/{}",
|
||||
plugin_id.as_str(),
|
||||
rel.as_str()
|
||||
))
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
|
||||
let invalid_hash_err = plugin_asset_response_with_stores(
|
||||
&app_data,
|
||||
invalid_hash_request,
|
||||
®istry,
|
||||
&packages,
|
||||
&validator,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(invalid_hash_err.0, StatusCode::FORBIDDEN);
|
||||
|
||||
registry.registry.lock().unwrap().plugins[0].lifecycle_state =
|
||||
PluginLifecycleState::Disabled;
|
||||
let inactive_request = http::Request::builder()
|
||||
.uri(format!(
|
||||
"idea-plugin://{}/current/{}/{}",
|
||||
plugin_id.as_str(),
|
||||
hash.as_str(),
|
||||
rel.as_str()
|
||||
))
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
|
||||
let inactive_err = plugin_asset_response_with_stores(
|
||||
&app_data,
|
||||
inactive_request,
|
||||
®istry,
|
||||
&packages,
|
||||
&validator,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(inactive_err.0, StatusCode::FORBIDDEN);
|
||||
|
||||
std::fs::remove_dir_all(app_data).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_asset_response_rejects_symlink_path_traversal() {
|
||||
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
|
||||
let hash = ContentHash::new("abc123").unwrap();
|
||||
let rel = RelativePath::new("assets/leak.txt").unwrap();
|
||||
let registry = FakeRegistry {
|
||||
registry: Mutex::new(PluginRegistry {
|
||||
version: 1,
|
||||
plugins: vec![PluginRegistryEntry {
|
||||
id: plugin_id.clone(),
|
||||
lifecycle_state: PluginLifecycleState::Enabled,
|
||||
source: PluginInstallSource::Directory {
|
||||
path_label: "/source/plugin".to_owned(),
|
||||
},
|
||||
content_hash: hash.clone(),
|
||||
restart_required: false,
|
||||
error: None,
|
||||
}],
|
||||
}),
|
||||
};
|
||||
let packages = FakePackages {
|
||||
manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(),
|
||||
};
|
||||
let validator = AcceptingValidator {
|
||||
plugin_id: plugin_id.clone(),
|
||||
main: RelativePath::new("dist/index.js").unwrap(),
|
||||
};
|
||||
let app_data = test_app_data_dir("plugin-asset-traversal");
|
||||
let plugin_root = app_data
|
||||
.join("plugins")
|
||||
.join("installed")
|
||||
.join(plugin_id.as_str());
|
||||
let assets = plugin_root.join("assets");
|
||||
std::fs::create_dir_all(&assets).unwrap();
|
||||
let outside = app_data.join("outside.txt");
|
||||
std::fs::write(&outside, "secret").unwrap();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&outside, assets.join("leak.txt")).unwrap();
|
||||
#[cfg(windows)]
|
||||
std::os::windows::fs::symlink_file(&outside, assets.join("leak.txt")).unwrap();
|
||||
let request = http::Request::builder()
|
||||
.uri(format!(
|
||||
"idea-plugin://{}/current/{}/{}",
|
||||
plugin_id.as_str(),
|
||||
hash.as_str(),
|
||||
rel.as_str()
|
||||
))
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
|
||||
let err =
|
||||
plugin_asset_response_with_stores(&app_data, request, ®istry, &packages, &validator)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.0, StatusCode::FORBIDDEN);
|
||||
assert!(err.1.contains("escapes plugin root"));
|
||||
|
||||
std::fs::remove_dir_all(app_data).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_asset_response_includes_cors_headers_for_dynamic_import() {
|
||||
let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
use app_tauri_lib::dto::{
|
||||
parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto,
|
||||
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto,
|
||||
UpdateAgentContextRequestDto,
|
||||
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||
};
|
||||
use application::AppError;
|
||||
use application::{
|
||||
@ -17,7 +17,7 @@ use application::{
|
||||
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
|
||||
use domain::ports::ConversationDetails;
|
||||
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
use domain::{Agent, AgentOrigin, ProjectPath, SkillKind};
|
||||
use domain::{Agent, AgentOrigin, EffortSelection, ProjectPath, SkillKind};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -60,6 +60,15 @@ fn agent_dto_serialises_camelcase() {
|
||||
assert!(v.get("profile_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_dto_serialises_effort_tagged_shape_when_present() {
|
||||
let agent = make_agent(1, 2).with_effort(Some(EffortSelection::Preset("medium".to_owned())));
|
||||
let dto = AgentDto::from_agent(agent);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
assert_eq!(v["effort"], json!({"kind":"preset","value":"medium"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_list_dto_is_transparent_array() {
|
||||
let first = make_agent(1, 2);
|
||||
@ -73,6 +82,7 @@ fn agent_list_dto_is_transparent_array() {
|
||||
kind: SkillKind::Reference,
|
||||
}],
|
||||
}],
|
||||
effective_orchestrator: Some(first.id),
|
||||
};
|
||||
let dto = AgentListDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
@ -82,7 +92,9 @@ fn agent_list_dto_is_transparent_array() {
|
||||
assert_eq!(arr[0]["capabilities"][0]["name"], "review");
|
||||
assert_eq!(arr[0]["capabilities"][0]["description"], "Reviews changes");
|
||||
assert_eq!(arr[0]["capabilities"][0]["kind"], "reference");
|
||||
assert_eq!(arr[0]["isOrchestrator"], true);
|
||||
assert_eq!(arr[1]["capabilities"], json!([]));
|
||||
assert_eq!(arr[1]["isOrchestrator"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -139,6 +151,28 @@ fn update_agent_context_request_deserialises_camelcase() {
|
||||
assert_eq!(dto.content, "# Updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_agent_effort_request_deserialises_and_null_clears() {
|
||||
let raw = json!({
|
||||
"projectId": Uuid::from_u128(1).to_string(),
|
||||
"agentId": Uuid::from_u128(2).to_string(),
|
||||
"effort": {"kind": "custom", "value": "x-deep"}
|
||||
});
|
||||
let dto: UpdateAgentEffortRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(
|
||||
dto.effort,
|
||||
Some(EffortSelection::Custom("x-deep".to_owned()))
|
||||
);
|
||||
|
||||
let clear: UpdateAgentEffortRequestDto = serde_json::from_value(json!({
|
||||
"projectId": Uuid::from_u128(1).to_string(),
|
||||
"agentId": Uuid::from_u128(2).to_string(),
|
||||
"effort": null
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(clear.effort, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_agent_request_deserialises_camelcase() {
|
||||
let project_id = Uuid::from_u128(1).to_string();
|
||||
|
||||
@ -49,6 +49,7 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() {
|
||||
icon_url: None,
|
||||
content_hash: "abc".to_owned(),
|
||||
capabilities: vec![PluginCapability::Ui, PluginCapability::Tooling],
|
||||
activation_scope: domain::PluginActivationScope::Project,
|
||||
contributes: PluginContributionSet::default(),
|
||||
}],
|
||||
};
|
||||
@ -63,6 +64,7 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() {
|
||||
value["plugins"][0]["capabilities"],
|
||||
serde_json::json!(["ui", "tooling"])
|
||||
);
|
||||
assert_eq!(value["plugins"][0]["activationScope"], "project");
|
||||
assert!(value["plugins"][0]["contributes"]["menus"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
|
||||
@ -12,6 +12,7 @@ thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
# Resolves the OpenCode cache dir (`~/.cache/opencode/models.json`) for the
|
||||
# dynamic provider catalogue (ticket #92 follow-up). See
|
||||
|
||||
@ -21,8 +21,8 @@ use domain::ports::{
|
||||
SpawnSpec, StructuredProviderLaunchPolicy, SystemPermissionStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE,
|
||||
CODEX_CODE_MODE_FEATURES_TOML,
|
||||
resolve_effort, EffortSelection, McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter,
|
||||
CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML,
|
||||
};
|
||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||
use domain::{
|
||||
@ -123,8 +123,9 @@ pub struct InjectedLiveRow {
|
||||
pub struct ResolvedAssignedSkill {
|
||||
/// Agent-facing snapshot metadata.
|
||||
pub snapshot: domain::AssignedSkillSnapshot,
|
||||
/// Full Markdown body, used only on non-MCP profiles that cannot call
|
||||
/// `idea_skill_read`.
|
||||
/// Full Markdown body kept in the effective snapshot so authorized lazy-read
|
||||
/// paths can use the same resolution; convention-file rendering exposes only
|
||||
/// bounded affordances.
|
||||
pub content: MarkdownDoc,
|
||||
}
|
||||
|
||||
@ -140,7 +141,9 @@ pub struct EffectiveAgentContext {
|
||||
pub capabilities: OrchestrationCapabilitySnapshot,
|
||||
/// Compact agent capability affordances resolved by [`ResolveAgentCapabilities`].
|
||||
pub agent_capabilities: Vec<AgentCapability>,
|
||||
/// Resolved assigned skill bodies for fallback non-MCP injection.
|
||||
/// Resolved assigned skill bodies. These are not dumped into provider context
|
||||
/// by default; agents receive compact affordances and load details through the
|
||||
/// active IdeA surface.
|
||||
pub assigned_skills: Vec<ResolvedAssignedSkill>,
|
||||
/// Project-memory recall selected for this launch.
|
||||
pub memory: Vec<MemoryIndexEntry>,
|
||||
@ -281,6 +284,8 @@ pub struct ListAgentsOutput {
|
||||
pub agents: Vec<Agent>,
|
||||
/// Resolved discoverable capabilities per agent.
|
||||
pub capabilities: Vec<ListedAgentCapabilities>,
|
||||
/// The manifest's resolved orchestrator.
|
||||
pub effective_orchestrator: Option<AgentId>,
|
||||
}
|
||||
|
||||
/// Resolved capabilities for one listed agent.
|
||||
@ -301,6 +306,8 @@ pub struct AgentDiscoveryEntry {
|
||||
pub agent: Agent,
|
||||
/// Resolved capability affordances.
|
||||
pub capabilities: Vec<AgentCapability>,
|
||||
/// Whether this agent is the project's current orchestrator.
|
||||
pub is_orchestrator: bool,
|
||||
}
|
||||
|
||||
impl ListAgentsOutput {
|
||||
@ -318,6 +325,7 @@ impl ListAgentsOutput {
|
||||
.map(|entry| entry.capabilities.clone())
|
||||
.unwrap_or_default();
|
||||
AgentDiscoveryEntry {
|
||||
is_orchestrator: self.effective_orchestrator == Some(agent.id),
|
||||
agent,
|
||||
capabilities,
|
||||
}
|
||||
@ -390,6 +398,7 @@ impl ListAgents {
|
||||
Ok(ListAgentsOutput {
|
||||
agents,
|
||||
capabilities,
|
||||
effective_orchestrator: manifest.effective_orchestrator(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -480,6 +489,74 @@ impl UpdateAgentContext {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UpdateAgentEffort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`UpdateAgentEffort::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAgentEffortInput {
|
||||
/// The owning project.
|
||||
pub project: Project,
|
||||
/// The agent whose effort override changes.
|
||||
pub agent_id: AgentId,
|
||||
/// `None` clears the override, falling back to the profile default.
|
||||
pub effort: Option<EffortSelection>,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateAgentEffort::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAgentEffortOutput {
|
||||
/// The updated agent.
|
||||
pub agent: Agent,
|
||||
}
|
||||
|
||||
/// Sets or clears an agent's per-agent effort override in the manifest.
|
||||
///
|
||||
/// The change applies at the agent's next launch; live sessions are not mutated.
|
||||
pub struct UpdateAgentEffort {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
}
|
||||
|
||||
impl UpdateAgentEffort {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
|
||||
Self { contexts }
|
||||
}
|
||||
|
||||
/// Executes the update.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the agent is unknown to the project,
|
||||
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateAgentEffortInput,
|
||||
) -> Result<UpdateAgentEffortOutput, AppError> {
|
||||
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let entry = manifest
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.agent_id == input.agent_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
|
||||
let agent = entry
|
||||
.to_agent()
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?
|
||||
.with_effort(input.effort);
|
||||
*entry = ManifestEntry::from_agent(&agent);
|
||||
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
Ok(UpdateAgentEffortOutput { agent })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChangeAgentProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -2017,6 +2094,12 @@ impl LaunchAgent {
|
||||
network_permission,
|
||||
&input.project.root,
|
||||
);
|
||||
let resolved_effort = resolve_effort(
|
||||
profile.model_reasoning_effort.as_deref(),
|
||||
agent.effort.as_ref(),
|
||||
);
|
||||
let mut launch_profile = profile.clone();
|
||||
launch_profile.model_reasoning_effort = resolved_effort;
|
||||
|
||||
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
|
||||
// L'intention est explicite sur le launcher : les cellules humaines peuvent
|
||||
@ -2053,7 +2136,7 @@ impl LaunchAgent {
|
||||
factory.as_ref(),
|
||||
structured,
|
||||
&agent,
|
||||
&profile,
|
||||
&launch_profile,
|
||||
&prepared,
|
||||
&run_dir,
|
||||
&session_plan,
|
||||
@ -2083,7 +2166,7 @@ impl LaunchAgent {
|
||||
// CODEX_HOME isolé. Passer le modèle du profil sur l'argv garantit que le
|
||||
// lancement interactif respecte l'édition IdeA, comme le chemin structuré
|
||||
// le fait déjà dans `CodexExecSession`.
|
||||
append_codex_pty_model_overrides(&profile, &mut spec);
|
||||
append_codex_pty_model_overrides(&launch_profile, &mut spec);
|
||||
|
||||
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
|
||||
let handle = self.pty.spawn(spec.clone(), size).await?;
|
||||
@ -3440,8 +3523,7 @@ fn append_block(input: &str, block: &str) -> String {
|
||||
/// Composes the convention file IdeA writes into an agent's run directory: an
|
||||
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
|
||||
/// so it must be told where to work), the IdeA orchestration contract, the
|
||||
/// agent's persona `.md`, then the bodies of its assigned `skills` under a
|
||||
/// `# Skills` section (ARCHITECTURE §14.2).
|
||||
/// agent's persona `.md` (ARCHITECTURE §14.2).
|
||||
///
|
||||
/// A short skill-awareness paragraph is always injected in the orchestration
|
||||
/// block (followed by the auto-memory harvest directive, Lot E1): it explains that
|
||||
@ -3451,18 +3533,12 @@ fn append_block(input: &str, block: &str) -> String {
|
||||
/// protocol). This awareness deliberately does not inject unassigned skill bodies;
|
||||
/// assignment remains the context boundary.
|
||||
///
|
||||
/// On top of that awareness, the assigned skills surface in one of two ways
|
||||
/// depending on the agent's **surface** (feature « skills à la MCP »), always in
|
||||
/// the given (manifest) order — making the output deterministic:
|
||||
/// - **MCP mode** (`mcp_enabled`): a high-altitude `# Skills disponibles` section,
|
||||
/// right after the orchestration block, listing each as
|
||||
/// `**<name>** — <effective description> (<kind>)` (affordances only, *no body*), with
|
||||
/// prose pointing to `idea_skill_read` to load a body on demand. Respects the
|
||||
/// altitude: the capability is exposed, never the skill content.
|
||||
/// - **Non-MCP mode**: the legacy `# Skills` section dumping each body in full
|
||||
/// under a `##` header carrying its name (unchanged — zero regression).
|
||||
/// When `skills` is empty both sections are omitted entirely, so an agent with no
|
||||
/// skills gets exactly the previous document.
|
||||
/// On top of that awareness, assigned skills surface as a high-altitude
|
||||
/// `# Skills disponibles` section right after the orchestration block, listing
|
||||
/// each as `**<name>** — <effective description> (<kind>)` (affordances only,
|
||||
/// *no body*). This bounded surface is shared by MCP and non-MCP profiles; only
|
||||
/// the instruction for loading details is adapted to the active runtime surface.
|
||||
/// When `skills` is empty the section is omitted entirely.
|
||||
///
|
||||
/// The project's `memory` recall (index/hooks, ARCHITECTURE §14.5.4) is appended as
|
||||
/// a `# Mémoire projet` section — one `- [Title](slug.md) — hook (type)` line per
|
||||
@ -3576,22 +3652,28 @@ pub(crate) fn compose_convention_file(
|
||||
out.push_str(memory_awareness());
|
||||
out.push_str("---\n\n");
|
||||
|
||||
// Skills « à la MCP » (feature skill-awareness) : à HAUTE ALTITUDE, juste après
|
||||
// le bloc d'orchestration. On expose les skills assignés comme des **affordances
|
||||
// nommées+décrites** (et NON leur corps complet), à la manière des outils MCP,
|
||||
// pour que l'agent sache qu'ils existent et charge le détail à la demande via
|
||||
// `idea_skill_read`. Réservé au mode MCP (le mode sans MCP conserve l'ancien dump
|
||||
// du corps complet en fin de fichier, plus bas). Omis si zéro skill.
|
||||
if mcp_enabled && !effective.agent_capabilities.is_empty() {
|
||||
// Skills « capability-first » : à HAUTE ALTITUDE, juste après le bloc
|
||||
// d'orchestration. On expose les skills assignés comme des affordances
|
||||
// nommées+décrites (et NON leur corps complet), à la manière des outils MCP.
|
||||
if !effective.agent_capabilities.is_empty() {
|
||||
out.push_str("# Skills disponibles\n\n");
|
||||
out.push_str("Snapshot version: ");
|
||||
out.push_str(&effective.capabilities.version.to_string());
|
||||
out.push_str("\n\n");
|
||||
out.push_str(
|
||||
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
||||
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
|
||||
workflow déjà couvert par un skill, charge-le.\n\n",
|
||||
);
|
||||
if mcp_enabled {
|
||||
out.push_str(
|
||||
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
||||
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
|
||||
workflow déjà couvert par un skill, charge-le.\n\n",
|
||||
);
|
||||
} else {
|
||||
out.push_str(
|
||||
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
|
||||
détail, lis le fichier `.ideai/skills/md/<skill-id>.md` correspondant au \
|
||||
`skillId` assigné dans le manifeste — n'improvise pas un workflow déjà \
|
||||
couvert par un skill, charge-le.\n\n",
|
||||
);
|
||||
}
|
||||
for skill in &effective.agent_capabilities {
|
||||
out.push_str("**");
|
||||
out.push_str(&skill.name);
|
||||
@ -3616,24 +3698,6 @@ pub(crate) fn compose_convention_file(
|
||||
|
||||
out.push_str(effective.persona.as_str());
|
||||
|
||||
// MODE SANS MCP (exigence zéro régression, décision produit 4.2(b)) : on conserve
|
||||
// l'ancien dump du **corps complet** des skills en fin de fichier. En mode MCP, le
|
||||
// corps n'est PAS injecté ici (l'agent le charge à la demande via `idea_skill_read`,
|
||||
// cf. la section « # Skills disponibles » à haute altitude plus haut).
|
||||
if !effective.assigned_skills.is_empty() && !mcp_enabled {
|
||||
out.push_str("\n\n---\n\n# Skills\n");
|
||||
out.push_str("\nSnapshot version: ");
|
||||
out.push_str(&effective.capabilities.version.to_string());
|
||||
out.push('\n');
|
||||
for skill in &effective.assigned_skills {
|
||||
out.push_str("\n## ");
|
||||
out.push_str(&skill.snapshot.name);
|
||||
out.push_str("\n\n");
|
||||
out.push_str(skill.content.as_str());
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if !effective.memory.is_empty() {
|
||||
out.push_str("\n\n---\n\n# Mémoire projet\n\n");
|
||||
for entry in &effective.memory {
|
||||
@ -3891,7 +3955,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_appends_assigned_skills_in_order() {
|
||||
fn compose_convention_file_appends_assigned_skill_affordances_in_order() {
|
||||
let s = |n: u128, name: &str, body: &str| {
|
||||
Skill::new(
|
||||
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
||||
@ -3915,28 +3979,28 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
|
||||
// Both skill bodies present, after the persona.
|
||||
assert!(doc.contains("REFAC_BODY"));
|
||||
assert!(doc.contains("REVIEW_BODY"));
|
||||
// Both skill affordances are present, but bodies are not dumped.
|
||||
assert!(doc.contains("**refactor** — REFAC_BODY (workflow)"));
|
||||
assert!(doc.contains("**review** — REVIEW_BODY (workflow)"));
|
||||
assert!(!doc.contains("\n\nREFAC_BODY"));
|
||||
assert!(!doc.contains("\n\nREVIEW_BODY"));
|
||||
let awareness_at = doc.find("**Skills IdeA**").unwrap();
|
||||
let skills_at = doc.find("# Skills disponibles").unwrap();
|
||||
let persona_at = doc.find("# Persona").unwrap();
|
||||
let skills_at = doc.find("\n# Skills\n").unwrap();
|
||||
let refac_at = doc.find("REFAC_BODY").unwrap();
|
||||
let review_at = doc.find("REVIEW_BODY").unwrap();
|
||||
let refac_at = doc.find("**refactor**").unwrap();
|
||||
let review_at = doc.find("**review**").unwrap();
|
||||
assert!(
|
||||
awareness_at < persona_at,
|
||||
"skill awareness belongs to orchestration, before persona"
|
||||
);
|
||||
assert!(
|
||||
persona_at < skills_at && skills_at < refac_at,
|
||||
"assigned skill bodies come under the Skills section after persona"
|
||||
awareness_at < skills_at && skills_at < persona_at,
|
||||
"assigned skill affordances come before persona"
|
||||
);
|
||||
assert!(persona_at < refac_at, "skills come after the persona");
|
||||
// Deterministic order: first assigned skill precedes the second.
|
||||
assert!(refac_at < review_at, "skills emitted in the given order");
|
||||
// Skill names surface as sub-headers.
|
||||
assert!(doc.contains("## refactor"));
|
||||
assert!(doc.contains("## review"));
|
||||
assert!(!doc.contains("## refactor"));
|
||||
assert!(!doc.contains("## review"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -4048,10 +4112,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_mcp_mode_exposes_skill_affordances_not_bodies() {
|
||||
// MCP mode (feature « skills à la MCP »): a high-altitude `# Skills disponibles`
|
||||
fn compose_convention_file_exposes_skill_affordances_not_bodies() {
|
||||
// Both surfaces: a high-altitude `# Skills disponibles`
|
||||
// section after the Orchestration block, listing `**name** — description`
|
||||
// affordances and pointing to `idea_skill_read`, WITHOUT dumping the bodies.
|
||||
// affordances, WITHOUT dumping the bodies.
|
||||
let s = |n: u128, name: &str, desc: Option<&str>, body: &str| {
|
||||
Skill::new(
|
||||
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
|
||||
@ -4062,44 +4126,47 @@ mod tests {
|
||||
.unwrap()
|
||||
.with_description(desc.map(str::to_owned))
|
||||
};
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[
|
||||
s(1, "refactor", Some("Refactors code"), "REFAC_BODY"),
|
||||
// No explicit description ⇒ effective_description falls back to the
|
||||
// body's first line (heading marker stripped).
|
||||
s(2, "review", None, "# Review skill\n\nREVIEW_BODY"),
|
||||
],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
true, // mcp_enabled
|
||||
);
|
||||
for mcp_enabled in [true, false] {
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[
|
||||
s(1, "refactor", Some("Refactors code"), "REFAC_BODY"),
|
||||
// No explicit description ⇒ effective_description falls back to the
|
||||
// body's first line (heading marker stripped).
|
||||
s(2, "review", None, "# Review skill\n\nREVIEW_BODY"),
|
||||
],
|
||||
&[],
|
||||
None,
|
||||
&[],
|
||||
mcp_enabled,
|
||||
);
|
||||
|
||||
// The affordance section is present.
|
||||
assert!(
|
||||
doc.contains("# Skills disponibles"),
|
||||
"MCP skills section present"
|
||||
);
|
||||
assert!(doc.contains("idea_skill_read"), "points to the read tool");
|
||||
// Affordance lines: `**name** — <effective description> (<kind>)`.
|
||||
assert!(doc.contains("**refactor** — Refactors code (workflow)"));
|
||||
assert!(doc.contains("**review** — Review skill (workflow)"));
|
||||
// The full bodies are NOT injected in MCP mode (loaded on demand instead).
|
||||
assert!(!doc.contains("REFAC_BODY"), "no full body in MCP mode");
|
||||
assert!(!doc.contains("REVIEW_BODY"), "no full body in MCP mode");
|
||||
// The legacy `## <name>` body dump headers are absent too.
|
||||
assert!(!doc.contains("## refactor"));
|
||||
assert!(
|
||||
doc.contains("# Skills disponibles"),
|
||||
"skills section present (mcp_enabled={mcp_enabled})"
|
||||
);
|
||||
if mcp_enabled {
|
||||
assert!(doc.contains("idea_skill_read"), "points to the read tool");
|
||||
} else {
|
||||
assert!(
|
||||
doc.contains(".ideai/skills/md/<skill-id>.md"),
|
||||
"points to the file-protocol read path"
|
||||
);
|
||||
}
|
||||
assert!(doc.contains("**refactor** — Refactors code (workflow)"));
|
||||
assert!(doc.contains("**review** — Review skill (workflow)"));
|
||||
assert!(!doc.contains("REFAC_BODY"), "no full body is injected");
|
||||
assert!(!doc.contains("REVIEW_BODY"), "no full body is injected");
|
||||
assert!(!doc.contains("## refactor"));
|
||||
|
||||
// The section sits at high altitude: after the Orchestration block, before
|
||||
// the persona.
|
||||
let orch_at = doc.find("# Orchestration IdeA").unwrap();
|
||||
let skills_at = doc.find("# Skills disponibles").unwrap();
|
||||
let persona_at = doc.find("# Persona").unwrap();
|
||||
assert!(orch_at < skills_at, "skills come after orchestration");
|
||||
assert!(skills_at < persona_at, "skills come before the persona");
|
||||
let orch_at = doc.find("# Orchestration IdeA").unwrap();
|
||||
let skills_at = doc.find("# Skills disponibles").unwrap();
|
||||
let persona_at = doc.find("# Persona").unwrap();
|
||||
assert!(orch_at < skills_at, "skills come after orchestration");
|
||||
assert!(skills_at < persona_at, "skills come before the persona");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -37,8 +37,9 @@ pub use lifecycle::{
|
||||
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities,
|
||||
LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredRoutingMode,
|
||||
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
||||
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
|
||||
UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
||||
};
|
||||
pub use model_catalogue::{
|
||||
claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput,
|
||||
|
||||
@ -26,6 +26,11 @@ pub enum AppError {
|
||||
#[error("invalid input: {0}")]
|
||||
Invalid(String),
|
||||
|
||||
/// An optimistic-concurrency `if_match` did not match the resource's current
|
||||
/// version. Carries the current version so the caller can retry.
|
||||
#[error("concurrency conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
/// A filesystem operation failed.
|
||||
#[error("filesystem error: {0}")]
|
||||
FileSystem(String),
|
||||
@ -107,6 +112,7 @@ impl AppError {
|
||||
match self {
|
||||
Self::NotFound(_) => "NOT_FOUND",
|
||||
Self::Invalid(_) => "INVALID",
|
||||
Self::Conflict(_) => "CONFLICT",
|
||||
Self::FileSystem(_) => "FILESYSTEM",
|
||||
Self::Store(_) => "STORE",
|
||||
Self::Process(_) => "PROCESS",
|
||||
|
||||
@ -61,7 +61,8 @@ pub use agent::{
|
||||
ResumableAgent, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
|
||||
UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
};
|
||||
pub use background::{
|
||||
@ -162,16 +163,16 @@ pub use plugin::{
|
||||
PluginEventPollInput, PluginEventSubscribeInput, PluginEventSubscription,
|
||||
PluginEventSubscriptions, PluginEventUnsubscribeInput, PluginFileDiagnostic,
|
||||
PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReview,
|
||||
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginTaskStatusInput,
|
||||
PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic,
|
||||
PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess,
|
||||
PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing,
|
||||
PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile,
|
||||
PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention,
|
||||
ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure,
|
||||
QueryProjectStructureInput, ReconcilePluginMcpServers, ReviewPluginPackage,
|
||||
ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput, UninstallPlugin,
|
||||
UninstallPluginInput, UninstallPluginResult,
|
||||
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginStorageAccess,
|
||||
PluginStorageGetInput, PluginStorageSetInput, PluginTaskStatusInput, PluginToolDiagnostic,
|
||||
PluginToolRequirement, PluginToolchainDiagnostic, PluginToolchainDiagnosticInput,
|
||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceBinaryFile,
|
||||
PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing, PluginWorkspacePathInput,
|
||||
PluginWorkspaceStat, PluginWorkspaceTextFile, PluginWorkspaceWriteBinaryInput,
|
||||
PluginWorkspaceWriteTextInput, ProjectConvention, ProjectModule, ProjectStructureEntry,
|
||||
ProjectStructureQuery, QueryProjectStructure, QueryProjectStructureInput,
|
||||
ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled,
|
||||
SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
|
||||
@ -29,8 +29,9 @@ use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
|
||||
use domain::{AgentId, Project};
|
||||
use domain::ports::{AgentContextStore, Clock, EventBus, FileSystem, MemoryStore, RemotePath};
|
||||
use domain::{AgentId, DomainEvent, Project};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -45,6 +46,16 @@ fn join_root(project: &Project, rel: &str) -> RemotePath {
|
||||
RemotePath::new(format!("{base}/{rel}"))
|
||||
}
|
||||
|
||||
pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
hex_encode(&hasher.finalize())
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Resolves an agent display name to its [`AgentId`] via the project manifest
|
||||
/// (case-insensitive), or [`AppError::NotFound`].
|
||||
async fn resolve_agent(
|
||||
@ -80,6 +91,14 @@ pub struct ReadContextInput {
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
/// Output of [`ReadContext`].
|
||||
pub struct ReadContextOutput {
|
||||
/// The context Markdown.
|
||||
pub content: MarkdownDoc,
|
||||
/// sha256 hex digest of `content`, only for the global project context.
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
impl ReadContext {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
@ -99,7 +118,7 @@ impl ReadContext {
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
|
||||
pub async fn execute(&self, input: ReadContextInput) -> Result<ReadContextOutput, AppError> {
|
||||
let ReadContextInput {
|
||||
project,
|
||||
target,
|
||||
@ -115,9 +134,13 @@ impl ReadContext {
|
||||
.map_err(map_guard_err)?;
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
let bytes = self.fs.read(&path).await?;
|
||||
let version = hex_sha256(&bytes);
|
||||
let text =
|
||||
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
Ok(MarkdownDoc::new(text))
|
||||
Ok(ReadContextOutput {
|
||||
content: MarkdownDoc::new(text),
|
||||
version: Some(version),
|
||||
})
|
||||
}
|
||||
Some(name) => {
|
||||
let agent = resolve_agent(&self.contexts, &project, &name).await?;
|
||||
@ -126,12 +149,114 @@ impl ReadContext {
|
||||
.acquire_read(requester, GuardedResource::AgentContext(agent))
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
Ok(self.contexts.read_context(&project, &agent).await?)
|
||||
Ok(ReadContextOutput {
|
||||
content: self.contexts.read_context(&project, &agent).await?,
|
||||
version: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Directly updates the global project context. This is the strict, fail-loud
|
||||
/// counterpart to [`ProposeContext`]'s soft-degrading global branch.
|
||||
pub struct UpdateProjectContext {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
/// Input for [`UpdateProjectContext`].
|
||||
pub struct UpdateProjectContextInput {
|
||||
/// The project to write within.
|
||||
pub project: Project,
|
||||
/// New global project context Markdown.
|
||||
pub content: String,
|
||||
/// Optional expected current version.
|
||||
pub if_match: Option<String>,
|
||||
/// The writing party.
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateProjectContext`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateProjectContextOutput {
|
||||
/// sha256 hex digest of the newly written content.
|
||||
pub new_version: String,
|
||||
}
|
||||
|
||||
impl UpdateProjectContext {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
events,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the direct global-context update.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] when the requester is not allowed to write directly,
|
||||
/// - [`AppError::Conflict`] when `if_match` does not match current content,
|
||||
/// - [`AppError`] on store/fs failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateProjectContextInput,
|
||||
) -> Result<UpdateProjectContextOutput, AppError> {
|
||||
let UpdateProjectContextInput {
|
||||
project,
|
||||
content,
|
||||
if_match,
|
||||
requester,
|
||||
} = input;
|
||||
|
||||
let manifest = self.contexts.load_manifest(&project).await?;
|
||||
let designation = manifest.orchestrator_designation();
|
||||
let resource = GuardedResource::ProjectContext;
|
||||
if !may_write_directly(requester, &resource, &designation) {
|
||||
return Err(map_guard_err(GuardError::Forbidden));
|
||||
}
|
||||
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_write(requester, resource)
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
let current_bytes = self.fs.read(&path).await?;
|
||||
let current_version = hex_sha256(¤t_bytes);
|
||||
if let Some(expected) = if_match {
|
||||
if expected != current_version {
|
||||
return Err(AppError::Conflict(current_version));
|
||||
}
|
||||
}
|
||||
|
||||
self.fs.write(&path, content.as_bytes()).await?;
|
||||
let new_version = hex_sha256(content.as_bytes());
|
||||
self.events.publish(DomainEvent::ProjectContextUpdated {
|
||||
project_id: project.id,
|
||||
by: requester,
|
||||
at_ms: self.clock.now_millis(),
|
||||
});
|
||||
|
||||
Ok(UpdateProjectContextOutput { new_version })
|
||||
}
|
||||
}
|
||||
|
||||
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
|
||||
///
|
||||
/// For an **agent** context: a direct write under an exclusive write-lease. For the
|
||||
@ -393,7 +518,7 @@ mod tests {
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{ReadLease, WriteLease};
|
||||
use domain::ports::{FsError, MemoryError, StoreError};
|
||||
use domain::ports::{EventStream, FsError, MemoryError, StoreError};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{ProfileId, ProjectId, RemoteRef};
|
||||
use std::collections::HashMap;
|
||||
@ -599,6 +724,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyBus(Mutex<Vec<DomainEvent>>);
|
||||
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn guard() -> Arc<dyn FileGuard> {
|
||||
Arc::new(TestGuard::default())
|
||||
}
|
||||
@ -619,6 +763,7 @@ mod tests {
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
}],
|
||||
},
|
||||
contexts: Mutex::new(contexts),
|
||||
@ -635,7 +780,7 @@ mod tests {
|
||||
contexts_with("Dev", agent, "# hello"),
|
||||
Arc::new(FakeFs::default()),
|
||||
);
|
||||
let md = uc
|
||||
let out = uc
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: Some("dev".to_owned()), // case-insensitive
|
||||
@ -643,7 +788,8 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(md.as_str(), "# hello");
|
||||
assert_eq!(out.content.as_str(), "# hello");
|
||||
assert_eq!(out.version, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -658,7 +804,7 @@ mod tests {
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
fs,
|
||||
);
|
||||
let md = uc
|
||||
let out = uc
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
@ -666,7 +812,8 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(md.as_str(), "# project");
|
||||
assert_eq!(out.content.as_str(), "# project");
|
||||
assert_eq!(out.version, Some(hex_sha256(b"# project")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -749,6 +896,262 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_orchestrator_writes_directly_and_returns_new_version() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::clone(&bus) as Arc<dyn EventBus>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let out = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.new_version, hex_sha256(b"# new"));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# new"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_non_orchestrator_fails_loud_no_proposal_filed() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let err = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# rejected".to_owned(),
|
||||
if_match: None,
|
||||
requester: agent_party(8),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
let files = fs.files.lock().unwrap();
|
||||
assert_eq!(files.get("/tmp/demo/CLAUDE.md").unwrap(), b"# old");
|
||||
assert!(
|
||||
!files.keys().any(|path| path.contains("/.ideai/proposals/")),
|
||||
"strict update must fail loud, not file a proposal"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_if_match_mismatch_returns_conflict_with_current_version() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# current".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let err = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: Some("stale".to_owned()),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err, AppError::Conflict(hex_sha256(b"# current")));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# current"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_if_match_matching_succeeds() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let out = uc
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: Some(hex_sha256(b"# old")),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.new_version, hex_sha256(b"# new"));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# new"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_no_if_match_is_last_write_wins() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# previous".to_vec());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
uc.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# latest".to_owned(),
|
||||
if_match: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# latest"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_context_publishes_project_context_updated_event() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
|
||||
let bus = Arc::new(SpyBus::default());
|
||||
let uc = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::clone(&bus) as Arc<dyn EventBus>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
uc.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# new".to_owned(),
|
||||
if_match: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
bus.events(),
|
||||
vec![DomainEvent::ProjectContextUpdated {
|
||||
project_id: project().id,
|
||||
by: ConversationParty::agent(agent),
|
||||
at_ms: 42,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_context_and_update_project_context_version_round_trip() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# first".to_vec());
|
||||
let contexts = contexts_with("Dev", agent, "agent body");
|
||||
let reader = ReadContext::new(
|
||||
guard(),
|
||||
Arc::clone(&contexts),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
);
|
||||
let updater = UpdateProjectContext::new(
|
||||
guard(),
|
||||
contexts,
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let version = reader
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.version
|
||||
.unwrap();
|
||||
|
||||
updater
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# second".to_owned(),
|
||||
if_match: Some(version.clone()),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let stale = updater
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project(),
|
||||
content: "# third".to_owned(),
|
||||
if_match: Some(version),
|
||||
requester: ConversationParty::agent(agent),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(stale, AppError::Conflict(hex_sha256(b"# second")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn propose_agent_context_writes_directly() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
|
||||
@ -10,8 +10,9 @@ mod service;
|
||||
pub mod wake;
|
||||
|
||||
pub use context_guard::{
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput,
|
||||
ReadContextOutput, ReadMemory, ReadMemoryInput, UpdateProjectContext,
|
||||
UpdateProjectContextInput, UpdateProjectContextOutput, WriteMemory, WriteMemoryInput,
|
||||
};
|
||||
pub use rendezvous::{
|
||||
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
||||
|
||||
@ -47,7 +47,8 @@ use crate::error::AppError;
|
||||
use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome};
|
||||
use crate::orchestrator::{
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
ReadMemoryInput, UpdateProjectContext, UpdateProjectContextInput, WriteMemory,
|
||||
WriteMemoryInput,
|
||||
};
|
||||
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
|
||||
@ -534,6 +535,8 @@ pub struct ContextGuardUseCases {
|
||||
pub read_context: Arc<ReadContext>,
|
||||
/// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
|
||||
pub propose_context: Arc<ProposeContext>,
|
||||
/// Écriture directe stricte du contexte projet global.
|
||||
pub update_project_context: Arc<UpdateProjectContext>,
|
||||
/// Lecture mémoire sous read-lease.
|
||||
pub read_memory: Arc<ReadMemory>,
|
||||
/// Écriture mémoire sous write-lease.
|
||||
@ -1241,6 +1244,14 @@ impl OrchestratorService {
|
||||
self.propose_context(project, target, content, requester)
|
||||
.await
|
||||
}
|
||||
OrchestratorCommand::UpdateProjectContext {
|
||||
content,
|
||||
if_match,
|
||||
requester,
|
||||
} => {
|
||||
self.update_project_context(project, content, if_match, requester)
|
||||
.await
|
||||
}
|
||||
OrchestratorCommand::ReadMemory { slug, requester } => {
|
||||
self.read_memory(project, slug, requester).await
|
||||
}
|
||||
@ -1363,7 +1374,7 @@ impl OrchestratorService {
|
||||
target: Option<String>,
|
||||
requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let md = self
|
||||
let out = self
|
||||
.require_context_guard()?
|
||||
.read_context
|
||||
.execute(ReadContextInput {
|
||||
@ -1372,9 +1383,13 @@ impl OrchestratorService {
|
||||
requester,
|
||||
})
|
||||
.await?;
|
||||
let mut text = out.content.into_string();
|
||||
if let Some(version) = &out.version {
|
||||
text.push_str(&format!("\n\n<!-- idea-context-version: {version} -->"));
|
||||
}
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("read {} context", target.as_deref().unwrap_or("project")),
|
||||
reply: Some(md.into_string()),
|
||||
reply: Some(text),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1411,6 +1426,31 @@ impl OrchestratorService {
|
||||
})
|
||||
}
|
||||
|
||||
/// `context.update` → strict direct write of the global project context.
|
||||
async fn update_project_context(
|
||||
&self,
|
||||
project: &Project,
|
||||
content: String,
|
||||
if_match: Option<String>,
|
||||
requester: ConversationParty,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let out = self
|
||||
.require_context_guard()?
|
||||
.update_project_context
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: project.clone(),
|
||||
content,
|
||||
if_match,
|
||||
requester,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("wrote project context (version {})", out.new_version),
|
||||
reply: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
|
||||
/// content is returned inline in the outcome's `reply`.
|
||||
async fn read_memory(
|
||||
@ -2783,6 +2823,7 @@ impl OrchestratorService {
|
||||
description: None,
|
||||
content,
|
||||
scope,
|
||||
kind: domain::SkillKind::Workflow,
|
||||
project_root: project.root.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
@ -7,7 +7,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::PermissionStore;
|
||||
use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions};
|
||||
use domain::{
|
||||
AgentId, EffectivePermissions, PermissionSet, PermissionShadowReport, Project,
|
||||
ProjectPermissions,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -131,6 +134,7 @@ impl ResolveAgentPermissions {
|
||||
let doc = self.store.load_permissions(&input.project).await?;
|
||||
Ok(ResolveAgentPermissionsOutput {
|
||||
effective: doc.resolve_for(input.agent_id),
|
||||
shadowed: doc.shadow_for(input.agent_id),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -147,4 +151,6 @@ pub struct ResolveAgentPermissionsInput {
|
||||
pub struct ResolveAgentPermissionsOutput {
|
||||
/// Resolved policy, or `None` when neither project nor agent policy exists.
|
||||
pub effective: Option<EffectivePermissions>,
|
||||
/// Diagnostic report for agent-level allows shadowed by project defaults.
|
||||
pub shadowed: PermissionShadowReport,
|
||||
}
|
||||
|
||||
@ -8,8 +8,8 @@ use domain::ports::{
|
||||
BackgroundTaskStore, Clock, DirEntry, EventBus, FileMetadata, FileSystem, IdGenerator,
|
||||
LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator,
|
||||
PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError,
|
||||
PluginRegistryStore, PluginStoreError, ProcessError, ProcessSpawner, ProjectStore, RemotePath,
|
||||
SpawnSpec,
|
||||
PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, ProcessError,
|
||||
ProcessSpawner, ProjectStore, RemotePath, SpawnSpec,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
|
||||
@ -153,10 +153,34 @@ pub struct PluginRuntimePlugin {
|
||||
pub content_hash: String,
|
||||
/// Public manifest capabilities.
|
||||
pub capabilities: Vec<domain::PluginCapability>,
|
||||
/// Manifest-declared activation scope.
|
||||
pub activation_scope: domain::PluginActivationScope,
|
||||
/// Contributions.
|
||||
pub contributes: PluginContributionSet,
|
||||
}
|
||||
|
||||
/// Input for plugin-owned storage reads/deletes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStorageGetInput {
|
||||
/// Plugin id owning the value.
|
||||
pub plugin_id: String,
|
||||
/// Plugin-owned key.
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
/// Input for plugin-owned storage writes.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStorageSetInput {
|
||||
/// Plugin id owning the value.
|
||||
pub plugin_id: String,
|
||||
/// Plugin-owned key.
|
||||
pub key: String,
|
||||
/// JSON value to persist.
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Input for reviewing a package.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReviewPluginPackageInput {
|
||||
@ -2023,6 +2047,14 @@ fn map_store(e: PluginStoreError) -> AppError {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_storage(e: PluginStorageError) -> AppError {
|
||||
match e {
|
||||
PluginStorageError::Invalid(m) => AppError::Invalid(m),
|
||||
PluginStorageError::Io(m) => AppError::FileSystem(m),
|
||||
PluginStorageError::Serialization(m) => AppError::Store(m),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_registry(e: PluginRegistryError) -> AppError {
|
||||
match e {
|
||||
PluginRegistryError::Io(m) => AppError::Store(m),
|
||||
@ -2177,6 +2209,7 @@ impl ListPlugins {
|
||||
icon: None,
|
||||
trust_level: PluginTrustLevel::Full,
|
||||
capabilities: Vec::new(),
|
||||
activation_scope: domain::PluginActivationScope::default(),
|
||||
contributes: PluginContributionSet::default(),
|
||||
};
|
||||
out.push(admin_from_descriptor(
|
||||
@ -2516,9 +2549,91 @@ pub struct UninstallPluginInput {
|
||||
pub plugin_id: String,
|
||||
}
|
||||
|
||||
/// Plugin-owned key/value storage facade.
|
||||
pub struct PluginStorageAccess {
|
||||
storage: Arc<dyn PluginStorageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
}
|
||||
|
||||
impl PluginStorageAccess {
|
||||
/// Builds the facade.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
storage: Arc<dyn PluginStorageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
) -> Self {
|
||||
Self { storage, registry }
|
||||
}
|
||||
|
||||
/// Reads one plugin-owned JSON value.
|
||||
pub async fn get(
|
||||
&self,
|
||||
input: PluginStorageGetInput,
|
||||
) -> Result<Option<serde_json::Value>, AppError> {
|
||||
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
|
||||
validate_storage_key(&input.key)?;
|
||||
self.storage
|
||||
.get(&plugin_id, &input.key)
|
||||
.await
|
||||
.map_err(map_storage)
|
||||
}
|
||||
|
||||
/// Writes one plugin-owned JSON value.
|
||||
pub async fn set(&self, input: PluginStorageSetInput) -> Result<(), AppError> {
|
||||
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
|
||||
validate_storage_key(&input.key)?;
|
||||
self.storage
|
||||
.set(&plugin_id, &input.key, input.value)
|
||||
.await
|
||||
.map_err(map_storage)
|
||||
}
|
||||
|
||||
/// Deletes one plugin-owned JSON value.
|
||||
pub async fn delete(&self, input: PluginStorageGetInput) -> Result<bool, AppError> {
|
||||
let plugin_id = self.active_plugin_id(input.plugin_id).await?;
|
||||
validate_storage_key(&input.key)?;
|
||||
self.storage
|
||||
.delete(&plugin_id, &input.key)
|
||||
.await
|
||||
.map_err(map_storage)
|
||||
}
|
||||
|
||||
async fn active_plugin_id(&self, raw: String) -> Result<PluginId, AppError> {
|
||||
let plugin_id = PluginId::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let registry = self.registry.load_registry().await.map_err(map_registry)?;
|
||||
let entry = registry
|
||||
.find(&plugin_id)
|
||||
.ok_or_else(|| AppError::NotFound("plugin".to_owned()))?;
|
||||
if !entry.lifecycle_state.is_runtime_active() {
|
||||
return Err(AppError::Invalid("plugin is not runtime-active".to_owned()));
|
||||
}
|
||||
Ok(plugin_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_storage_key(key: &str) -> Result<(), AppError> {
|
||||
if key.trim().is_empty() {
|
||||
return Err(AppError::Invalid(
|
||||
"plugin storage key must not be empty".to_owned(),
|
||||
));
|
||||
}
|
||||
if key.len() > 512 {
|
||||
return Err(AppError::Invalid(
|
||||
"plugin storage key must not exceed 512 bytes".to_owned(),
|
||||
));
|
||||
}
|
||||
if key.contains('\0') {
|
||||
return Err(AppError::Invalid(
|
||||
"plugin storage key must not contain NUL bytes".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstalls a plugin.
|
||||
pub struct UninstallPlugin {
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
storage: Arc<dyn PluginStorageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
mcp: Arc<dyn PluginMcpSupervisor>,
|
||||
@ -2529,12 +2644,14 @@ impl UninstallPlugin {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
packages: Arc<dyn PluginPackageStore>,
|
||||
storage: Arc<dyn PluginStorageStore>,
|
||||
registry: Arc<dyn PluginRegistryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
mcp: Arc<dyn PluginMcpSupervisor>,
|
||||
) -> Self {
|
||||
Self {
|
||||
packages,
|
||||
storage,
|
||||
registry,
|
||||
events,
|
||||
mcp,
|
||||
@ -2562,6 +2679,10 @@ impl UninstallPlugin {
|
||||
.remove_package(&plugin_id)
|
||||
.await
|
||||
.map_err(map_store)?;
|
||||
self.storage
|
||||
.purge_plugin(&plugin_id)
|
||||
.await
|
||||
.map_err(map_storage)?;
|
||||
self.events.publish(DomainEvent::PluginUninstalled {
|
||||
plugin_id: plugin_id.clone(),
|
||||
restart_required: true,
|
||||
@ -2675,6 +2796,7 @@ async fn runtime_plugin_from_entry(
|
||||
icon_url,
|
||||
content_hash: descriptor.registry.content_hash.as_str().to_owned(),
|
||||
capabilities: descriptor.manifest.capabilities,
|
||||
activation_scope: descriptor.manifest.activation_scope,
|
||||
contributes: descriptor.manifest.contributes,
|
||||
})
|
||||
}
|
||||
@ -2888,6 +3010,8 @@ struct RawManifest {
|
||||
trust_level: String,
|
||||
#[serde(default)]
|
||||
capabilities: Vec<String>,
|
||||
#[serde(default)]
|
||||
activation_scope: domain::PluginActivationScope,
|
||||
contributes: RawContributes,
|
||||
}
|
||||
|
||||
@ -3040,6 +3164,7 @@ impl PluginManifestValidator for JsonPluginManifestValidator {
|
||||
icon,
|
||||
trust_level: PluginTrustLevel::Full,
|
||||
capabilities,
|
||||
activation_scope: raw.activation_scope,
|
||||
contributes,
|
||||
})
|
||||
}
|
||||
@ -3214,7 +3339,8 @@ mod tests {
|
||||
use domain::ports::{
|
||||
BackgroundCompletionStream, BackgroundTaskHandle, BackgroundTaskPortError,
|
||||
BackgroundTaskRunner, BackgroundTaskSpec, EventStream, FileMetadata, IdGenerator,
|
||||
PluginPackageStore, PluginRegistryStore, PluginStoreError, StoreError,
|
||||
PluginPackageStore, PluginRegistryStore, PluginStorageError, PluginStorageStore,
|
||||
PluginStoreError, StoreError,
|
||||
};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{BackgroundTaskState, ProjectPath};
|
||||
@ -3411,6 +3537,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeStorage {
|
||||
values: Mutex<HashMap<(String, String), serde_json::Value>>,
|
||||
purged: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl PluginStorageStore for FakeStorage {
|
||||
async fn get(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, PluginStorageError> {
|
||||
Ok(self
|
||||
.values
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&(plugin_id.as_str().to_owned(), key.to_owned()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), PluginStorageError> {
|
||||
self.values
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((plugin_id.as_str().to_owned(), key.to_owned()), value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
) -> Result<bool, PluginStorageError> {
|
||||
Ok(self
|
||||
.values
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(plugin_id.as_str().to_owned(), key.to_owned()))
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn purge_plugin(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStorageError> {
|
||||
self.purged
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(plugin_id.as_str().to_owned());
|
||||
self.values
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(id, _), _| id != plugin_id.as_str());
|
||||
Ok(RemovalOutcome::Removed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeEvents {
|
||||
events: Mutex<Vec<DomainEvent>>,
|
||||
@ -3802,6 +3991,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn uninstall_removes_registry_package_and_stops_mcp() {
|
||||
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||
});
|
||||
@ -3809,6 +3999,7 @@ mod tests {
|
||||
let mcp = Arc::new(FakeMcp::default());
|
||||
let uninstall = UninstallPlugin::new(
|
||||
packages.clone(),
|
||||
storage.clone(),
|
||||
registry.clone(),
|
||||
events.clone(),
|
||||
mcp.clone(),
|
||||
@ -3825,6 +4016,7 @@ mod tests {
|
||||
assert!(result.restart_required);
|
||||
assert!(registry.load_registry().await.unwrap().plugins.is_empty());
|
||||
assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]);
|
||||
assert_eq!(&*storage.purged.lock().unwrap(), &["dev.acme.gitgraph"]);
|
||||
assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]);
|
||||
assert!(events.events.lock().unwrap().iter().any(|event| matches!(
|
||||
event,
|
||||
@ -3835,6 +4027,89 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_storage_round_trips_json_for_runtime_active_plugin() {
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||
});
|
||||
let access = PluginStorageAccess::new(storage, registry);
|
||||
|
||||
access
|
||||
.set(PluginStorageSetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
value: serde_json::json!({"count": 2}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let value = access
|
||||
.get(PluginStorageGetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(value, Some(serde_json::json!({"count": 2})));
|
||||
assert!(access
|
||||
.delete(PluginStorageGetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
access
|
||||
.get(PluginStorageGetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_storage_rejects_inactive_plugin_and_invalid_key() {
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Disabled)),
|
||||
});
|
||||
let access = PluginStorageAccess::new(storage, registry);
|
||||
|
||||
let inactive = access
|
||||
.set(PluginStorageSetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
value: serde_json::json!(1),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
inactive,
|
||||
AppError::Invalid("plugin is not runtime-active".to_owned())
|
||||
);
|
||||
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let registry = Arc::new(FakeRegistry {
|
||||
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||
});
|
||||
let access = PluginStorageAccess::new(storage, registry);
|
||||
let invalid = access
|
||||
.get(PluginStorageGetInput {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: " ".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
invalid,
|
||||
AppError::Invalid("plugin storage key must not be empty".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninstall_then_reinstall_leaves_runtime_catalog_active_without_residue() {
|
||||
let packages = Arc::new(FakePackages::with_manifest_and_staged_count(
|
||||
@ -3844,6 +4119,7 @@ mod tests {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
let events = Arc::new(FakeEvents::default());
|
||||
let mcp = Arc::new(FakeMcp::default());
|
||||
let storage = Arc::new(FakeStorage::default());
|
||||
let install = InstallPluginFromDirectory::new(
|
||||
packages.clone(),
|
||||
registry.clone(),
|
||||
@ -3851,8 +4127,13 @@ mod tests {
|
||||
events.clone(),
|
||||
mcp.clone(),
|
||||
);
|
||||
let uninstall =
|
||||
UninstallPlugin::new(packages.clone(), registry.clone(), events, mcp.clone());
|
||||
let uninstall = UninstallPlugin::new(
|
||||
packages.clone(),
|
||||
storage.clone(),
|
||||
registry.clone(),
|
||||
events,
|
||||
mcp.clone(),
|
||||
);
|
||||
|
||||
install.execute("/source/plugin".to_owned()).await.unwrap();
|
||||
uninstall
|
||||
|
||||
@ -30,6 +30,9 @@ pub struct CreateSkillInput {
|
||||
/// `None`/empty ⇒ the skill falls back to the first line of its body when
|
||||
/// surfaced (see [`domain::Skill::effective_description`]).
|
||||
pub description: Option<String>,
|
||||
/// Capability nature to expose for this skill. Defaults to workflow when the
|
||||
/// caller does not specify it.
|
||||
pub kind: SkillKind,
|
||||
/// Initial Markdown body.
|
||||
pub content: String,
|
||||
/// Scope the skill is created in (selects its backing store).
|
||||
@ -67,7 +70,8 @@ impl CreateSkill {
|
||||
let id = SkillId::from_uuid(self.ids.new_uuid());
|
||||
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?
|
||||
.with_description(input.description);
|
||||
.with_description(input.description)
|
||||
.with_kind(input.kind);
|
||||
self.skills.save(&skill, &input.project_root).await?;
|
||||
Ok(CreateSkillOutput { skill })
|
||||
}
|
||||
|
||||
@ -35,8 +35,8 @@ use domain::ports::{
|
||||
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, SystemPermissionStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
|
||||
SessionStrategy, StructuredAdapter,
|
||||
AgentProfile, ContextInjection, EffortSelection, McpCapability, McpConfigStrategy,
|
||||
McpTransport, OpenCodeConfig, SessionStrategy, StructuredAdapter,
|
||||
};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
@ -50,7 +50,7 @@ use application::{
|
||||
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
||||
LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext,
|
||||
ReadAgentContextInput, StructuredRoutingMode, StructuredSessions, TerminalSessions,
|
||||
UpdateAgentContext, UpdateAgentContextInput,
|
||||
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -604,6 +604,7 @@ impl AgentSession for FakeSession {
|
||||
struct FakeStructuredFactory {
|
||||
trace: Trace,
|
||||
starts: Arc<Mutex<Vec<ProfileId>>>,
|
||||
efforts: Arc<Mutex<Vec<Option<String>>>>,
|
||||
envs: Arc<Mutex<Vec<Vec<(String, String)>>>>,
|
||||
policies: Arc<Mutex<Vec<Option<domain::ports::StructuredProviderLaunchPolicy>>>>,
|
||||
next_session: SessionId,
|
||||
@ -614,6 +615,7 @@ impl FakeStructuredFactory {
|
||||
Self {
|
||||
trace,
|
||||
starts: Arc::new(Mutex::new(Vec::new())),
|
||||
efforts: Arc::new(Mutex::new(Vec::new())),
|
||||
envs: Arc::new(Mutex::new(Vec::new())),
|
||||
policies: Arc::new(Mutex::new(Vec::new())),
|
||||
next_session,
|
||||
@ -624,6 +626,10 @@ impl FakeStructuredFactory {
|
||||
self.starts.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn efforts(&self) -> Vec<Option<String>> {
|
||||
self.efforts.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn envs(&self) -> Vec<Vec<(String, String)>> {
|
||||
self.envs.lock().unwrap().clone()
|
||||
}
|
||||
@ -655,6 +661,10 @@ impl AgentSessionFactory for FakeStructuredFactory {
|
||||
.unwrap()
|
||||
.push("structured.start".to_owned());
|
||||
self.starts.lock().unwrap().push(profile.id);
|
||||
self.efforts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(profile.model_reasoning_effort.clone());
|
||||
self.envs.lock().unwrap().push(_env.to_vec());
|
||||
self.policies
|
||||
.lock()
|
||||
@ -864,6 +874,54 @@ async fn list_resolves_agent_capabilities_additively() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agents_marks_effective_orchestrator_as_is_orchestrator_true() {
|
||||
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a1, "ctx1");
|
||||
{
|
||||
let mut inner = contexts.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(&a2));
|
||||
inner.manifest.designate(a2.id).unwrap();
|
||||
}
|
||||
let list = ListAgents::new(Arc::new(contexts));
|
||||
|
||||
let out = list
|
||||
.execute(ListAgentsInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
let entries = out.discovery_entries();
|
||||
|
||||
assert_eq!(out.effective_orchestrator, Some(a2.id));
|
||||
assert_eq!(entries[0].is_orchestrator, false);
|
||||
assert_eq!(entries[1].is_orchestrator, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agents_default_orchestrator_is_oldest_agent_when_none_designated() {
|
||||
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a1, "ctx1");
|
||||
contexts
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.manifest
|
||||
.entries
|
||||
.push(ManifestEntry::from_agent(&a2));
|
||||
let list = ListAgents::new(Arc::new(contexts));
|
||||
|
||||
let out = list
|
||||
.execute(ListAgentsInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
let entries = out.discovery_entries();
|
||||
|
||||
assert_eq!(out.effective_orchestrator, Some(a1.id));
|
||||
assert_eq!(entries[0].is_orchestrator, true);
|
||||
assert_eq!(entries[1].is_orchestrator, false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_then_update_context_roundtrips() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
@ -895,6 +953,69 @@ async fn read_then_update_context_roundtrips() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_effort_sets_preset_selection_and_persists_manifest() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
|
||||
|
||||
let out = update
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project: project(),
|
||||
agent_id: a.id,
|
||||
effort: Some(EffortSelection::Preset("high".to_owned())),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
out.agent.effort,
|
||||
Some(EffortSelection::Preset("high".to_owned()))
|
||||
);
|
||||
assert_eq!(
|
||||
contexts.manifest().entries[0].effort,
|
||||
Some(EffortSelection::Preset("high".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_effort_none_clears_existing_override() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9))
|
||||
.with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
|
||||
|
||||
let out = update
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project: project(),
|
||||
agent_id: a.id,
|
||||
effort: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.agent.effort, None);
|
||||
assert_eq!(contexts.manifest().entries[0].effort, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_effort_not_found_for_unknown_agent_id() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let contexts = FakeContexts::with_agent(&a, "ctx");
|
||||
let update = UpdateAgentEffort::new(Arc::new(contexts));
|
||||
|
||||
let err = update
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project: project(),
|
||||
agent_id: aid(404),
|
||||
effort: Some(EffortSelection::Preset("medium".to_owned())),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_entry_then_unknown_is_not_found() {
|
||||
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
@ -965,6 +1086,15 @@ fn launch_fixture_with_profile_and_recall(
|
||||
recall: FakeRecall,
|
||||
) -> LaunchFixture {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
launch_fixture_with_profile_agent_and_recall(profile, agent, plan, recall)
|
||||
}
|
||||
|
||||
fn launch_fixture_with_profile_agent_and_recall(
|
||||
profile: AgentProfile,
|
||||
agent: Agent,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
recall: FakeRecall,
|
||||
) -> LaunchFixture {
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile]);
|
||||
let tr = trace();
|
||||
@ -1127,6 +1257,63 @@ async fn structured_profile_with_factory_routes_to_structured_session_without_pt
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_agent_structured_forwards_resolved_effort_ahead_of_profile_default() {
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_model_reasoning_effort("low");
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
|
||||
.with_effort(Some(EffortSelection::Preset("high".to_owned())));
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) =
|
||||
launch_fixture_with_profile_agent_and_recall(
|
||||
profile,
|
||||
agent,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
FakeRecall::default(),
|
||||
);
|
||||
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let launch = launch
|
||||
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||
.with_structured(Arc::new(factory.clone()), structured);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
assert!(pty.spawns().is_empty());
|
||||
assert_eq!(factory.efforts(), vec![Some("high".to_owned())]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_agent_structured_falls_back_to_profile_default_when_agent_has_no_override() {
|
||||
let profile = profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
)
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_model_reasoning_effort("low");
|
||||
let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888));
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let launch = launch
|
||||
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||
.with_structured(Arc::new(factory.clone()), structured);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
assert!(pty.spawns().is_empty());
|
||||
assert_eq!(factory.efforts(), vec![Some("low".to_owned())]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_profile_without_factory_require_structured_errors_without_pty_spawn() {
|
||||
let profile = profile(
|
||||
@ -3264,6 +3451,17 @@ fn launch_with_projection_and_env(
|
||||
env: Vec<(String, String)>,
|
||||
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
launch_with_projection_agent_and_env(profile, agent, plan, registry, perm_doc, env)
|
||||
}
|
||||
|
||||
fn launch_with_projection_agent_and_env(
|
||||
profile: AgentProfile,
|
||||
agent: Agent,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
registry: Option<Arc<PermissionProjectorRegistry>>,
|
||||
perm_doc: Option<ProjectPermissions>,
|
||||
env: Vec<(String, String)>,
|
||||
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile]);
|
||||
let tr = trace();
|
||||
@ -3896,6 +4094,40 @@ async fn codex_pty_launch_forwards_profile_model_as_config_override() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_agent_pty_codex_overrides_use_resolved_effort() {
|
||||
let profile = codex_profile()
|
||||
.with_projector(ProjectorKey::Codex)
|
||||
.with_model_reasoning_effort("low");
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id)
|
||||
.with_effort(Some(EffortSelection::Custom("x-deep".to_owned())));
|
||||
let (launch, agent, _fs, pty, _s) = launch_with_projection_agent_and_env(
|
||||
profile,
|
||||
agent,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "AGENTS.md".to_owned(),
|
||||
}),
|
||||
Some(full_registry()),
|
||||
None,
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
let args = &pty.spawns()[0].args;
|
||||
assert!(
|
||||
args.windows(2).any(|w| w
|
||||
== [
|
||||
"-c".to_owned(),
|
||||
"model_reasoning_effort=\"x-deep\"".to_owned()
|
||||
]),
|
||||
"PTY Codex launch must forward the resolved per-agent effort, got {args:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (5) MCP decoupling — THE key case of the lot ---------------------------
|
||||
|
||||
/// (5) A Codex profile with **no MCP capability** still gets its sandbox projected
|
||||
|
||||
@ -9,7 +9,10 @@ use domain::ids::{AgentId, ProjectId};
|
||||
use domain::ports::{PermissionStore, StoreError};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{PermissionSet, Posture, ProjectPermissions};
|
||||
use domain::{
|
||||
Capability, Effect, PermissionRule, PermissionSet, PermissionShadowReport, Posture,
|
||||
ProjectPermissions,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakePermissionStore {
|
||||
@ -125,4 +128,69 @@ async fn resolve_agent_permissions_returns_effective_policy() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.effective.unwrap().fallback(), Posture::Ask);
|
||||
assert_eq!(out.shadowed, PermissionShadowReport::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_agent_permissions_reports_shadowed_alongside_unchanged_effective() {
|
||||
let agent = AgentId::new_random();
|
||||
let store = Arc::new(FakePermissionStore {
|
||||
doc: Mutex::new(ProjectPermissions::new(
|
||||
Some(PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Deny, vec![])],
|
||||
Posture::Ask,
|
||||
)),
|
||||
vec![domain::AgentPermissionOverride::new(
|
||||
agent,
|
||||
PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Allow, vec![])],
|
||||
Posture::Ask,
|
||||
),
|
||||
)],
|
||||
)),
|
||||
saves: Mutex::new(0),
|
||||
});
|
||||
let use_case = ResolveAgentPermissions::new(store);
|
||||
|
||||
let out = use_case
|
||||
.execute(ResolveAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(out.shadowed.execute_bash);
|
||||
assert_eq!(out.effective.unwrap().decide_bash("ls"), Posture::Deny);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_agent_permissions_shadowed_defaults_when_no_agent_override() {
|
||||
let agent = AgentId::new_random();
|
||||
let store = Arc::new(FakePermissionStore {
|
||||
doc: Mutex::new(ProjectPermissions::new(
|
||||
Some(PermissionSet::new(
|
||||
vec![PermissionRule::file(
|
||||
Capability::Read,
|
||||
Effect::Deny,
|
||||
domain::PathScope::new(["**".to_owned()]).unwrap(),
|
||||
)
|
||||
.unwrap()],
|
||||
Posture::Deny,
|
||||
)),
|
||||
vec![],
|
||||
)),
|
||||
saves: Mutex::new(0),
|
||||
});
|
||||
let use_case = ResolveAgentPermissions::new(store);
|
||||
|
||||
let out = use_case
|
||||
.execute(ResolveAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.shadowed, PermissionShadowReport::default());
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, EventBus, EventStream, IdGenerator, SkillStore, StoreError,
|
||||
};
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use domain::skill::{Skill, SkillKind, SkillScope};
|
||||
use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef, SkillRef};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -196,6 +196,7 @@ async fn create_skill_persists_in_its_scope() {
|
||||
.execute(CreateSkillInput {
|
||||
name: "refactor".to_owned(),
|
||||
description: Some("Refactors code".to_owned()),
|
||||
kind: SkillKind::Reference,
|
||||
content: "# body".to_owned(),
|
||||
scope: SkillScope::Project,
|
||||
project_root: root(),
|
||||
@ -204,6 +205,7 @@ async fn create_skill_persists_in_its_scope() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.skill.scope, SkillScope::Project);
|
||||
assert_eq!(out.skill.kind, SkillKind::Reference);
|
||||
// The one-line affordance description flows through the use case onto the skill.
|
||||
assert_eq!(out.skill.description.as_deref(), Some("Refactors code"));
|
||||
assert_eq!(
|
||||
@ -228,6 +230,7 @@ async fn create_skill_rejects_empty_content() {
|
||||
.execute(CreateSkillInput {
|
||||
name: "k".to_owned(),
|
||||
description: None,
|
||||
kind: SkillKind::Workflow,
|
||||
content: String::new(),
|
||||
scope: SkillScope::Global,
|
||||
project_root: root(),
|
||||
|
||||
@ -242,6 +242,8 @@ pub struct PluginRuntimePluginDto {
|
||||
pub content_hash: String,
|
||||
/// Public manifest capabilities.
|
||||
pub capabilities: Vec<domain::PluginCapability>,
|
||||
/// Manifest-declared activation scope.
|
||||
pub activation_scope: domain::PluginActivationScope,
|
||||
/// Contributions.
|
||||
pub contributes: domain::PluginContributionSet,
|
||||
}
|
||||
@ -269,6 +271,7 @@ impl From<application::PluginRuntimePlugin> for PluginRuntimePluginDto {
|
||||
icon_url: value.icon_url,
|
||||
content_hash: value.content_hash,
|
||||
capabilities: value.capabilities,
|
||||
activation_scope: value.activation_scope,
|
||||
contributes: value.contributes,
|
||||
}
|
||||
}
|
||||
@ -337,6 +340,47 @@ impl From<PluginWorkspaceWriteBinaryDto> for application::PluginWorkspaceWriteBi
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin-owned storage read/delete request DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStorageGetDto {
|
||||
/// Plugin id owning the value.
|
||||
pub plugin_id: String,
|
||||
/// Plugin-owned key.
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
impl From<PluginStorageGetDto> for application::PluginStorageGetInput {
|
||||
fn from(value: PluginStorageGetDto) -> Self {
|
||||
Self {
|
||||
plugin_id: value.plugin_id,
|
||||
key: value.key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin-owned storage write request DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginStorageSetDto {
|
||||
/// Plugin id owning the value.
|
||||
pub plugin_id: String,
|
||||
/// Plugin-owned key.
|
||||
pub key: String,
|
||||
/// JSON value to persist.
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
impl From<PluginStorageSetDto> for application::PluginStorageSetInput {
|
||||
fn from(value: PluginStorageSetDto) -> Self {
|
||||
Self {
|
||||
plugin_id: value.plugin_id,
|
||||
key: value.key,
|
||||
value: value.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin structured config document read request DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -2377,8 +2421,8 @@ use application::{
|
||||
LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput,
|
||||
};
|
||||
use domain::{
|
||||
Agent, AgentMcpToolPolicyOverride, EffectivePermissions, McpToolPolicy, PermissionSet,
|
||||
ProjectPermissions, SkillKind, TerminalSession,
|
||||
Agent, AgentMcpToolPolicyOverride, EffectivePermissions, EffortSelection, McpToolPolicy,
|
||||
PermissionSet, PermissionShadowReport, ProjectPermissions, SkillKind, TerminalSession,
|
||||
};
|
||||
|
||||
/// One discoverable capability carried by an agent.
|
||||
@ -2413,6 +2457,8 @@ pub struct AgentDto {
|
||||
pub agent: Agent,
|
||||
/// Resolved discoverable capabilities.
|
||||
pub capabilities: Vec<AgentCapabilityDto>,
|
||||
/// Whether this agent is the effective project orchestrator.
|
||||
pub is_orchestrator: bool,
|
||||
}
|
||||
|
||||
impl AgentDto {
|
||||
@ -2422,6 +2468,7 @@ impl AgentDto {
|
||||
Self {
|
||||
agent,
|
||||
capabilities: Vec::new(),
|
||||
is_orchestrator: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2443,6 +2490,7 @@ impl From<ListAgentsOutput> for AgentListDto {
|
||||
.into_iter()
|
||||
.map(AgentCapabilityDto::from)
|
||||
.collect(),
|
||||
is_orchestrator: entry.is_orchestrator,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
@ -2512,6 +2560,25 @@ pub struct ProjectPermissionsDto(pub ProjectPermissions);
|
||||
#[serde(transparent)]
|
||||
pub struct EffectivePermissionsDto(pub EffectivePermissions);
|
||||
|
||||
/// Response for resolving one agent's file/bash permissions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolveAgentPermissionsResponseDto {
|
||||
/// Resolved policy, or `null` when neither project nor agent policy exists.
|
||||
pub effective: Option<EffectivePermissions>,
|
||||
/// Diagnostic report for agent-level allows shadowed by project defaults.
|
||||
pub shadowed: PermissionShadowReport,
|
||||
}
|
||||
|
||||
impl From<application::ResolveAgentPermissionsOutput> for ResolveAgentPermissionsResponseDto {
|
||||
fn from(out: application::ResolveAgentPermissionsOutput) -> Self {
|
||||
Self {
|
||||
effective: out.effective,
|
||||
shadowed: out.shadowed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full project system permission document crossing the wire.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
@ -2767,6 +2834,18 @@ pub struct ChangeAgentProfileRequestDto {
|
||||
pub cols: u16,
|
||||
}
|
||||
|
||||
/// Request DTO for `update_agent_effort`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateAgentEffortRequestDto {
|
||||
/// Id of the owning project.
|
||||
pub project_id: String,
|
||||
/// Id of the agent whose effort override changes.
|
||||
pub agent_id: String,
|
||||
/// `null` clears the override.
|
||||
pub effort: Option<EffortSelection>,
|
||||
}
|
||||
|
||||
/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly
|
||||
/// relaunched session when a live session was hot-swapped (absent otherwise).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@ -3997,10 +4076,16 @@ pub struct CreateSkillRequestDto {
|
||||
pub project_id: String,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Optional one-line affordance description.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Initial Markdown content.
|
||||
pub content: String,
|
||||
/// Scope the skill is created in.
|
||||
pub scope: SkillScope,
|
||||
/// Capability nature. Missing legacy clients create workflow skills.
|
||||
#[serde(default)]
|
||||
pub kind: SkillKind,
|
||||
}
|
||||
|
||||
/// Request DTO for `update_skill`.
|
||||
@ -4703,7 +4788,7 @@ pub struct SpawnBackgroundCommandRequestDto {
|
||||
mod tests {
|
||||
use application::McpToolPermissionCatalogue;
|
||||
use domain::mailbox::TicketId;
|
||||
use domain::{AgentId, ConversationId, ProjectMcpToolPermissions};
|
||||
use domain::{AgentId, ConversationId, PermissionShadowReport, ProjectMcpToolPermissions};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -4755,6 +4840,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_agent_permissions_response_dto_uses_effective_plus_shadowed_shape() {
|
||||
let dto = ResolveAgentPermissionsResponseDto {
|
||||
effective: None,
|
||||
shadowed: PermissionShadowReport {
|
||||
read: false,
|
||||
write: false,
|
||||
delete: false,
|
||||
execute_bash: true,
|
||||
fallback: true,
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(dto).unwrap(),
|
||||
json!({
|
||||
"effective": null,
|
||||
"shadowed": {
|
||||
"read": false,
|
||||
"write": false,
|
||||
"delete": false,
|
||||
"executeBash": true,
|
||||
"fallback": true
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_plugins_workspace_requests_use_stable_camel_case_contract() {
|
||||
let path = PluginWorkspacePathDto {
|
||||
@ -4868,6 +4981,42 @@ mod tests {
|
||||
assert_eq!(input.value["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_plugins_storage_requests_use_stable_camel_case_contract() {
|
||||
let get = PluginStorageGetDto {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.launches".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&get).unwrap(),
|
||||
json!({
|
||||
"pluginId": "dev.acme.gitgraph",
|
||||
"key": "helloPlugin.launches"
|
||||
})
|
||||
);
|
||||
let input: application::PluginStorageGetInput = get.into();
|
||||
assert_eq!(input.plugin_id, "dev.acme.gitgraph");
|
||||
assert_eq!(input.key, "helloPlugin.launches");
|
||||
|
||||
let set = PluginStorageSetDto {
|
||||
plugin_id: "dev.acme.gitgraph".to_owned(),
|
||||
key: "helloPlugin.enabled".to_owned(),
|
||||
value: json!({"enabled": true}),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&set).unwrap(),
|
||||
json!({
|
||||
"pluginId": "dev.acme.gitgraph",
|
||||
"key": "helloPlugin.enabled",
|
||||
"value": {"enabled": true}
|
||||
})
|
||||
);
|
||||
let input: application::PluginStorageSetInput = set.into();
|
||||
assert_eq!(input.plugin_id, "dev.acme.gitgraph");
|
||||
assert_eq!(input.key, "helloPlugin.enabled");
|
||||
assert_eq!(input.value, json!({"enabled": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_plugins_workspace_outputs_use_stable_camel_case_contract() {
|
||||
let listing = PluginWorkspaceDirectoryListingDto {
|
||||
|
||||
@ -579,6 +579,16 @@ pub enum DomainEventDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
orchestrator: Option<String>,
|
||||
},
|
||||
/// The project's global context was written directly.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ProjectContextUpdated {
|
||||
/// The project whose global context changed.
|
||||
project_id: String,
|
||||
/// Writer party (`"user"` or agent id).
|
||||
by: String,
|
||||
/// Epoch-milliseconds of the write.
|
||||
at_ms: i64,
|
||||
},
|
||||
/// A memory note was created or updated.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
MemorySaved {
|
||||
@ -1213,6 +1223,15 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
project_id: project_id.to_string(),
|
||||
orchestrator: orchestrator.as_ref().map(|a| a.to_string()),
|
||||
},
|
||||
DomainEvent::ProjectContextUpdated {
|
||||
project_id,
|
||||
by,
|
||||
at_ms,
|
||||
} => Self::ProjectContextUpdated {
|
||||
project_id: project_id.to_string(),
|
||||
by: conversation_party_wire(*by),
|
||||
at_ms: *at_ms,
|
||||
},
|
||||
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
|
||||
slug: slug.as_str().to_string(),
|
||||
},
|
||||
@ -1426,6 +1445,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_context_updated_relays_writer_to_wire() {
|
||||
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
let writer = agent(2);
|
||||
|
||||
let dto = DomainEventDto::from(&DomainEvent::ProjectContextUpdated {
|
||||
project_id,
|
||||
by: ConversationParty::agent(writer),
|
||||
at_ms: 987_654,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&dto).unwrap(),
|
||||
json!({
|
||||
"type": "projectContextUpdated",
|
||||
"projectId": project_id.to_string(),
|
||||
"by": writer.to_string(),
|
||||
"atMs": 987654,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_completion_relays_rendezvous_context_to_wire() {
|
||||
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
|
||||
@ -11,6 +11,7 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
|
||||
use application::{
|
||||
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
|
||||
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
|
||||
@ -35,7 +36,7 @@ use application::{
|
||||
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
||||
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
||||
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
|
||||
PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions,
|
||||
PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure,
|
||||
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment,
|
||||
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
|
||||
@ -49,7 +50,7 @@ use application::{
|
||||
SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext,
|
||||
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort,
|
||||
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
||||
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
||||
@ -64,10 +65,11 @@ use domain::ports::{
|
||||
EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort,
|
||||
IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall,
|
||||
MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
||||
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore,
|
||||
ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler,
|
||||
SecretStore, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer,
|
||||
SystemPermissionStore, TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
||||
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore,
|
||||
ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe,
|
||||
ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
|
||||
WakeError, WakeReason, WindowStateStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
@ -91,15 +93,15 @@ use infrastructure::{
|
||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
||||
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
||||
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
||||
FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore,
|
||||
FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
||||
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
||||
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
|
||||
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
||||
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
|
||||
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||
FsPluginStorageStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore,
|
||||
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore,
|
||||
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
||||
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
||||
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer,
|
||||
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
|
||||
ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory,
|
||||
SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
@ -1093,6 +1095,8 @@ pub struct BackendCore {
|
||||
pub update_project_permissions: Arc<UpdateProjectPermissions>,
|
||||
/// Update one agent permission override.
|
||||
pub update_agent_permissions: Arc<UpdateAgentPermissions>,
|
||||
/// Update one agent effort override.
|
||||
pub update_agent_effort: Arc<UpdateAgentEffort>,
|
||||
/// Resolve effective permissions for one agent.
|
||||
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
|
||||
/// Read the project system permission document.
|
||||
@ -1142,6 +1146,8 @@ pub struct BackendCore {
|
||||
pub plugin_workspace_access: Arc<PluginWorkspaceAccess>,
|
||||
/// Public plugin structured config document facade.
|
||||
pub plugin_config_documents: Arc<PluginConfigDocuments>,
|
||||
/// Public plugin-owned storage facade.
|
||||
pub plugin_storage_access: Arc<PluginStorageAccess>,
|
||||
/// Public plugin project-structure query use case.
|
||||
pub query_project_structure: Arc<QueryProjectStructure>,
|
||||
/// Public plugin command/task facade.
|
||||
@ -1416,11 +1422,13 @@ impl BackendCore {
|
||||
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
||||
let plugin_packages = Arc::new(FsPluginPackageStore::new(app_data_dir.clone()));
|
||||
let plugin_registry = Arc::new(FsPluginRegistryStore::new(app_data_dir.clone()));
|
||||
let plugin_storage = Arc::new(FsPluginStorageStore::new(app_data_dir.clone()));
|
||||
let plugin_validator =
|
||||
Arc::new(JsonPluginManifestValidator::new(env!("CARGO_PKG_VERSION")));
|
||||
let plugin_mcp_supervisor = Arc::new(ExternalMcpPluginSupervisor::new());
|
||||
let plugin_package_store = Arc::clone(&plugin_packages) as Arc<dyn PluginPackageStore>;
|
||||
let plugin_registry_store = Arc::clone(&plugin_registry) as Arc<dyn PluginRegistryStore>;
|
||||
let plugin_storage_store = Arc::clone(&plugin_storage) as Arc<dyn PluginStorageStore>;
|
||||
let plugin_manifest_validator =
|
||||
Arc::clone(&plugin_validator) as Arc<dyn PluginManifestValidator>;
|
||||
let plugin_mcp_supervisor_port =
|
||||
@ -2178,6 +2186,7 @@ impl BackendCore {
|
||||
let update_agent_permissions = Arc::new(UpdateAgentPermissions::new(Arc::clone(
|
||||
&permission_store_port,
|
||||
)));
|
||||
let update_agent_effort = Arc::new(UpdateAgentEffort::new(Arc::clone(&contexts_port)));
|
||||
let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone(
|
||||
&permission_store_port,
|
||||
)));
|
||||
@ -2425,6 +2434,7 @@ impl BackendCore {
|
||||
));
|
||||
let uninstall_plugin = Arc::new(UninstallPlugin::new(
|
||||
Arc::clone(&plugin_package_store),
|
||||
Arc::clone(&plugin_storage_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
Arc::clone(&events_port),
|
||||
Arc::clone(&plugin_mcp_supervisor_port),
|
||||
@ -2448,6 +2458,10 @@ impl BackendCore {
|
||||
PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port))
|
||||
.with_events(Arc::clone(&events_port)),
|
||||
);
|
||||
let plugin_storage_access = Arc::new(PluginStorageAccess::new(
|
||||
Arc::clone(&plugin_storage_store),
|
||||
Arc::clone(&plugin_registry_store),
|
||||
));
|
||||
let query_project_structure = Arc::new(QueryProjectStructure::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
@ -2718,6 +2732,13 @@ impl BackendCore {
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
)),
|
||||
update_project_context: Arc::new(GuardedUpdateProjectContext::new(
|
||||
Arc::clone(&file_guard),
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&events_port),
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
)),
|
||||
read_memory: Arc::new(ReadMemory::new(
|
||||
Arc::clone(&file_guard),
|
||||
Arc::clone(&memory_store_port),
|
||||
@ -2976,6 +2997,7 @@ impl BackendCore {
|
||||
get_project_permissions,
|
||||
update_project_permissions,
|
||||
update_agent_permissions,
|
||||
update_agent_effort,
|
||||
resolve_agent_permissions,
|
||||
get_project_system_permissions,
|
||||
update_project_system_permissions,
|
||||
@ -3045,6 +3067,7 @@ impl BackendCore {
|
||||
reconcile_plugin_mcp_servers,
|
||||
plugin_workspace_access,
|
||||
plugin_config_documents,
|
||||
plugin_storage_access,
|
||||
query_project_structure,
|
||||
plugin_command_tasks,
|
||||
plugin_toolchain_diagnostics,
|
||||
@ -4649,6 +4672,7 @@ mod mcp_serve_peer_tests {
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
});
|
||||
id
|
||||
}
|
||||
@ -5387,6 +5411,7 @@ mod mcp_serve_peer_tests {
|
||||
// traite ces commandes sans cette erreur — et le réfute sans le câblage.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use application::orchestrator::UpdateProjectContext as GuardedUpdateProjectContext;
|
||||
use application::{ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory};
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::memory::{
|
||||
@ -5481,6 +5506,13 @@ mod mcp_serve_peer_tests {
|
||||
Arc::new(FakeFs),
|
||||
Arc::new(FixedClock),
|
||||
)),
|
||||
update_project_context: Arc::new(GuardedUpdateProjectContext::new(
|
||||
Arc::clone(&file_guard),
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(FakeFs),
|
||||
Arc::new(NoopBus),
|
||||
Arc::new(FixedClock),
|
||||
)),
|
||||
read_memory: Arc::new(ReadMemory::new(
|
||||
Arc::clone(&file_guard),
|
||||
Arc::clone(&memory) as Arc<dyn MemoryStore>,
|
||||
@ -5977,6 +6009,7 @@ mod mcp_e2e_loopback_tests {
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::{AgentId, ProfileId, TemplateId};
|
||||
use crate::profile::EffortSelection;
|
||||
use crate::skill::SkillRef;
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
@ -70,6 +71,10 @@ pub struct Agent {
|
||||
/// activation (ARCHITECTURE §14.2). Empty by default.
|
||||
#[serde(default)]
|
||||
pub skills: Vec<SkillRef>,
|
||||
/// Per-agent effort selection. `None` falls back to the profile's
|
||||
/// `model_reasoning_effort` at launch.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<EffortSelection>,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
@ -104,6 +109,7 @@ impl Agent {
|
||||
origin,
|
||||
synchronized,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -128,6 +134,13 @@ impl Agent {
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a copy of this agent carrying the given per-agent effort override.
|
||||
#[must_use]
|
||||
pub fn with_effort(mut self, effort: Option<EffortSelection>) -> Self {
|
||||
self.effort = effort;
|
||||
self
|
||||
}
|
||||
|
||||
/// Assigns a skill to this agent. Idempotent: re-assigning the same
|
||||
/// `skill_id` is a no-op (returns `false`); a new assignment returns `true`.
|
||||
pub fn assign_skill(&mut self, skill: SkillRef) -> bool {
|
||||
@ -183,6 +196,9 @@ pub struct ManifestEntry {
|
||||
/// backward-compatible deserialisation of pre-L12 manifests.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub skills: Vec<SkillRef>,
|
||||
/// Per-agent effort selection. Missing in older manifests means no override.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<EffortSelection>,
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
@ -221,6 +237,7 @@ impl ManifestEntry {
|
||||
synchronized,
|
||||
synced_template_version,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -246,6 +263,7 @@ impl ManifestEntry {
|
||||
synchronized: agent.synchronized,
|
||||
synced_template_version,
|
||||
skills: agent.skills.clone(),
|
||||
effort: agent.effort.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -271,7 +289,8 @@ impl ManifestEntry {
|
||||
origin,
|
||||
self.synchronized,
|
||||
)?
|
||||
.with_skills(self.skills.clone()))
|
||||
.with_skills(self.skills.clone())
|
||||
.with_effort(self.effort.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -530,4 +549,41 @@ mod orchestrator_tests {
|
||||
&d,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_with_effort_round_trips_through_manifest_entry() {
|
||||
let agent = Agent::new(
|
||||
agent_id(1),
|
||||
"agent-1",
|
||||
"agents/agent-1.md",
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(1001)),
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
.with_effort(Some(EffortSelection::Preset("medium".to_owned())));
|
||||
|
||||
let entry = ManifestEntry::from_agent(&agent);
|
||||
let back = entry.to_agent().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
back.effort,
|
||||
Some(EffortSelection::Preset("medium".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_effort_absent_deserialises_as_none() {
|
||||
let json = r#"{
|
||||
"agentId":"00000000-0000-0000-0000-000000000001",
|
||||
"name":"agent-1",
|
||||
"mdPath":"agents/agent-1.md",
|
||||
"profileId":"00000000-0000-0000-0000-0000000003e9",
|
||||
"synchronized":false
|
||||
}"#;
|
||||
|
||||
let entry: ManifestEntry = serde_json::from_str(json).expect("legacy entry deserialises");
|
||||
assert_eq!(entry.effort, None);
|
||||
assert_eq!(entry.to_agent().unwrap().effort, None);
|
||||
}
|
||||
}
|
||||
|
||||
@ -637,6 +637,16 @@ pub enum DomainEvent {
|
||||
/// (the oldest agent orchestrates).
|
||||
orchestrator: Option<AgentId>,
|
||||
},
|
||||
/// The project's global context was written directly, as opposed to a
|
||||
/// non-orchestrator's change being filed as a proposal.
|
||||
ProjectContextUpdated {
|
||||
/// The project whose global context changed.
|
||||
project_id: ProjectId,
|
||||
/// The party that performed the write.
|
||||
by: ConversationParty,
|
||||
/// Epoch-milliseconds of the write.
|
||||
at_ms: i64,
|
||||
},
|
||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||
PtyOutput {
|
||||
/// The session.
|
||||
|
||||
@ -106,8 +106,8 @@ pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
|
||||
pub use template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
pub use profile::{
|
||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
|
||||
McpServerWiring, RateLimitPattern, SessionStrategy,
|
||||
resolve_effort, AgentProfile, ContextInjection, EffortOption, EffortSelection, EmbedderProfile,
|
||||
EmbedderStrategy, LivenessStrategy, McpServerWiring, RateLimitPattern, SessionStrategy,
|
||||
};
|
||||
|
||||
pub use mailbox::{
|
||||
@ -205,8 +205,8 @@ pub use permission::{
|
||||
opencode_permission_block, render_permission_summary, resolve as resolve_permissions,
|
||||
AgentPermissionOverride, Capability, CommandMatcher, CommandRule, Effect, EffectivePermissions,
|
||||
Glob, PathScope, PermissionError, PermissionProjection, PermissionProjector, PermissionRule,
|
||||
PermissionSet, Posture, ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey,
|
||||
PERMISSIONS_VERSION,
|
||||
PermissionSet, PermissionShadowReport, Posture, ProjectPermissions, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey, PERMISSIONS_VERSION,
|
||||
};
|
||||
|
||||
pub use system_permissions::{
|
||||
@ -217,13 +217,13 @@ pub use system_permissions::{
|
||||
};
|
||||
|
||||
pub use plugin::{
|
||||
ContentHash, CustomPluginLayout, PluginBundleUrl, PluginCapability, PluginCommandId,
|
||||
PluginContributionSet, PluginDescriptor, PluginError, PluginId, PluginInstallSource,
|
||||
PluginLayoutContribution, PluginLayoutType, PluginLifecycleState, PluginManifest,
|
||||
PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec, PluginMcpStatus,
|
||||
PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef, PluginRegistry,
|
||||
PluginRegistryEntry, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion,
|
||||
RelativePath, RemovalOutcome, StagedPluginPackage,
|
||||
ContentHash, CustomPluginLayout, PluginActivationScope, PluginBundleUrl, PluginCapability,
|
||||
PluginCommandId, PluginContributionSet, PluginDescriptor, PluginError, PluginId,
|
||||
PluginInstallSource, PluginLayoutContribution, PluginLayoutType, PluginLifecycleState,
|
||||
PluginManifest, PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec,
|
||||
PluginMcpStatus, PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef,
|
||||
PluginRegistry, PluginRegistryEntry, PluginTopLevelMenuContribution, PluginTrustLevel,
|
||||
PluginVersion, RelativePath, RemovalOutcome, StagedPluginPackage,
|
||||
};
|
||||
|
||||
pub use sandbox::{
|
||||
@ -248,10 +248,10 @@ pub use ports::{
|
||||
MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
|
||||
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
||||
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
||||
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore,
|
||||
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
|
||||
RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
|
||||
SprintStoreError, StoreError, StructuredSessionEnvironment,
|
||||
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError,
|
||||
PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner,
|
||||
ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError,
|
||||
RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler,
|
||||
SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment,
|
||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, WindowStateStore,
|
||||
};
|
||||
|
||||
@ -194,6 +194,9 @@ pub struct OrchestratorRequest {
|
||||
/// `memory.write`, cadrage C7). Required by those actions, ignored otherwise.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
/// Optional optimistic-concurrency version for `context.update`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub if_match: Option<String>,
|
||||
/// Target memory note slug for the memory tools (`memory.read`/`memory.write`,
|
||||
/// cadrage C7). Required by `memory.write`; optional for `memory.read` (absent ⇒
|
||||
/// the aggregated index). Ignored by the other actions.
|
||||
@ -342,6 +345,16 @@ pub enum OrchestratorCommand {
|
||||
/// The proposing party (handshake identity).
|
||||
requester: ConversationParty,
|
||||
},
|
||||
/// Directly update the global project context. Strict, orchestrator-only
|
||||
/// counterpart to [`Self::ProposeContext`].
|
||||
UpdateProjectContext {
|
||||
/// The new Markdown body.
|
||||
content: String,
|
||||
/// Optional expected current version.
|
||||
if_match: Option<String>,
|
||||
/// The writing party (handshake identity).
|
||||
requester: ConversationParty,
|
||||
},
|
||||
/// Read a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7).
|
||||
/// `slug` absent = the aggregated `MEMORY.md` index; otherwise one note.
|
||||
ReadMemory {
|
||||
@ -500,6 +513,11 @@ impl OrchestratorRequest {
|
||||
content: self.require("content", action, self.content.as_deref())?,
|
||||
requester: self.requester_party(),
|
||||
}),
|
||||
"context.update" => Ok(OrchestratorCommand::UpdateProjectContext {
|
||||
content: self.require("content", action, self.content.as_deref())?,
|
||||
if_match: self.if_match.clone(),
|
||||
requester: self.requester_party(),
|
||||
}),
|
||||
"memory.read" => Ok(OrchestratorCommand::ReadMemory {
|
||||
slug: self.optional_slug(),
|
||||
requester: self.requester_party(),
|
||||
@ -1089,6 +1107,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_update_command_parses_content_and_if_match() {
|
||||
let uid = uuid::Uuid::from_u128(42);
|
||||
let r = req(&format!(
|
||||
r##"{{ "type":"context.update", "requestedBy":"{uid}", "content":"# body", "ifMatch":"abc123" }}"##
|
||||
));
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::UpdateProjectContext {
|
||||
content: "# body".to_owned(),
|
||||
if_match: Some("abc123".to_owned()),
|
||||
requester: ConversationParty::agent(AgentId::from_uuid(uid)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_update_requires_content() {
|
||||
let missing = req(r#"{ "type":"context.update", "ifMatch":"abc123" }"#);
|
||||
assert_eq!(
|
||||
missing.validate(),
|
||||
Err(OrchestratorError::MissingField {
|
||||
action: "context.update".to_owned(),
|
||||
field: "content".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_read_optional_slug() {
|
||||
assert_eq!(
|
||||
|
||||
@ -542,6 +542,36 @@ impl ProjectPermissions {
|
||||
self.agent_permissions(agent_id),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reports agent-level blanket allows that are shadowed by project-level
|
||||
/// blanket denies for `agent_id`.
|
||||
///
|
||||
/// This is a diagnostic companion to [`Self::resolve_for`]. It does not
|
||||
/// participate in permission resolution and does not change deny-wins.
|
||||
#[must_use]
|
||||
pub fn shadow_for(&self, agent_id: AgentId) -> PermissionShadowReport {
|
||||
shadow_report(
|
||||
self.project_defaults.as_ref(),
|
||||
self.agent_permissions(agent_id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostic report for agent overrides that cannot loosen the project policy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionShadowReport {
|
||||
/// Agent blanket read allow is shadowed by a project blanket read deny.
|
||||
pub read: bool,
|
||||
/// Agent blanket write allow is shadowed by a project blanket write deny.
|
||||
pub write: bool,
|
||||
/// Agent blanket delete allow is shadowed by a project blanket delete deny.
|
||||
pub delete: bool,
|
||||
/// Agent blanket bash allow is shadowed by a project blanket bash deny.
|
||||
pub execute_bash: bool,
|
||||
/// Agent fallback choice is looser than the resolved project-tightened
|
||||
/// fallback.
|
||||
pub fallback: bool,
|
||||
}
|
||||
|
||||
/// The normalised, flattened output of [`resolve`] — the **sole input** of the
|
||||
@ -713,6 +743,72 @@ pub fn resolve(
|
||||
Some(EffectivePermissions { rules, fallback })
|
||||
}
|
||||
|
||||
/// Reports agent-level blanket allows shadowed by project-level blanket denies.
|
||||
///
|
||||
/// This is deliberately **not** a general glob-overlap solver. It is shaped to
|
||||
/// the current UI contract: file capabilities are considered blanket only when
|
||||
/// the rule has exactly one glob, `"**"`; bash is considered blanket only when
|
||||
/// the rule has no command matchers. Scoped rules are ignored by this diagnostic
|
||||
/// even though normal [`resolve`] and decision methods still honour them.
|
||||
#[must_use]
|
||||
pub fn shadow_report(
|
||||
project: Option<&PermissionSet>,
|
||||
agent: Option<&PermissionSet>,
|
||||
) -> PermissionShadowReport {
|
||||
let Some(agent) = agent else {
|
||||
return PermissionShadowReport::default();
|
||||
};
|
||||
|
||||
let shadowed = |capability| {
|
||||
blanket_effect(project, capability, BlanketLookupMode::DenyWins) == Some(Effect::Deny)
|
||||
&& blanket_effect(Some(agent), capability, BlanketLookupMode::AllowWins)
|
||||
== Some(Effect::Allow)
|
||||
};
|
||||
let fallback = resolve(project, Some(agent))
|
||||
.is_some_and(|resolved| agent.fallback() != resolved.fallback());
|
||||
|
||||
PermissionShadowReport {
|
||||
read: shadowed(Capability::Read),
|
||||
write: shadowed(Capability::Write),
|
||||
delete: shadowed(Capability::Delete),
|
||||
execute_bash: shadowed(Capability::ExecuteBash),
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum BlanketLookupMode {
|
||||
DenyWins,
|
||||
AllowWins,
|
||||
}
|
||||
|
||||
fn blanket_effect(
|
||||
set: Option<&PermissionSet>,
|
||||
capability: Capability,
|
||||
mode: BlanketLookupMode,
|
||||
) -> Option<Effect> {
|
||||
let mut found = None;
|
||||
for rule in set?.rules() {
|
||||
if rule.capability() != capability || !is_blanket_rule(rule) {
|
||||
continue;
|
||||
}
|
||||
match (mode, rule.effect()) {
|
||||
(BlanketLookupMode::DenyWins, Effect::Deny) => return Some(Effect::Deny),
|
||||
(BlanketLookupMode::AllowWins, Effect::Allow) => return Some(Effect::Allow),
|
||||
_ => found = Some(rule.effect()),
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn is_blanket_rule(rule: &PermissionRule) -> bool {
|
||||
if rule.capability().is_bash() {
|
||||
return rule.commands().is_empty();
|
||||
}
|
||||
let globs = rule.paths().globs();
|
||||
globs.len() == 1 && globs[0].pattern() == "**"
|
||||
}
|
||||
|
||||
/// Renders a human-readable Markdown **summary** of the resolved policy, suitable
|
||||
/// for injection into an agent's context (lot LP4-0).
|
||||
///
|
||||
@ -1118,6 +1214,10 @@ mod tests {
|
||||
PathScope::new(patterns.iter().map(|s| s.to_string())).unwrap()
|
||||
}
|
||||
|
||||
fn blanket_file(capability: Capability, effect: Effect) -> PermissionRule {
|
||||
PermissionRule::file(capability, effect, path_scope(&["**"])).unwrap()
|
||||
}
|
||||
|
||||
// ---- VO construction & invariants -----------------------------------
|
||||
|
||||
#[test]
|
||||
@ -1398,6 +1498,155 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- shadow diagnostics (ticket #122) -------------------------------
|
||||
|
||||
#[test]
|
||||
fn shadow_report_flags_read_write_delete_bash_when_project_blanket_deny_beats_agent_blanket_allow(
|
||||
) {
|
||||
let project = PermissionSet::new(
|
||||
vec![
|
||||
blanket_file(Capability::Read, Effect::Deny),
|
||||
blanket_file(Capability::Write, Effect::Deny),
|
||||
blanket_file(Capability::Delete, Effect::Deny),
|
||||
PermissionRule::bash(Effect::Deny, vec![]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
let agent = PermissionSet::new(
|
||||
vec![
|
||||
blanket_file(Capability::Read, Effect::Allow),
|
||||
blanket_file(Capability::Write, Effect::Allow),
|
||||
blanket_file(Capability::Delete, Effect::Allow),
|
||||
PermissionRule::bash(Effect::Allow, vec![]),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
shadow_report(Some(&project), Some(&agent)),
|
||||
PermissionShadowReport {
|
||||
read: true,
|
||||
write: true,
|
||||
delete: true,
|
||||
execute_bash: true,
|
||||
fallback: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_report_false_when_project_has_no_deny_rule_for_capability() {
|
||||
let project = PermissionSet::new(
|
||||
vec![blanket_file(Capability::Read, Effect::Allow)],
|
||||
Posture::Ask,
|
||||
);
|
||||
let agent = PermissionSet::new(
|
||||
vec![blanket_file(Capability::Read, Effect::Allow)],
|
||||
Posture::Ask,
|
||||
);
|
||||
|
||||
assert_eq!(shadow_report(Some(&project), Some(&agent)).read, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_report_false_when_agent_rule_is_scoped_not_blanket() {
|
||||
let project = PermissionSet::new(
|
||||
vec![blanket_file(Capability::Write, Effect::Deny)],
|
||||
Posture::Ask,
|
||||
);
|
||||
let agent = PermissionSet::new(
|
||||
vec![
|
||||
PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["src/**"]))
|
||||
.unwrap(),
|
||||
],
|
||||
Posture::Ask,
|
||||
);
|
||||
|
||||
assert_eq!(shadow_report(Some(&project), Some(&agent)).write, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_report_false_when_agent_also_denies() {
|
||||
let project = PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Deny, vec![])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let agent = PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Deny, vec![])],
|
||||
Posture::Ask,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
shadow_report(Some(&project), Some(&agent)).execute_bash,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_report_fallback_true_when_project_fallback_stricter_than_agent_chosen_fallback() {
|
||||
let project = PermissionSet::new(vec![], Posture::Deny);
|
||||
let agent = PermissionSet::new(vec![], Posture::Allow);
|
||||
|
||||
assert!(shadow_report(Some(&project), Some(&agent)).fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_report_all_false_when_agent_is_none() {
|
||||
let project = PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Deny, vec![])],
|
||||
Posture::Deny,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
shadow_report(Some(&project), None),
|
||||
PermissionShadowReport::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_for_matches_free_function_via_project_permissions() {
|
||||
let agent = AgentId::new_random();
|
||||
let project = PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Deny, vec![])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let custom = PermissionSet::new(
|
||||
vec![PermissionRule::bash(Effect::Allow, vec![])],
|
||||
Posture::Ask,
|
||||
);
|
||||
let doc = ProjectPermissions::new(
|
||||
Some(project.clone()),
|
||||
vec![AgentPermissionOverride::new(agent, custom.clone())],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
doc.shadow_for(agent),
|
||||
shadow_report(Some(&project), Some(&custom))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_shadow_report_serialises_camel_case() {
|
||||
let report = PermissionShadowReport {
|
||||
read: false,
|
||||
write: false,
|
||||
delete: false,
|
||||
execute_bash: true,
|
||||
fallback: true,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(report).unwrap(),
|
||||
serde_json::json!({
|
||||
"read": false,
|
||||
"write": false,
|
||||
"delete": false,
|
||||
"executeBash": true,
|
||||
"fallback": true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ---- LP3: ProjectorKey serde + PermissionProjection invariant ------
|
||||
|
||||
#[test]
|
||||
|
||||
@ -288,6 +288,22 @@ pub enum PluginCapability {
|
||||
Tooling,
|
||||
}
|
||||
|
||||
/// Manifest-declared runtime activation scope.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PluginActivationScope {
|
||||
/// Activate at app bootstrap, without requiring a focused project.
|
||||
App,
|
||||
/// Wait until a project is focused before the first activation.
|
||||
Project,
|
||||
}
|
||||
|
||||
impl Default for PluginActivationScope {
|
||||
fn default() -> Self {
|
||||
Self::App
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin command id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
@ -495,6 +511,9 @@ pub struct PluginManifest {
|
||||
/// Capabilities.
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<PluginCapability>,
|
||||
/// Activation scope. Missing in older manifests means app-level activation.
|
||||
#[serde(default)]
|
||||
pub activation_scope: PluginActivationScope,
|
||||
/// Contributions.
|
||||
pub contributes: PluginContributionSet,
|
||||
}
|
||||
@ -693,4 +712,13 @@ mod tests {
|
||||
serde_json::json!(["ui", "mcp", "tooling"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_activation_scope_defaults_to_app_and_serializes_public_names() {
|
||||
assert_eq!(PluginActivationScope::default(), PluginActivationScope::App);
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginActivationScope::Project).unwrap(),
|
||||
serde_json::json!("project")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -386,6 +386,48 @@ pub enum PluginMcpError {
|
||||
Process(String),
|
||||
}
|
||||
|
||||
/// Plugin-owned storage errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum PluginStorageError {
|
||||
/// Invalid key or value.
|
||||
#[error("plugin storage invalid input: {0}")]
|
||||
Invalid(String),
|
||||
/// Filesystem failure.
|
||||
#[error("plugin storage I/O error: {0}")]
|
||||
Io(String),
|
||||
/// Serialization failure.
|
||||
#[error("plugin storage serialization error: {0}")]
|
||||
Serialization(String),
|
||||
}
|
||||
|
||||
/// Store for plugin-owned key/value JSON data under app-data `plugins/data/<pluginId>/`.
|
||||
#[async_trait]
|
||||
pub trait PluginStorageStore: Send + Sync {
|
||||
/// Reads one plugin-owned value.
|
||||
async fn get(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
) -> Result<Option<Value>, PluginStorageError>;
|
||||
|
||||
/// Writes one plugin-owned value.
|
||||
async fn set(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
value: Value,
|
||||
) -> Result<(), PluginStorageError>;
|
||||
|
||||
/// Deletes one plugin-owned value.
|
||||
async fn delete(&self, plugin_id: &PluginId, key: &str) -> Result<bool, PluginStorageError>;
|
||||
|
||||
/// Purges every plugin-owned value for one plugin.
|
||||
async fn purge_plugin(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStorageError>;
|
||||
}
|
||||
|
||||
/// Store for installed plugin packages under the global app data directory.
|
||||
#[async_trait]
|
||||
pub trait PluginPackageStore: Send + Sync {
|
||||
|
||||
@ -846,6 +846,53 @@ fn toml_string(s: &str) -> String {
|
||||
json_string(s)
|
||||
}
|
||||
|
||||
/// One effort/reasoning preset a profile natively exposes to the UI.
|
||||
/// Declaration order is light to deep and is never reordered by consumers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EffortOption {
|
||||
/// Raw value forwarded to the CLI/session (e.g. Codex's `"medium"`).
|
||||
pub value: String,
|
||||
/// Human-readable label for the UI droplist.
|
||||
pub label: String,
|
||||
/// Optional short description.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
/// A per-agent effort choice, preserving whether the value came from a profile
|
||||
/// preset or free text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum EffortSelection {
|
||||
/// Picked from the profile's declared `effort_options`.
|
||||
Preset(String),
|
||||
/// Freehand value.
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl EffortSelection {
|
||||
/// Raw effort value forwarded to the session, regardless of provenance.
|
||||
#[must_use]
|
||||
pub fn value(&self) -> &str {
|
||||
match self {
|
||||
Self::Preset(value) | Self::Custom(value) => value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the raw effort value forwarded to a session launch: the agent's
|
||||
/// explicit selection wins; otherwise the profile's static default.
|
||||
#[must_use]
|
||||
pub fn resolve_effort(
|
||||
profile_default: Option<&str>,
|
||||
agent_selection: Option<&EffortSelection>,
|
||||
) -> Option<String> {
|
||||
agent_selection
|
||||
.map(|selection| selection.value().to_owned())
|
||||
.or_else(|| profile_default.map(str::to_owned))
|
||||
}
|
||||
|
||||
/// Declarative runtime configuration for one AI CLI.
|
||||
///
|
||||
/// Invariants:
|
||||
@ -914,6 +961,10 @@ pub struct AgentProfile {
|
||||
/// conserve le défaut natif de la CLI ; seules les sessions Codex le consomment.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_reasoning_effort: Option<String>,
|
||||
/// Effort/reasoning presets this profile natively exposes (ticket #131).
|
||||
/// Empty means the provider declares none.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub effort_options: Vec<EffortOption>,
|
||||
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
|
||||
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
|
||||
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
|
||||
@ -1122,6 +1173,7 @@ impl AgentProfile {
|
||||
opencode_provider: None,
|
||||
model: None,
|
||||
model_reasoning_effort: None,
|
||||
effort_options: Vec::new(),
|
||||
mcp: None,
|
||||
liveness: None,
|
||||
rate_limit_pattern: None,
|
||||
@ -1186,6 +1238,13 @@ impl AgentProfile {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe les presets d'effort exposés par ce profil.
|
||||
#[must_use]
|
||||
pub fn with_effort_options(mut self, options: Vec<EffortOption>) -> Self {
|
||||
self.effort_options = options;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils sans MCP ne l'appellent simplement pas.
|
||||
@ -1519,6 +1578,88 @@ mod mcp_tests {
|
||||
assert_eq!(back.model_reasoning_effort.as_deref(), Some("medium"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_option_round_trips_camel_case() {
|
||||
let option = EffortOption {
|
||||
value: "medium".to_owned(),
|
||||
label: "Medium".to_owned(),
|
||||
hint: Some("Balanced".to_owned()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&option).expect("serialise");
|
||||
assert_eq!(
|
||||
json,
|
||||
r#"{"value":"medium","label":"Medium","hint":"Balanced"}"#
|
||||
);
|
||||
let back: EffortOption = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(back, option);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_selection_preset_and_custom_value_accessor() {
|
||||
assert_eq!(EffortSelection::Preset("high".to_owned()).value(), "high");
|
||||
assert_eq!(
|
||||
EffortSelection::Custom("provider-x".to_owned()).value(),
|
||||
"provider-x"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_selection_tagged_serde_shape() {
|
||||
let preset = serde_json::to_string(&EffortSelection::Preset("medium".to_owned()))
|
||||
.expect("serialise");
|
||||
assert_eq!(preset, r#"{"kind":"preset","value":"medium"}"#);
|
||||
|
||||
let custom = serde_json::to_string(&EffortSelection::Custom("x-deep".to_owned()))
|
||||
.expect("serialise");
|
||||
assert_eq!(custom, r#"{"kind":"custom","value":"x-deep"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_effort_prefers_agent_selection_over_profile_default() {
|
||||
let selection = EffortSelection::Preset("high".to_owned());
|
||||
assert_eq!(
|
||||
resolve_effort(Some("low"), Some(&selection)).as_deref(),
|
||||
Some("high")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_effort_falls_back_to_profile_default_when_agent_selection_absent() {
|
||||
assert_eq!(resolve_effort(Some("low"), None).as_deref(), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_effort_none_when_neither_present() {
|
||||
assert_eq!(resolve_effort(None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_profile_new_effort_options_defaults_empty_and_serialises_omitted() {
|
||||
let profile = profile_without_mcp();
|
||||
assert!(profile.effort_options.is_empty());
|
||||
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("\"effortOptions\""),
|
||||
"a profile without effort options must keep the legacy JSON shape: {json}"
|
||||
);
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert!(back.effort_options.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_effort_options_builder_sets_field() {
|
||||
let option = EffortOption {
|
||||
value: "high".to_owned(),
|
||||
label: "High".to_owned(),
|
||||
hint: None,
|
||||
};
|
||||
let profile = profile_without_mcp().with_effort_options(vec![option.clone()]);
|
||||
|
||||
assert_eq!(profile.effort_options, vec![option]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_backend_consistency_rejects_both_configs_set() {
|
||||
let local = OpenCodeConfig::new(
|
||||
|
||||
@ -24,6 +24,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# AEAD encryption for the at-rest `SecretStore` adapter (ticket #92, lot B2).
|
||||
# Already vendored transitively (rustls/reqwest use it) — made an explicit direct
|
||||
# dependency here rather than adding a new crate to the tree.
|
||||
|
||||
@ -88,7 +88,9 @@ pub use orchestrator::{
|
||||
};
|
||||
pub use pair_attempt_limiter::InMemoryPairAttemptLimiter;
|
||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
||||
pub use plugin::{ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore};
|
||||
pub use plugin::{
|
||||
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
|
||||
};
|
||||
pub use process::{LocalEnvironmentReader, LocalProcessSpawner};
|
||||
pub use pty::PortablePtyAdapter;
|
||||
pub use ratelimit::RateLimitParser;
|
||||
|
||||
@ -68,6 +68,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
||||
"idea_stop_agent",
|
||||
"idea_update_context",
|
||||
"idea_context_propose",
|
||||
"idea_context_update",
|
||||
"idea_memory_write",
|
||||
"idea_workstate_set",
|
||||
"idea_create_skill",
|
||||
@ -166,7 +167,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
name: "idea_list_agents",
|
||||
description: "List the IdeA agents declared in the project's manifest. Returns the \
|
||||
agents inline as a JSON array (id, name, profile, origin, raw skills, \
|
||||
and resolved capabilities).",
|
||||
resolved capabilities, and isOrchestrator).",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@ -308,6 +309,23 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_context_update",
|
||||
description: "Directly update the global project context. Strict, orchestrator-only \
|
||||
counterpart to idea_context_propose: if you are not the project's \
|
||||
current orchestrator (see isOrchestrator on idea_list_agents), this call \
|
||||
fails and never silently files a proposal. Pass ifMatch from a prior \
|
||||
idea_context_read to reject the write if the context changed.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": { "type": "string", "description": "The new Markdown body." },
|
||||
"ifMatch": { "type": "string", "description": "Optional version from a prior idea_context_read; rejected if stale." }
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_memory_read",
|
||||
description: "Read project memory (under the file guard). Omit `slug` for the aggregated \
|
||||
@ -485,6 +503,16 @@ pub fn map_tool_call(
|
||||
content: s("content"),
|
||||
..base()
|
||||
},
|
||||
"idea_context_update" => {
|
||||
reject_unknown_keys(name, args, &["content", "ifMatch"])?;
|
||||
OrchestratorRequest {
|
||||
request_type: Some("context.update".to_owned()),
|
||||
requested_by: Some(requester.to_owned()),
|
||||
content: s("content"),
|
||||
if_match: s("ifMatch"),
|
||||
..base()
|
||||
}
|
||||
}
|
||||
"idea_memory_read" => OrchestratorRequest {
|
||||
request_type: Some("memory.read".to_owned()),
|
||||
requested_by: Some(requester.to_owned()),
|
||||
@ -556,6 +584,7 @@ fn base() -> OrchestratorRequest {
|
||||
result: None,
|
||||
ticket: None,
|
||||
content: None,
|
||||
if_match: None,
|
||||
slug: None,
|
||||
status: None,
|
||||
intent: None,
|
||||
@ -569,6 +598,20 @@ fn base() -> OrchestratorRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_unknown_keys(
|
||||
tool: &str,
|
||||
args: &serde_json::Map<String, Value>,
|
||||
allowed: &[&str],
|
||||
) -> Result<(), ToolMapError> {
|
||||
if args
|
||||
.keys()
|
||||
.any(|key| !allowed.iter().any(|allowed_key| allowed_key == key))
|
||||
{
|
||||
return Err(ToolMapError::BadArguments(tool.to_owned()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parses an optional `nodeId` JSON value into a [`domain::NodeId`], silently
|
||||
/// dropping a malformed/absent one (validation then rejects a `visible` launch
|
||||
/// missing its node, with a precise field error).
|
||||
@ -880,6 +923,51 @@ mod tests {
|
||||
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idea_context_update_tool_registered_and_maps_to_context_update_command() {
|
||||
assert_eq!(
|
||||
tool_access("idea_context_update"),
|
||||
Some(McpToolAccess::WriteAction)
|
||||
);
|
||||
assert!(catalogue()
|
||||
.into_iter()
|
||||
.any(|tool| tool.name == "idea_context_update"));
|
||||
|
||||
let cmd = map_tool_call(
|
||||
"idea_context_update",
|
||||
&json!({ "content": "# body", "ifMatch": "abc123" }),
|
||||
REQ,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cmd,
|
||||
OrchestratorCommand::UpdateProjectContext {
|
||||
content: "# body".to_owned(),
|
||||
if_match: Some("abc123".to_owned()),
|
||||
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
|
||||
uuid::Uuid::parse_str(REQ).unwrap()
|
||||
)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idea_context_update_requires_content_and_rejects_unknown_fields() {
|
||||
let missing = map_tool_call("idea_context_update", &json!({ "ifMatch": "abc123" }), REQ);
|
||||
assert!(matches!(missing, Err(ToolMapError::Invalid(_))));
|
||||
|
||||
let unknown = map_tool_call(
|
||||
"idea_context_update",
|
||||
&json!({ "content": "# body", "target": "Dev" }),
|
||||
REQ,
|
||||
);
|
||||
assert_eq!(
|
||||
unknown,
|
||||
Err(ToolMapError::BadArguments("idea_context_update".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_read_and_write_map_to_their_commands() {
|
||||
assert_eq!(
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
@ -11,7 +11,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
LocalPath, PluginManifestBytes, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
|
||||
PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
||||
PluginRegistryError, PluginRegistryStore, PluginStorageError, PluginStorageStore,
|
||||
PluginStoreError,
|
||||
};
|
||||
use domain::{
|
||||
ContentHash, PluginBundleUrl, PluginId, PluginInstallSource, PluginMcpServerSpec,
|
||||
@ -23,6 +24,14 @@ use tokio::process::Child;
|
||||
|
||||
const REGISTRY_FILE: &str = "registry.json";
|
||||
const MANIFEST_FILE: &str = "idea-plugin.json";
|
||||
const STORAGE_ENTRIES_DIR: &str = "entries";
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PluginStorageEntry {
|
||||
key: String,
|
||||
value: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Filesystem package store under app-data `plugins/`.
|
||||
#[derive(Debug, Clone)]
|
||||
@ -151,18 +160,7 @@ impl PluginPackageStore for FsPluginPackageStore {
|
||||
archive: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
let stage = self.stage_root()?;
|
||||
let status = std::process::Command::new("unzip")
|
||||
.arg("-q")
|
||||
.arg(archive.as_str())
|
||||
.arg("-d")
|
||||
.arg(&stage)
|
||||
.status()
|
||||
.map_err(|e| PluginStoreError::Io(format!("failed to run unzip: {e}")))?;
|
||||
if !status.success() {
|
||||
return Err(PluginStoreError::Format(format!(
|
||||
"unzip exited with status {status}"
|
||||
)));
|
||||
}
|
||||
extract_archive_confined(Path::new(archive.as_str()), &stage)?;
|
||||
ensure_manifest(&stage)?;
|
||||
let content_hash = hash_dir(&stage)?;
|
||||
Ok(StagedPluginPackage {
|
||||
@ -278,6 +276,43 @@ fn ensure_manifest(root: &Path) -> Result<(), PluginStoreError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_archive_confined(archive: &Path, stage: &Path) -> Result<(), PluginStoreError> {
|
||||
let file = fs::File::open(archive).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
let mut archive = zip::ZipArchive::new(file)
|
||||
.map_err(|e| PluginStoreError::Format(format!("invalid zip archive: {e}")))?;
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.map_err(|e| PluginStoreError::Format(format!("invalid zip entry: {e}")))?;
|
||||
let entry_name = entry.name().to_owned();
|
||||
let enclosed = entry.enclosed_name().ok_or_else(|| {
|
||||
PluginStoreError::Invalid(format!("archive entry escapes plugin root: {entry_name}"))
|
||||
})?;
|
||||
if entry
|
||||
.unix_mode()
|
||||
.is_some_and(|mode| mode & 0o170000 == 0o120000)
|
||||
{
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"archive entry symlinks are not allowed: {entry_name}"
|
||||
)));
|
||||
}
|
||||
let destination = stage.join(&enclosed);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
}
|
||||
let mut out =
|
||||
fs::File::create(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
io::copy(&mut entry, &mut out).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
out.flush()
|
||||
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
|
||||
fs::create_dir_all(target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
for entry in fs::read_dir(source).map_err(|e| PluginStoreError::Io(e.to_string()))? {
|
||||
@ -290,6 +325,16 @@ fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
|
||||
copy_dir_all(&entry.path(), &dest)?;
|
||||
} else if ty.is_file() {
|
||||
fs::copy(entry.path(), dest).map_err(|e| PluginStoreError::Io(e.to_string()))?;
|
||||
} else if ty.is_symlink() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin source contains symlink: {}",
|
||||
entry.path().display()
|
||||
)));
|
||||
} else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin source contains unsupported entry: {}",
|
||||
entry.path().display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@ -329,6 +374,16 @@ fn collect_files(root: &Path, files: &mut Vec<PathBuf>) -> Result<(), PluginStor
|
||||
collect_files(&entry.path(), files)?;
|
||||
} else if ty.is_file() {
|
||||
files.push(entry.path());
|
||||
} else if ty.is_symlink() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin package contains symlink: {}",
|
||||
entry.path().display()
|
||||
)));
|
||||
} else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin package contains unsupported entry: {}",
|
||||
entry.path().display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@ -354,6 +409,97 @@ impl FsPluginRegistryStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem plugin-owned storage under app-data `plugins/data/<pluginId>/`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FsPluginStorageStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl FsPluginStorageStore {
|
||||
/// Builds the store.
|
||||
#[must_use]
|
||||
pub fn new(app_data_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
root: app_data_dir.into().join("plugins").join("data"),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_dir(&self, plugin_id: &PluginId) -> PathBuf {
|
||||
self.root.join(plugin_id.as_str())
|
||||
}
|
||||
|
||||
fn entry_path(&self, plugin_id: &PluginId, key: &str) -> PathBuf {
|
||||
let digest = Sha256::digest(key.as_bytes());
|
||||
self.plugin_dir(plugin_id)
|
||||
.join(STORAGE_ENTRIES_DIR)
|
||||
.join(format!("{}.json", hex::encode(digest)))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginStorageStore for FsPluginStorageStore {
|
||||
async fn get(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, PluginStorageError> {
|
||||
let path = self.entry_path(plugin_id, key);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = fs::read(&path).map_err(|e| PluginStorageError::Io(e.to_string()))?;
|
||||
let entry: PluginStorageEntry = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| PluginStorageError::Serialization(e.to_string()))?;
|
||||
if entry.key == key {
|
||||
Ok(Some(entry.value))
|
||||
} else {
|
||||
Err(PluginStorageError::Invalid(
|
||||
"plugin storage key hash collision".to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), PluginStorageError> {
|
||||
let path = self.entry_path(plugin_id, key);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| PluginStorageError::Io(e.to_string()))?;
|
||||
}
|
||||
let entry = PluginStorageEntry {
|
||||
key: key.to_owned(),
|
||||
value,
|
||||
};
|
||||
let bytes = serde_json::to_vec_pretty(&entry)
|
||||
.map_err(|e| PluginStorageError::Serialization(e.to_string()))?;
|
||||
fs::write(path, bytes).map_err(|e| PluginStorageError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
async fn delete(&self, plugin_id: &PluginId, key: &str) -> Result<bool, PluginStorageError> {
|
||||
let path = self.entry_path(plugin_id, key);
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(e) => Err(PluginStorageError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn purge_plugin(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStorageError> {
|
||||
let dir = self.plugin_dir(plugin_id);
|
||||
match fs::remove_dir_all(dir) {
|
||||
Ok(()) => Ok(RemovalOutcome::Removed),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemovalOutcome::NotFound),
|
||||
Err(e) => Err(PluginStorageError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginRegistryStore for FsPluginRegistryStore {
|
||||
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
|
||||
@ -574,6 +720,99 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
struct ZipEntrySpec<'a> {
|
||||
name: &'a str,
|
||||
contents: &'a [u8],
|
||||
unix_mode: Option<u32>,
|
||||
}
|
||||
|
||||
fn write_u16(out: &mut Vec<u8>, value: u16) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_u32(out: &mut Vec<u8>, value: u32) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn crc32(bytes: &[u8]) -> u32 {
|
||||
let mut crc = 0xffff_ffffu32;
|
||||
for &byte in bytes {
|
||||
crc ^= u32::from(byte);
|
||||
for _ in 0..8 {
|
||||
let mask = (crc & 1).wrapping_neg();
|
||||
crc = (crc >> 1) ^ (0xedb8_8320 & mask);
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
fn write_zip(path: &Path, entries: &[ZipEntrySpec<'_>]) {
|
||||
let mut file = fs::File::create(path).unwrap();
|
||||
let mut central = Vec::new();
|
||||
let mut offset = 0u32;
|
||||
for entry in entries {
|
||||
let name = entry.name.as_bytes();
|
||||
let data = entry.contents;
|
||||
let crc = crc32(data);
|
||||
let local_size = 30u32 + name.len() as u32 + data.len() as u32;
|
||||
|
||||
let mut local = Vec::new();
|
||||
write_u32(&mut local, 0x0403_4b50);
|
||||
write_u16(&mut local, 20);
|
||||
write_u16(&mut local, 0);
|
||||
write_u16(&mut local, 0);
|
||||
write_u16(&mut local, 0);
|
||||
write_u16(&mut local, 0);
|
||||
write_u32(&mut local, crc);
|
||||
write_u32(&mut local, data.len() as u32);
|
||||
write_u32(&mut local, data.len() as u32);
|
||||
write_u16(&mut local, name.len() as u16);
|
||||
write_u16(&mut local, 0);
|
||||
local.extend_from_slice(name);
|
||||
local.extend_from_slice(data);
|
||||
file.write_all(&local).unwrap();
|
||||
|
||||
let mut header = Vec::new();
|
||||
let version_made_by = if entry.unix_mode.is_some() {
|
||||
(3u16 << 8) | 20
|
||||
} else {
|
||||
20
|
||||
};
|
||||
let external_attributes = entry.unix_mode.unwrap_or(0) << 16;
|
||||
write_u32(&mut header, 0x0201_4b50);
|
||||
write_u16(&mut header, version_made_by);
|
||||
write_u16(&mut header, 20);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u32(&mut header, crc);
|
||||
write_u32(&mut header, data.len() as u32);
|
||||
write_u32(&mut header, data.len() as u32);
|
||||
write_u16(&mut header, name.len() as u16);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u16(&mut header, 0);
|
||||
write_u32(&mut header, external_attributes);
|
||||
write_u32(&mut header, offset);
|
||||
header.extend_from_slice(name);
|
||||
central.extend_from_slice(&header);
|
||||
offset += local_size;
|
||||
}
|
||||
file.write_all(¢ral).unwrap();
|
||||
let mut eocd = Vec::new();
|
||||
write_u32(&mut eocd, 0x0605_4b50);
|
||||
write_u16(&mut eocd, 0);
|
||||
write_u16(&mut eocd, 0);
|
||||
write_u16(&mut eocd, entries.len() as u16);
|
||||
write_u16(&mut eocd, entries.len() as u16);
|
||||
write_u32(&mut eocd, central.len() as u32);
|
||||
write_u32(&mut eocd, offset);
|
||||
write_u16(&mut eocd, 0);
|
||||
file.write_all(&eocd).unwrap();
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingBridge {
|
||||
started: Mutex<Vec<PluginMcpServerSpec>>,
|
||||
@ -671,35 +910,23 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn extracts_archive_without_path_escape() {
|
||||
if std::process::Command::new("zip")
|
||||
.arg("-h")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_err()
|
||||
|| std::process::Command::new("unzip")
|
||||
.arg("-h")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let app = temp_dir("archive-app");
|
||||
let source = temp_dir("archive-source");
|
||||
write_plugin(&source, "bundle");
|
||||
let archive_path = app.join("plugin.ideaplug");
|
||||
{
|
||||
let status = std::process::Command::new("zip")
|
||||
.arg("-qr")
|
||||
.arg(&archive_path)
|
||||
.arg(".")
|
||||
.current_dir(&source)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
}
|
||||
write_zip(
|
||||
&archive_path,
|
||||
&[
|
||||
ZipEntrySpec {
|
||||
name: "idea-plugin.json",
|
||||
contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
ZipEntrySpec {
|
||||
name: "dist/index.js",
|
||||
contents: b"bundle",
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
],
|
||||
);
|
||||
let store = FsPluginPackageStore::new(app.join("data"));
|
||||
let staged = store
|
||||
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
||||
@ -707,9 +934,142 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(PathBuf::from(staged.root).join(MANIFEST_FILE).exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_from_directory_rejects_source_symlink() {
|
||||
let app = temp_dir("symlink-app");
|
||||
let source = temp_dir("symlink-source");
|
||||
write_plugin(&source, "bundle");
|
||||
let outside = app.join("outside.txt");
|
||||
fs::write(&outside, "secret").unwrap();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&outside, source.join("dist/escape.txt")).unwrap();
|
||||
#[cfg(windows)]
|
||||
std::os::windows::fs::symlink_file(&outside, source.join("dist/escape.txt")).unwrap();
|
||||
let store = FsPluginPackageStore::new(&app);
|
||||
|
||||
let err = store
|
||||
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, PluginStoreError::Invalid(_)));
|
||||
assert!(err.to_string().contains("symlink"));
|
||||
assert!(!app.join("plugins/installed/dev.acme.test").exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
let _ = fs::remove_dir_all(source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_from_archive_rejects_parent_traversal_without_writing_outside_stage() {
|
||||
let app = temp_dir("traversal-app");
|
||||
let archive_path = app.join("plugin.ideaplug");
|
||||
write_zip(
|
||||
&archive_path,
|
||||
&[
|
||||
ZipEntrySpec {
|
||||
name: "../../../../outside.txt",
|
||||
contents: b"pwned",
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
ZipEntrySpec {
|
||||
name: "idea-plugin.json",
|
||||
contents: br#"{"ideaPluginManifestVersion":1}"#,
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
],
|
||||
);
|
||||
let store = FsPluginPackageStore::new(&app);
|
||||
|
||||
let err = store
|
||||
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, PluginStoreError::Invalid(_)));
|
||||
assert!(err.to_string().contains("escapes plugin root"));
|
||||
assert!(!app.join("outside.txt").exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_from_archive_rejects_symlink_entries() {
|
||||
let app = temp_dir("archive-symlink-app");
|
||||
let archive_path = app.join("plugin.ideaplug");
|
||||
write_zip(
|
||||
&archive_path,
|
||||
&[
|
||||
ZipEntrySpec {
|
||||
name: "idea-plugin.json",
|
||||
contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
|
||||
unix_mode: Some(0o100644),
|
||||
},
|
||||
ZipEntrySpec {
|
||||
name: "dist/link.js",
|
||||
contents: b"/tmp/outside.js",
|
||||
unix_mode: Some(0o120777),
|
||||
},
|
||||
],
|
||||
);
|
||||
let store = FsPluginPackageStore::new(&app);
|
||||
|
||||
let err = store
|
||||
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, PluginStoreError::Invalid(_)));
|
||||
assert!(err.to_string().contains("symlinks are not allowed"));
|
||||
assert!(!app.join("plugins/installed/dev.acme.test").exists());
|
||||
let _ = fs::remove_dir_all(app);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_storage_store_round_trips_deletes_and_purges_plugin_data() {
|
||||
let app = temp_dir("storage-app");
|
||||
let store = FsPluginStorageStore::new(&app);
|
||||
let plugin_id = PluginId::new("dev.acme.test").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
|
||||
None
|
||||
);
|
||||
store
|
||||
.set(
|
||||
&plugin_id,
|
||||
"helloPlugin.launches",
|
||||
serde_json::json!({"count": 1}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
|
||||
Some(serde_json::json!({"count": 1}))
|
||||
);
|
||||
assert!(app.join("plugins/data/dev.acme.test/entries").is_dir());
|
||||
|
||||
assert!(store
|
||||
.delete(&plugin_id, "helloPlugin.launches")
|
||||
.await
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
|
||||
None
|
||||
);
|
||||
store
|
||||
.set(&plugin_id, "helloPlugin.enabled", serde_json::json!(true))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.purge_plugin(&plugin_id).await.unwrap(),
|
||||
RemovalOutcome::Removed
|
||||
);
|
||||
assert!(!app.join("plugins/data/dev.acme.test").exists());
|
||||
|
||||
let _ = fs::remove_dir_all(app);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supervisor_delegates_stdio_servers_to_external_mcp_bridge() {
|
||||
let bridge = Arc::new(RecordingBridge::default());
|
||||
|
||||
@ -108,6 +108,7 @@ impl FakeContexts {
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
@ -97,6 +97,7 @@ impl FakeContexts {
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
effort: None,
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ use application::{
|
||||
ListPlugins, UninstallPlugin, UninstallPluginInput,
|
||||
};
|
||||
use infrastructure::{
|
||||
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore,
|
||||
ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
|
||||
TokioBroadcastEventBus,
|
||||
};
|
||||
|
||||
@ -39,6 +39,48 @@ fn sdk_hello_plugin_path() -> PathBuf {
|
||||
.join("sdk/IdeaSDK/examples/hello-plugin")
|
||||
}
|
||||
|
||||
fn materialize_sdk_hello_plugin() -> PathBuf {
|
||||
let source = sdk_hello_plugin_path();
|
||||
let root = temp_dir("hello-plugin-built");
|
||||
fs::create_dir_all(root.join("dist")).unwrap();
|
||||
fs::write(
|
||||
root.join("idea-plugin.json"),
|
||||
fs::read(source.join("idea-plugin.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("dist/index.js"),
|
||||
"export function activate() { return 'hello-plugin'; }",
|
||||
)
|
||||
.unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
fn write_multifile_plugin(root: &Path) {
|
||||
fs::create_dir_all(root.join("dist/core")).unwrap();
|
||||
fs::write(
|
||||
root.join("idea-plugin.json"),
|
||||
r#"{
|
||||
"ideaPluginManifestVersion": 1,
|
||||
"id": "dev.acme.multifile",
|
||||
"displayName": "Multifile Plugin",
|
||||
"version": "0.1.0",
|
||||
"main": "dist/index.js",
|
||||
"trustLevel": "full",
|
||||
"capabilities": ["ui"],
|
||||
"contributes": {}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("dist/index.js"),
|
||||
"import { answer } from './constants.js'; export default answer;",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("dist/constants.js"), "export const answer = 42;").unwrap();
|
||||
fs::write(root.join("dist/core/util.js"), "export const util = true;").unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installs_reference_fixture_and_loads_runtime_catalog() {
|
||||
let app_data = temp_dir("app-data");
|
||||
@ -100,7 +142,7 @@ async fn installs_reference_fixture_and_loads_runtime_catalog() {
|
||||
#[tokio::test]
|
||||
async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
|
||||
let app_data = temp_dir("hello-app-data");
|
||||
let hello_plugin = sdk_hello_plugin_path();
|
||||
let hello_plugin = materialize_sdk_hello_plugin();
|
||||
let packages = Arc::new(FsPluginPackageStore::new(&app_data));
|
||||
let registry = Arc::new(FsPluginRegistryStore::new(&app_data));
|
||||
let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0"));
|
||||
@ -152,14 +194,16 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
|
||||
assert_eq!(plugin.contributes.layouts[0].label.as_str(), "hello-world");
|
||||
|
||||
let _ = fs::remove_dir_all(app_data);
|
||||
let _ = fs::remove_dir_all(hello_plugin);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
|
||||
let app_data = temp_dir("hello-reinstall-app-data");
|
||||
let hello_plugin = sdk_hello_plugin_path();
|
||||
let hello_plugin = materialize_sdk_hello_plugin();
|
||||
let packages = Arc::new(FsPluginPackageStore::new(&app_data));
|
||||
let registry = Arc::new(FsPluginRegistryStore::new(&app_data));
|
||||
let storage = Arc::new(FsPluginStorageStore::new(&app_data));
|
||||
let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0"));
|
||||
let events = Arc::new(TokioBroadcastEventBus::new());
|
||||
let mcp = Arc::new(ExternalMcpPluginSupervisor::new());
|
||||
@ -170,7 +214,7 @@ async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
|
||||
events.clone(),
|
||||
mcp.clone(),
|
||||
);
|
||||
let uninstall = UninstallPlugin::new(packages.clone(), registry.clone(), events, mcp);
|
||||
let uninstall = UninstallPlugin::new(packages.clone(), storage, registry.clone(), events, mcp);
|
||||
|
||||
install
|
||||
.execute(hello_plugin.to_string_lossy().into_owned())
|
||||
@ -225,6 +269,76 @@ async fn uninstalls_then_reinstalls_sdk_hello_plugin_without_runtime_residue() {
|
||||
assert!(catalog.plugins[0].bundle_url.ends_with("/dist/index.js"));
|
||||
|
||||
let _ = fs::remove_dir_all(app_data);
|
||||
let _ = fs::remove_dir_all(hello_plugin);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninstall_multifile_plugin_removes_package_registry_and_runtime_residue() {
|
||||
let app_data = temp_dir("multifile-uninstall-app-data");
|
||||
let source = temp_dir("multifile-source");
|
||||
write_multifile_plugin(&source);
|
||||
let packages = Arc::new(FsPluginPackageStore::new(&app_data));
|
||||
let registry = Arc::new(FsPluginRegistryStore::new(&app_data));
|
||||
let storage = Arc::new(FsPluginStorageStore::new(&app_data));
|
||||
let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0"));
|
||||
let events = Arc::new(TokioBroadcastEventBus::new());
|
||||
let mcp = Arc::new(ExternalMcpPluginSupervisor::new());
|
||||
let install = InstallPluginFromDirectory::new(
|
||||
packages.clone(),
|
||||
registry.clone(),
|
||||
validator.clone(),
|
||||
events.clone(),
|
||||
mcp.clone(),
|
||||
);
|
||||
let uninstall = UninstallPlugin::new(packages.clone(), storage, registry.clone(), events, mcp);
|
||||
|
||||
install
|
||||
.execute(source.to_string_lossy().into_owned())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(app_data
|
||||
.join("plugins/installed/dev.acme.multifile/dist/constants.js")
|
||||
.is_file());
|
||||
assert!(app_data
|
||||
.join("plugins/installed/dev.acme.multifile/dist/core/util.js")
|
||||
.is_file());
|
||||
let plugin_data = app_data.join("plugins/data/dev.acme.multifile");
|
||||
fs::create_dir_all(&plugin_data).unwrap();
|
||||
fs::write(plugin_data.join("state.json"), r#"{"launches":1}"#).unwrap();
|
||||
|
||||
let uninstall_result = uninstall
|
||||
.execute(UninstallPluginInput {
|
||||
plugin_id: "dev.acme.multifile".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
uninstall_result.removal_outcome,
|
||||
domain::RemovalOutcome::Removed
|
||||
);
|
||||
assert!(!app_data
|
||||
.join("plugins/installed/dev.acme.multifile")
|
||||
.exists());
|
||||
assert!(!app_data.join("plugins/data/dev.acme.multifile").exists());
|
||||
assert!(
|
||||
ListPlugins::new(packages.clone(), registry.clone(), validator.clone())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
ListPluginRuntimeContributions::new(packages, registry, validator)
|
||||
.execute()
|
||||
.await
|
||||
.unwrap()
|
||||
.plugins
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(app_data);
|
||||
let _ = fs::remove_dir_all(source);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -52,10 +52,11 @@ use application::{
|
||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||
RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
|
||||
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
|
||||
UpdateAgentContextInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
|
||||
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
|
||||
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
||||
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
|
||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
|
||||
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||
WriteToTerminalInput,
|
||||
};
|
||||
use domain::ports::PtyHandle;
|
||||
use domain::IssueActor;
|
||||
@ -74,16 +75,16 @@ use backend::dto::{
|
||||
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||
CreateAgentRequestDto, CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
|
||||
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto,
|
||||
MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||
ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
|
||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
||||
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
|
||||
RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
|
||||
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
|
||||
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
||||
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto,
|
||||
@ -97,8 +98,9 @@ use backend::dto::{
|
||||
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
||||
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
|
||||
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
|
||||
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
|
||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||
};
|
||||
@ -2426,6 +2428,7 @@ async fn invoke(
|
||||
"update_agent_permissions" => {
|
||||
invoke_update_agent_permissions(&request.args, &state.app).await
|
||||
}
|
||||
"update_agent_effort" => invoke_update_agent_effort(&request.args, &state.app).await,
|
||||
"resolve_agent_permissions" => {
|
||||
invoke_resolve_agent_permissions(&request.args, &state.app).await
|
||||
}
|
||||
@ -3482,9 +3485,10 @@ async fn invoke_create_skill(args: &Value, state: &BackendCore) -> Result<Value,
|
||||
.create_skill
|
||||
.execute(CreateSkillInput {
|
||||
name: request.name,
|
||||
description: None,
|
||||
description: request.description,
|
||||
content: request.content,
|
||||
scope: request.scope,
|
||||
kind: request.kind,
|
||||
project_root: project.root,
|
||||
})
|
||||
.await
|
||||
@ -3620,6 +3624,22 @@ async fn invoke_update_agent_permissions(
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_update_agent_effort(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
let request = required_request::<UpdateAgentEffortRequestDto>("update_agent_effort", args)?;
|
||||
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||
let output = state
|
||||
.update_agent_effort
|
||||
.execute(UpdateAgentEffortInput {
|
||||
project,
|
||||
agent_id: parse_agent_id(&request.agent_id)?,
|
||||
effort: request.effort,
|
||||
})
|
||||
.await
|
||||
.map(|out| AgentDto::from_agent(out.agent))
|
||||
.map_err(ErrorDto::from)?;
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_resolve_agent_permissions(
|
||||
args: &Value,
|
||||
state: &BackendCore,
|
||||
@ -3634,7 +3654,7 @@ async fn invoke_resolve_agent_permissions(
|
||||
agent_id: parse_agent_id(&request.agent_id)?,
|
||||
})
|
||||
.await
|
||||
.map(|out| out.effective.map(EffectivePermissionsDto))
|
||||
.map(ResolveAgentPermissionsResponseDto::from)
|
||||
.map_err(ErrorDto::from)?;
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
@ -7895,6 +7915,7 @@ mod tests {
|
||||
"get_project_permissions",
|
||||
"update_project_permissions",
|
||||
"update_agent_permissions",
|
||||
"update_agent_effort",
|
||||
"resolve_agent_permissions",
|
||||
"get_project_system_permissions",
|
||||
"update_project_system_permissions",
|
||||
|
||||
@ -42,12 +42,13 @@ describe("TauriAgentGateway invoke payloads", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("list_agents / read / delete pass top-level args (no request wrapper)", async () => {
|
||||
it("list_agents / read / delete pass top-level args and unwrap read context DTO", async () => {
|
||||
const gw = new TauriAgentGateway();
|
||||
await gw.listAgents("p");
|
||||
expect(invoke).toHaveBeenCalledWith("list_agents", { projectId: "p" });
|
||||
|
||||
await gw.readContext("p", "a");
|
||||
invoke.mockResolvedValueOnce({ content: "# context" });
|
||||
await expect(gw.readContext("p", "a")).resolves.toBe("# context");
|
||||
expect(invoke).toHaveBeenCalledWith("read_agent_context", {
|
||||
projectId: "p",
|
||||
agentId: "a",
|
||||
@ -139,4 +140,25 @@ describe("TauriAgentGateway invoke payloads", () => {
|
||||
);
|
||||
expect(out.relaunchedSession).toBeUndefined();
|
||||
});
|
||||
|
||||
it("update_agent_effort wraps the nullable effort override in the request DTO", async () => {
|
||||
invoke.mockResolvedValueOnce({ id: "agent-2", effort: { kind: "preset", value: "high" } });
|
||||
await new TauriAgentGateway().updateAgentEffort("proj-1", "agent-2", {
|
||||
kind: "preset",
|
||||
value: "high",
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith("update_agent_effort", {
|
||||
request: {
|
||||
projectId: "proj-1",
|
||||
agentId: "agent-2",
|
||||
effort: { kind: "preset", value: "high" },
|
||||
},
|
||||
});
|
||||
|
||||
invoke.mockClear().mockResolvedValueOnce({ id: "agent-2" });
|
||||
await new TauriAgentGateway().updateAgentEffort("proj-1", "agent-2", null);
|
||||
expect(invoke).toHaveBeenCalledWith("update_agent_effort", {
|
||||
request: { projectId: "proj-1", agentId: "agent-2", effort: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,6 +16,8 @@ import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
AgentContextDocument,
|
||||
EffortSelection,
|
||||
ResumableAgent,
|
||||
TerminalSession,
|
||||
} from "@/domain";
|
||||
@ -105,8 +107,21 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
);
|
||||
}
|
||||
|
||||
updateAgentEffort(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
effort: EffortSelection | null,
|
||||
): Promise<Agent> {
|
||||
return invoke<Agent>("update_agent_effort", {
|
||||
request: { projectId, agentId, effort },
|
||||
});
|
||||
}
|
||||
|
||||
readContext(projectId: string, agentId: string): Promise<string> {
|
||||
return invoke<string>("read_agent_context", { projectId, agentId });
|
||||
return invoke<AgentContextDocument>("read_agent_context", {
|
||||
projectId,
|
||||
agentId,
|
||||
}).then((res) => res.content);
|
||||
}
|
||||
|
||||
async updateContext(
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { HttpAgentGateway } from "./streamGateways";
|
||||
import { HttpInvoker } from "./httpInvoker";
|
||||
import { HttpInvoker, type FetchLike } from "./httpInvoker";
|
||||
import { WsLiveClient, type WebSocketLike } from "./wsLiveClient";
|
||||
import { bytesToBase64 } from "./frames";
|
||||
|
||||
@ -45,6 +45,27 @@ function gateway(): { gw: HttpAgentGateway; sockets: FakeSocket[] } {
|
||||
return { gw, sockets };
|
||||
}
|
||||
|
||||
function httpAgentGateway(
|
||||
fetchImpl: FetchLike,
|
||||
): { gw: HttpAgentGateway; calls: { url: string; init: unknown }[] } {
|
||||
const calls: { url: string; init: unknown }[] = [];
|
||||
const recordingFetch: FetchLike = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return fetchImpl(url, init);
|
||||
};
|
||||
const ws = new WsLiveClient({
|
||||
wsUrl: "wss://host",
|
||||
socketFactory: () => new FakeSocket(),
|
||||
});
|
||||
return {
|
||||
gw: new HttpAgentGateway(
|
||||
new HttpInvoker({ baseUrl: "https://host", fetchImpl: recordingFetch }),
|
||||
ws,
|
||||
),
|
||||
calls,
|
||||
};
|
||||
}
|
||||
|
||||
async function replyToLast(
|
||||
socket: FakeSocket,
|
||||
index: number,
|
||||
@ -78,6 +99,26 @@ function attachedAck(
|
||||
const OPTS = { cwd: "/srv/app", rows: 24, cols: 80, nodeId: "node-1" };
|
||||
|
||||
describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
|
||||
it("readContext unwraps the read_agent_context DTO content over HTTP", async () => {
|
||||
const { gw, calls } = httpAgentGateway(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ content: "## Restored context" }),
|
||||
text: async () => JSON.stringify({ content: "## Restored context" }),
|
||||
}));
|
||||
|
||||
await expect(gw.readContext("proj-1", "agent-1")).resolves.toBe(
|
||||
"## Restored context",
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
const init = calls[0].init as { body: string };
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
command: "read_agent_context",
|
||||
args: { projectId: "proj-1", agentId: "agent-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => {
|
||||
const { gw, sockets } = gateway();
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
@ -48,6 +48,7 @@ import {
|
||||
WebPluginConfigGateway,
|
||||
WebPluginEventGateway,
|
||||
WebPluginGateway,
|
||||
WebPluginStorageGateway,
|
||||
WebPluginTaskGateway,
|
||||
WebPluginToolchainGateway,
|
||||
WebPluginWorkspaceGateway,
|
||||
@ -151,6 +152,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
||||
pluginToolchain: new WebPluginToolchainGateway(),
|
||||
pluginEvents: new WebPluginEventGateway(),
|
||||
pluginConfig: new WebPluginConfigGateway(),
|
||||
pluginStorage: new WebPluginStorageGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,6 @@ import type {
|
||||
Agent,
|
||||
AgentDrift,
|
||||
AgentProfile,
|
||||
EffectivePermissions,
|
||||
EmbedderEngines,
|
||||
EmbedderProfile,
|
||||
FirstRunState,
|
||||
@ -43,6 +42,7 @@ import type {
|
||||
ProjectSystemPermissions,
|
||||
ProfileAvailability,
|
||||
ProfileModelCatalog,
|
||||
ResolvedAgentPermissions,
|
||||
ResolvedAgentSystemPermissions,
|
||||
SystemPermissionSet,
|
||||
Skill,
|
||||
@ -286,7 +286,14 @@ export class HttpSkillGateway implements SkillGateway {
|
||||
}
|
||||
createSkill(input: CreateSkillInput): Promise<Skill> {
|
||||
return this.http.invoke<Skill>("create_skill", {
|
||||
request: { projectId: input.projectId, name: input.name, content: input.content, scope: input.scope },
|
||||
request: {
|
||||
projectId: input.projectId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
kind: input.kind,
|
||||
content: input.content,
|
||||
scope: input.scope,
|
||||
},
|
||||
});
|
||||
}
|
||||
updateSkill(projectId: string, scope: SkillScope, skillId: string, content: string): Promise<Skill> {
|
||||
@ -381,8 +388,8 @@ export class HttpPermissionGateway implements PermissionGateway {
|
||||
request: { projectId, agentId, permissions },
|
||||
});
|
||||
}
|
||||
resolveAgentPermissions(projectId: string, agentId: string): Promise<EffectivePermissions | null> {
|
||||
return this.http.invoke<EffectivePermissions | null>("resolve_agent_permissions", {
|
||||
resolveAgentPermissions(projectId: string, agentId: string): Promise<ResolvedAgentPermissions> {
|
||||
return this.http.invoke<ResolvedAgentPermissions>("resolve_agent_permissions", {
|
||||
request: { projectId, agentId },
|
||||
});
|
||||
}
|
||||
|
||||
@ -21,8 +21,10 @@
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
AgentContextDocument,
|
||||
AppExitWorkGuardState,
|
||||
DomainEvent,
|
||||
EffortSelection,
|
||||
HealthReport,
|
||||
ReplyChunk,
|
||||
ResumableAgent,
|
||||
@ -223,8 +225,19 @@ export class HttpAgentGateway implements AgentGateway {
|
||||
request: { projectId, agentId, profileId, rows, cols },
|
||||
});
|
||||
}
|
||||
updateAgentEffort(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
effort: EffortSelection | null,
|
||||
): Promise<Agent> {
|
||||
return this.http.invoke<Agent>("update_agent_effort", {
|
||||
request: { projectId, agentId, effort },
|
||||
});
|
||||
}
|
||||
readContext(projectId: string, agentId: string): Promise<string> {
|
||||
return this.http.invoke<string>("read_agent_context", { projectId, agentId });
|
||||
return this.http
|
||||
.invoke<AgentContextDocument>("read_agent_context", { projectId, agentId })
|
||||
.then((res) => res.content);
|
||||
}
|
||||
async updateContext(projectId: string, agentId: string, content: string): Promise<void> {
|
||||
await this.http.invoke("update_agent_context", { request: { projectId, agentId, content } });
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
import type {
|
||||
EmbeddedServerStatus,
|
||||
GatewayError,
|
||||
JsonValue,
|
||||
PluginAdmin,
|
||||
PluginCommandTask,
|
||||
PluginConfigDocument,
|
||||
@ -44,6 +45,9 @@ import type {
|
||||
PluginEventSubscribeInput,
|
||||
PluginEventUnsubscribeInput,
|
||||
PluginGateway,
|
||||
PluginStorageGateway,
|
||||
PluginStorageGetInput,
|
||||
PluginStorageSetInput,
|
||||
PluginProjectStructureQuery,
|
||||
PluginRunCommandInput,
|
||||
PluginTaskGateway,
|
||||
@ -263,3 +267,18 @@ export class WebPluginConfigGateway implements PluginConfigGateway {
|
||||
return unsupportedOnWeb("Plugin structured config documents");
|
||||
}
|
||||
}
|
||||
|
||||
/** Web stub: plugin-owned storage is owned by the desktop host app-data. */
|
||||
export class WebPluginStorageGateway implements PluginStorageGateway {
|
||||
async get(_input: PluginStorageGetInput): Promise<JsonValue | null> {
|
||||
return unsupportedOnWeb("Plugin storage");
|
||||
}
|
||||
|
||||
async set(_input: PluginStorageSetInput): Promise<void> {
|
||||
return unsupportedOnWeb("Plugin storage");
|
||||
}
|
||||
|
||||
async delete(_input: PluginStorageGetInput): Promise<boolean> {
|
||||
return unsupportedOnWeb("Plugin storage");
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,6 +40,7 @@ import { TauriPluginTaskGateway } from "./pluginTask";
|
||||
import { TauriPluginToolchainGateway } from "./pluginToolchain";
|
||||
import { TauriPluginEventGateway } from "./pluginEvents";
|
||||
import { TauriPluginConfigGateway } from "./pluginConfig";
|
||||
import { TauriPluginStorageGateway } from "./pluginStorage";
|
||||
|
||||
function notImplemented(what: string): never {
|
||||
const err: GatewayError = {
|
||||
@ -87,6 +88,7 @@ export function createTauriGateways(): Gateways {
|
||||
pluginToolchain: new TauriPluginToolchainGateway(),
|
||||
pluginEvents: new TauriPluginEventGateway(),
|
||||
pluginConfig: new TauriPluginConfigGateway(),
|
||||
pluginStorage: new TauriPluginStorageGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -114,4 +116,5 @@ export {
|
||||
TauriFocusedProjectGateway,
|
||||
LocalStorageUiPreferencesGateway,
|
||||
TauriPluginGateway,
|
||||
TauriPluginStorageGateway,
|
||||
};
|
||||
|
||||
@ -11,6 +11,7 @@ import type {
|
||||
AgentProfile,
|
||||
DiagnosticWarning,
|
||||
DomainEvent,
|
||||
EffortSelection,
|
||||
EmbedderEngines,
|
||||
EmbedderProfile,
|
||||
EmbeddedServerStatus,
|
||||
@ -37,7 +38,6 @@ import type {
|
||||
OpenCodeProviderCatalogEntry,
|
||||
ProfileModelCatalog,
|
||||
ProfileModelCatalogEntry,
|
||||
EffectivePermissions,
|
||||
PairedDevice,
|
||||
PairingCode,
|
||||
PermissionSet,
|
||||
@ -72,6 +72,7 @@ import type {
|
||||
ProjectSystemPermissions,
|
||||
ProfileAvailability,
|
||||
ResumableAgent,
|
||||
ResolvedAgentPermissions,
|
||||
ResolvedAgentSystemPermissions,
|
||||
ServerExposurePreview,
|
||||
ServerExposureSettings,
|
||||
@ -129,6 +130,9 @@ import type {
|
||||
PluginEventSubscribeInput,
|
||||
PluginEventUnsubscribeInput,
|
||||
PluginGateway,
|
||||
PluginStorageGateway,
|
||||
PluginStorageGetInput,
|
||||
PluginStorageSetInput,
|
||||
PluginProjectStructureQuery,
|
||||
PluginRunCommandInput,
|
||||
PluginTaskGateway,
|
||||
@ -603,6 +607,28 @@ export class MockAgentGateway implements AgentGateway {
|
||||
};
|
||||
}
|
||||
|
||||
async updateAgentEffort(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
effort: EffortSelection | null,
|
||||
): Promise<Agent> {
|
||||
const list = this.getAgents(projectId);
|
||||
const idx = list.findIndex((a) => a.id === agentId);
|
||||
if (idx === -1) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `agent ${agentId} not found in project ${projectId}`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
const next =
|
||||
effort === null
|
||||
? (({ effort: _dropped, ...rest }) => rest)(list[idx])
|
||||
: { ...list[idx], effort };
|
||||
list[idx] = next;
|
||||
return structuredClone(next);
|
||||
}
|
||||
|
||||
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
|
||||
|
||||
/**
|
||||
@ -2006,6 +2032,8 @@ export class MockSkillGateway implements SkillGateway {
|
||||
const skill: Skill = {
|
||||
id: `mock-skill-${this.seq}`,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
kind: input.kind ?? "workflow",
|
||||
contentMd: input.content,
|
||||
scope: input.scope,
|
||||
};
|
||||
@ -2431,14 +2459,18 @@ export class MockPermissionGateway implements PermissionGateway {
|
||||
async resolveAgentPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<EffectivePermissions | null> {
|
||||
): Promise<ResolvedAgentPermissions> {
|
||||
const doc = this.doc(projectId);
|
||||
const project = doc.projectDefaults;
|
||||
const agent = doc.agents?.find((entry) => entry.agentId === agentId)?.permissions;
|
||||
if (!project && !agent) return null;
|
||||
const shadowed = permissionShadowReport(project, agent);
|
||||
if (!project && !agent) return { effective: null, shadowed };
|
||||
return {
|
||||
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
|
||||
fallback: mostRestrictive(project?.fallback, agent?.fallback),
|
||||
effective: {
|
||||
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
|
||||
fallback: mostRestrictive(project?.fallback, agent?.fallback),
|
||||
},
|
||||
shadowed,
|
||||
};
|
||||
}
|
||||
|
||||
@ -3466,6 +3498,47 @@ function mostRestrictive(
|
||||
return rank[agent] >= rank[fallback] ? agent : fallback;
|
||||
}
|
||||
|
||||
function permissionShadowReport(project?: PermissionSet, agent?: PermissionSet) {
|
||||
const empty = {
|
||||
read: false,
|
||||
write: false,
|
||||
delete: false,
|
||||
executeBash: false,
|
||||
fallback: false,
|
||||
};
|
||||
if (!agent) return empty;
|
||||
const shadowed = (capability: "read" | "write" | "delete" | "executeBash") =>
|
||||
blanketEffect(project, capability, "deny") === "deny" &&
|
||||
blanketEffect(agent, capability, "allow") === "allow";
|
||||
return {
|
||||
read: shadowed("read"),
|
||||
write: shadowed("write"),
|
||||
delete: shadowed("delete"),
|
||||
executeBash: shadowed("executeBash"),
|
||||
fallback: agent.fallback !== mostRestrictive(project?.fallback, agent.fallback),
|
||||
};
|
||||
}
|
||||
|
||||
function blanketEffect(
|
||||
set: PermissionSet | undefined,
|
||||
capability: "read" | "write" | "delete" | "executeBash",
|
||||
wins: "allow" | "deny",
|
||||
) {
|
||||
let found: "allow" | "deny" | undefined;
|
||||
for (const rule of set?.rules ?? []) {
|
||||
if (rule.capability !== capability || !isBlanketRule(rule)) continue;
|
||||
if (rule.effect === wins) return rule.effect;
|
||||
found = rule.effect;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function isBlanketRule(rule: PermissionSet["rules"][number]) {
|
||||
if (rule.capability === "executeBash") return (rule.commands ?? []).length === 0;
|
||||
const paths = rule.paths ?? [];
|
||||
return paths.length === 1 && paths[0] === "**";
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory plugin store (ticket #43, F1). Mirrors the carnet contract closely
|
||||
* enough to develop/test F1-F4 without the backend (B1-B4, landing in
|
||||
@ -4016,6 +4089,38 @@ export class MockPluginConfigGateway implements PluginConfigGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory plugin-owned storage gateway for plugin runtime tests/dev.
|
||||
*/
|
||||
export class MockPluginStorageGateway implements PluginStorageGateway {
|
||||
private readonly values = new Map<string, JsonValue>();
|
||||
|
||||
private storageKey(input: PluginStorageGetInput): string {
|
||||
if (!input.pluginId.trim()) {
|
||||
const err: GatewayError = { code: "INVALID", message: "pluginId must not be empty" };
|
||||
throw err;
|
||||
}
|
||||
if (!input.key.trim()) {
|
||||
const err: GatewayError = { code: "INVALID", message: "key must not be empty" };
|
||||
throw err;
|
||||
}
|
||||
return `${input.pluginId}:${input.key}`;
|
||||
}
|
||||
|
||||
async get(input: PluginStorageGetInput): Promise<JsonValue | null> {
|
||||
const value = this.values.get(this.storageKey(input));
|
||||
return value === undefined ? null : cloneJson(value);
|
||||
}
|
||||
|
||||
async set(input: PluginStorageSetInput): Promise<void> {
|
||||
this.values.set(this.storageKey(input), cloneJson(input.value));
|
||||
}
|
||||
|
||||
async delete(input: PluginStorageGetInput): Promise<boolean> {
|
||||
return this.values.delete(this.storageKey(input));
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the full set of mock gateways. */
|
||||
export function createMockGateways(): Gateways {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
@ -4050,6 +4155,7 @@ export function createMockGateways(): Gateways {
|
||||
pluginToolchain: new MockPluginToolchainGateway(),
|
||||
pluginEvents: new MockPluginEventGateway(),
|
||||
pluginConfig: new MockPluginConfigGateway(),
|
||||
pluginStorage: new MockPluginStorageGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
EffectivePermissions,
|
||||
McpToolPolicy,
|
||||
PermissionSet,
|
||||
ProjectMcpToolPermissions,
|
||||
ProjectPermissions,
|
||||
ProjectSystemPermissions,
|
||||
ResolvedAgentPermissions,
|
||||
ResolvedAgentSystemPermissions,
|
||||
SystemPermissionSet,
|
||||
} from "@/domain";
|
||||
@ -40,8 +40,8 @@ export class TauriPermissionGateway implements PermissionGateway {
|
||||
resolveAgentPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<EffectivePermissions | null> {
|
||||
return invoke<EffectivePermissions | null>("resolve_agent_permissions", {
|
||||
): Promise<ResolvedAgentPermissions> {
|
||||
return invoke<ResolvedAgentPermissions>("resolve_agent_permissions", {
|
||||
request: { projectId, agentId },
|
||||
});
|
||||
}
|
||||
|
||||
26
frontend/src/adapters/pluginStorage.ts
Normal file
26
frontend/src/adapters/pluginStorage.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Tauri adapter for plugin-owned JSON storage (#139).
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { JsonValue } from "@/domain";
|
||||
import type {
|
||||
PluginStorageGateway,
|
||||
PluginStorageGetInput,
|
||||
PluginStorageSetInput,
|
||||
} from "@/ports";
|
||||
|
||||
export class TauriPluginStorageGateway implements PluginStorageGateway {
|
||||
get(input: PluginStorageGetInput): Promise<JsonValue | null> {
|
||||
return invoke<JsonValue | null>("plugin_storage_get", { input });
|
||||
}
|
||||
|
||||
async set(input: PluginStorageSetInput): Promise<void> {
|
||||
await invoke("plugin_storage_set", { input });
|
||||
}
|
||||
|
||||
delete(input: PluginStorageGetInput): Promise<boolean> {
|
||||
return invoke<boolean>("plugin_storage_delete", { input });
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,8 @@ export class TauriSkillGateway implements SkillGateway {
|
||||
request: {
|
||||
projectId: input.projectId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
kind: input.kind,
|
||||
content: input.content,
|
||||
scope: input.scope,
|
||||
},
|
||||
|
||||
@ -767,6 +767,21 @@ export interface EffectivePermissions {
|
||||
fallback: PermissionPosture;
|
||||
}
|
||||
|
||||
/** Agent override choices shadowed by stricter project defaults. */
|
||||
export interface PermissionShadowReport {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
delete: boolean;
|
||||
executeBash: boolean;
|
||||
fallback: boolean;
|
||||
}
|
||||
|
||||
/** Resolved file/bash permissions plus non-authoritative diagnostics. */
|
||||
export interface ResolvedAgentPermissions {
|
||||
effective: EffectivePermissions | null;
|
||||
shadowed: PermissionShadowReport;
|
||||
}
|
||||
|
||||
/** Wanted/effective network policy for system permissions. */
|
||||
export type NetworkPolicy = "allow" | "deny" | "ask";
|
||||
|
||||
@ -1151,6 +1166,21 @@ export interface ProfileModelCatalog {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** One native effort/reasoning preset declared by an AI profile. */
|
||||
export interface EffortOption {
|
||||
/** Raw value persisted/forwarded to the provider. */
|
||||
value: string;
|
||||
/** Human-readable label shown in selectors. */
|
||||
label: string;
|
||||
/** Optional short description from the profile declaration. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/** Per-agent effort override. `undefined`/`null` means profile default. */
|
||||
export type EffortSelection =
|
||||
| { kind: "preset"; value: string }
|
||||
| { kind: "custom"; value: string };
|
||||
|
||||
/**
|
||||
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
|
||||
* UUID string; `detect` is the optional detection command line.
|
||||
@ -1192,6 +1222,16 @@ export interface AgentProfile {
|
||||
* CLI's own default. OpenCode keeps its dedicated provider/local model fields.
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* Optional direct CLI reasoning effort configured on the profile. `undefined`
|
||||
* keeps the CLI/provider default.
|
||||
*/
|
||||
modelReasoningEffort?: string;
|
||||
/**
|
||||
* Native effort presets exposed by this profile, in declaration order from
|
||||
* light to deep. Empty/omitted means the provider declares no native options.
|
||||
*/
|
||||
effortOptions?: EffortOption[];
|
||||
}
|
||||
|
||||
/** Availability of a candidate profile after detection (mirror of the DTO). */
|
||||
@ -1233,6 +1273,13 @@ export interface Agent {
|
||||
synchronized: boolean;
|
||||
/** Skills assigned to this agent (injected into its convention file). */
|
||||
skills: SkillRef[];
|
||||
/** Per-agent effort override. Omitted for older manifests/profile default. */
|
||||
effort?: EffortSelection;
|
||||
}
|
||||
|
||||
/** Response DTO returned by `read_agent_context`; adapters unwrap `content`. */
|
||||
export interface AgentContextDocument {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1284,6 +1331,7 @@ export interface ResumableAgent {
|
||||
* across projects; `project` skills are specific to one project's `.ideai/`.
|
||||
*/
|
||||
export type SkillScope = "global" | "project";
|
||||
export type SkillKind = "workflow" | "reference";
|
||||
|
||||
/**
|
||||
* A reusable, model-agnostic workflow assignable to agents (mirror of the
|
||||
@ -1292,6 +1340,8 @@ export type SkillScope = "global" | "project";
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
kind: SkillKind;
|
||||
contentMd: string;
|
||||
scope: SkillScope;
|
||||
}
|
||||
@ -1749,6 +1799,14 @@ export interface PluginRuntimePlugin {
|
||||
publisher?: string;
|
||||
version: string;
|
||||
capabilities?: string[];
|
||||
/**
|
||||
* Runtime activation scope declared by the plugin manifest.
|
||||
*
|
||||
* Omitted by older manifests and treated as `"app"`: the bundle is activated
|
||||
* immediately at app bootstrap. `"project"` plugins are held pending until a
|
||||
* focused project exists, then activated once for the current app session.
|
||||
*/
|
||||
activationScope?: "app" | "project";
|
||||
bundleUrl: string;
|
||||
iconUrl?: string;
|
||||
contentHash: string;
|
||||
|
||||
@ -25,7 +25,13 @@ import { useAgents } from "./useAgents";
|
||||
import { correlateModelServerStatus } from "./modelServerLaunch";
|
||||
import { AgentLimitBadge } from "./AgentLimitBadge";
|
||||
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||
import type { ResolvedAgentSystemPermissions } from "@/domain";
|
||||
import type {
|
||||
Agent,
|
||||
AgentProfile,
|
||||
EffortOption,
|
||||
EffortSelection,
|
||||
ResolvedAgentSystemPermissions,
|
||||
} from "@/domain";
|
||||
|
||||
export interface AgentsPanelProps {
|
||||
/** The project whose agents to manage. */
|
||||
@ -237,7 +243,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
// Determine if a template is chosen → profile selector is hidden (template imposes it).
|
||||
const hasTemplate = newTemplateId !== "";
|
||||
|
||||
const profileLabel = (profile: import("@/domain").AgentProfile): string => {
|
||||
const profileLabel = (profile: AgentProfile): string => {
|
||||
const model =
|
||||
profile.model ??
|
||||
profile.opencode?.model ??
|
||||
@ -365,12 +371,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
const isSelected = a.id === vm.selectedAgentId;
|
||||
const isRunning = a.id === activeAgentId;
|
||||
const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id);
|
||||
const agentProfile = vm.profiles.find((p) => p.id === a.profileId) ?? null;
|
||||
const profileName =
|
||||
(() => {
|
||||
const p = vm.profiles.find((p) => p.id === a.profileId);
|
||||
return p ? profileLabel(p) : null;
|
||||
})() ??
|
||||
a.profileId;
|
||||
(agentProfile ? profileLabel(agentProfile) : null) ?? a.profileId;
|
||||
const agentDrift = drift.driftByAgentId.get(a.id);
|
||||
// Source of this agent's last orchestration delegation (mcp vs
|
||||
// file), if any has been observed. Absent ⇒ no badge.
|
||||
@ -480,6 +483,14 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<EffortSelector
|
||||
agent={a}
|
||||
profile={agentProfile}
|
||||
busy={vm.busy}
|
||||
onChange={(effort) =>
|
||||
void vm.updateAgentEffort(a.id, effort)
|
||||
}
|
||||
/>
|
||||
{agentDrift && (
|
||||
<Button
|
||||
size="sm"
|
||||
@ -706,6 +717,141 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const EFFORT_DEFAULT_VALUE = "__profile_default__";
|
||||
const EFFORT_CUSTOM_VALUE = "__custom_effort__";
|
||||
|
||||
const GENERIC_EFFORT_OPTIONS: EffortOption[] = [
|
||||
{
|
||||
value: "low",
|
||||
label: "Rapide (par défaut)",
|
||||
hint: "Fallback générique léger.",
|
||||
},
|
||||
{
|
||||
value: "medium",
|
||||
label: "Standard (par défaut)",
|
||||
hint: "Fallback générique équilibré.",
|
||||
},
|
||||
{
|
||||
value: "high",
|
||||
label: "Approfondi (par défaut)",
|
||||
hint: "Fallback générique profond.",
|
||||
},
|
||||
];
|
||||
|
||||
function rawEffortValue(selection: EffortSelection | undefined): string {
|
||||
return selection?.value ?? "";
|
||||
}
|
||||
|
||||
function EffortSelector({
|
||||
agent,
|
||||
profile,
|
||||
busy,
|
||||
onChange,
|
||||
}: {
|
||||
agent: Agent;
|
||||
profile: AgentProfile | null;
|
||||
busy: boolean;
|
||||
onChange: (effort: EffortSelection | null) => void;
|
||||
}) {
|
||||
const nativeOptions = profile?.effortOptions ?? [];
|
||||
const hasNativeOptions = nativeOptions.length > 0;
|
||||
const displayedOptions = hasNativeOptions
|
||||
? nativeOptions
|
||||
: GENERIC_EFFORT_OPTIONS;
|
||||
const [customText, setCustomText] = useState(rawEffortValue(agent.effort));
|
||||
const [forceCustom, setForceCustom] = useState(agent.effort?.kind === "custom");
|
||||
|
||||
useEffect(() => {
|
||||
setCustomText(rawEffortValue(agent.effort));
|
||||
setForceCustom(agent.effort?.kind === "custom");
|
||||
}, [agent.id, agent.effort]);
|
||||
|
||||
let selectedOption = EFFORT_DEFAULT_VALUE;
|
||||
if (forceCustom) {
|
||||
selectedOption = EFFORT_CUSTOM_VALUE;
|
||||
} else if (
|
||||
hasNativeOptions &&
|
||||
agent.effort?.kind === "preset" &&
|
||||
nativeOptions.some((option) => option.value === agent.effort?.value)
|
||||
) {
|
||||
selectedOption = `preset:${agent.effort.value}`;
|
||||
} else if (
|
||||
!hasNativeOptions &&
|
||||
agent.effort &&
|
||||
displayedOptions.some((option) => option.value === agent.effort?.value)
|
||||
) {
|
||||
selectedOption = `fallback:${agent.effort.value}`;
|
||||
} else if (agent.effort) {
|
||||
selectedOption = EFFORT_CUSTOM_VALUE;
|
||||
}
|
||||
|
||||
const customVisible = selectedOption === EFFORT_CUSTOM_VALUE;
|
||||
|
||||
function commitCustom() {
|
||||
const value = customText.trim();
|
||||
if (value.length === 0) return;
|
||||
if (agent.effort?.kind === "custom" && agent.effort.value === value) return;
|
||||
onChange({ kind: "custom", value });
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex min-w-[11rem] max-w-full flex-wrap items-center gap-1.5">
|
||||
<SmallDropdown
|
||||
aria-label={`effort for ${agent.name}`}
|
||||
value={selectedOption}
|
||||
disabled={busy}
|
||||
onChange={(value) => {
|
||||
if (value === EFFORT_DEFAULT_VALUE) {
|
||||
setForceCustom(false);
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
if (value === EFFORT_CUSTOM_VALUE) {
|
||||
setForceCustom(true);
|
||||
setCustomText(rawEffortValue(agent.effort));
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("preset:")) {
|
||||
setForceCustom(false);
|
||||
onChange({ kind: "preset", value: value.slice("preset:".length) });
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("fallback:")) {
|
||||
setForceCustom(false);
|
||||
onChange({ kind: "custom", value: value.slice("fallback:".length) });
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: EFFORT_DEFAULT_VALUE, label: "Effort: profil" },
|
||||
...displayedOptions.map((option) => ({
|
||||
value: `${hasNativeOptions ? "preset" : "fallback"}:${option.value}`,
|
||||
label: option.label,
|
||||
})),
|
||||
{ value: EFFORT_CUSTOM_VALUE, label: "Personnalisé" },
|
||||
]}
|
||||
/>
|
||||
{customVisible && (
|
||||
<Input
|
||||
aria-label={`custom effort for ${agent.name}`}
|
||||
value={customText}
|
||||
disabled={busy}
|
||||
placeholder="valeur brute"
|
||||
onChange={(event) => setCustomText(event.target.value)}
|
||||
onBlur={commitCustom}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitCustom();
|
||||
}
|
||||
}}
|
||||
className="h-8 min-w-[8rem] flex-1 px-2 text-xs"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkPermissionBadge({
|
||||
state,
|
||||
}: {
|
||||
|
||||
@ -241,6 +241,43 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reopening the agents panel reloads the saved context as textarea text", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
await agent.createAgent(PROJECT_ID, {
|
||||
name: "Reopen",
|
||||
profileId: "p1",
|
||||
initialContent: "initial",
|
||||
});
|
||||
const firstRender = renderPanel(agent);
|
||||
await waitForIdle();
|
||||
|
||||
let buttons = screen.getAllByRole("button", { name: /reopen/i });
|
||||
let rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
|
||||
fireEvent.click(rowBtn);
|
||||
|
||||
let textarea = await screen.findByLabelText("agent context");
|
||||
fireEvent.change(textarea, { target: { value: "persisted after reopen" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const agents = await agent.listAgents(PROJECT_ID);
|
||||
await expect(agent.readContext(PROJECT_ID, agents[0].id)).resolves.toBe(
|
||||
"persisted after reopen",
|
||||
);
|
||||
});
|
||||
|
||||
firstRender.unmount();
|
||||
renderPanel(agent);
|
||||
await waitForIdle();
|
||||
|
||||
buttons = screen.getAllByRole("button", { name: /reopen/i });
|
||||
rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
|
||||
fireEvent.click(rowBtn);
|
||||
|
||||
textarea = await screen.findByLabelText("agent context");
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("persisted after reopen");
|
||||
});
|
||||
|
||||
it("deleting an agent removes it from the list", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
await agent.createAgent(PROJECT_ID, {
|
||||
@ -492,6 +529,11 @@ async function seededProfiles(): Promise<MockProfileGateway> {
|
||||
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
|
||||
detect: null,
|
||||
cwdTemplate: "{projectRoot}",
|
||||
effortOptions: [
|
||||
{ value: "low", label: "Léger" },
|
||||
{ value: "medium", label: "Standard" },
|
||||
{ value: "high", label: "Profond" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "prof-2",
|
||||
@ -588,6 +630,123 @@ describe("AgentsPanel profile hot-swap (A2)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentsPanel effort selection (#131)", () => {
|
||||
it("shows native profile effort options in declaration order with Personnalisé last", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
await agent.createAgent(PROJECT_ID, { name: "Thinker", profileId: "prof-1" });
|
||||
const profile = await seededProfiles();
|
||||
|
||||
renderPanel(agent, profile);
|
||||
await waitForIdle();
|
||||
await screen.findByText("Thinker");
|
||||
|
||||
openDropdown("effort for Thinker");
|
||||
const labels = screen
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent);
|
||||
|
||||
expect(labels).toEqual([
|
||||
"Effort: profil",
|
||||
"Léger",
|
||||
"Standard",
|
||||
"Profond",
|
||||
"Personnalisé",
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists a native effort option as a preset", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const created = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Thinker",
|
||||
profileId: "prof-1",
|
||||
});
|
||||
const updateSpy = vi.spyOn(agent, "updateAgentEffort");
|
||||
|
||||
renderPanel(agent, await seededProfiles());
|
||||
await waitForIdle();
|
||||
await screen.findByText("Thinker");
|
||||
|
||||
chooseDropdownOption("effort for Thinker", "Profond");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, {
|
||||
kind: "preset",
|
||||
value: "high",
|
||||
});
|
||||
});
|
||||
const [updated] = await agent.listAgents(PROJECT_ID);
|
||||
expect(updated.effort).toEqual({ kind: "preset", value: "high" });
|
||||
});
|
||||
|
||||
it("shows generic default fallback options when the profile declares no native options", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const profile = new MockProfileGateway();
|
||||
await profile.configureProfiles([
|
||||
{
|
||||
id: "plain",
|
||||
name: "Plain provider",
|
||||
command: "plain-ai",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||
detect: null,
|
||||
cwdTemplate: "{projectRoot}",
|
||||
},
|
||||
]);
|
||||
const created = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Fallback",
|
||||
profileId: "plain",
|
||||
});
|
||||
|
||||
renderPanel(agent, profile);
|
||||
await waitForIdle();
|
||||
await screen.findByText("Fallback");
|
||||
|
||||
openDropdown("effort for Fallback");
|
||||
const labels = screen
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent);
|
||||
expect(labels).toEqual([
|
||||
"Effort: profil",
|
||||
"Rapide (par défaut)",
|
||||
"Standard (par défaut)",
|
||||
"Approfondi (par défaut)",
|
||||
"Personnalisé",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole("option", { name: "Standard (par défaut)" }));
|
||||
await waitFor(async () => {
|
||||
const [updated] = await agent.listAgents(PROJECT_ID);
|
||||
expect(updated.id).toBe(created.id);
|
||||
expect(updated.effort).toEqual({ kind: "custom", value: "medium" });
|
||||
});
|
||||
});
|
||||
|
||||
it("reveals an inline custom effort field and persists the free text", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const created = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Custom",
|
||||
profileId: "prof-1",
|
||||
});
|
||||
const updateSpy = vi.spyOn(agent, "updateAgentEffort");
|
||||
|
||||
renderPanel(agent, await seededProfiles());
|
||||
await waitForIdle();
|
||||
await screen.findByText("Custom");
|
||||
|
||||
chooseDropdownOption("effort for Custom", "Personnalisé");
|
||||
const input = screen.getByLabelText("custom effort for Custom");
|
||||
fireEvent.change(input, { target: { value: "x-provider-deep" } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, {
|
||||
kind: "custom",
|
||||
value: "x-provider-deep",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentsPanel live refresh on domain events", () => {
|
||||
it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
|
||||
@ -12,6 +12,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import type {
|
||||
Agent,
|
||||
AgentProfile,
|
||||
EffortSelection,
|
||||
GatewayError,
|
||||
ModelServerStatus,
|
||||
TerminalSession,
|
||||
@ -124,6 +125,11 @@ export interface AgentsViewModel {
|
||||
rows: number,
|
||||
cols: number,
|
||||
) => Promise<TerminalSession | undefined>;
|
||||
/** Sets or clears a per-agent effort override. */
|
||||
updateAgentEffort: (
|
||||
agentId: string,
|
||||
effort: EffortSelection | null,
|
||||
) => Promise<void>;
|
||||
/** Deletes an agent; deselects if it was selected. */
|
||||
deleteAgent: (agentId: string) => Promise<void>;
|
||||
/**
|
||||
@ -427,6 +433,26 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
[agent, projectId, refreshLiveAgents],
|
||||
);
|
||||
|
||||
const updateAgentEffort = useCallback(
|
||||
async (agentId: string, effort: EffortSelection | null): Promise<void> => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await agent.updateAgentEffort(projectId, agentId, effort);
|
||||
setAgents((prev) =>
|
||||
prev.map((candidate) =>
|
||||
candidate.id === updated.id ? updated : candidate,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[agent, projectId],
|
||||
);
|
||||
|
||||
const deleteAgent = useCallback(
|
||||
async (agentId: string) => {
|
||||
setBusy(true);
|
||||
@ -544,6 +570,7 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
selectAgent,
|
||||
saveContext,
|
||||
changeAgentProfile,
|
||||
updateAgentEffort,
|
||||
deleteAgent,
|
||||
launchAgent,
|
||||
stopAgent,
|
||||
|
||||
@ -8,8 +8,10 @@ import type {
|
||||
PermissionPosture,
|
||||
PermissionRule,
|
||||
PermissionSet,
|
||||
PermissionShadowReport,
|
||||
ProjectPermissions,
|
||||
ProjectSystemPermissions,
|
||||
ResolvedAgentPermissions,
|
||||
ResolvedAgentSystemPermissions,
|
||||
SystemPermissionSet,
|
||||
} from "@/domain";
|
||||
@ -27,6 +29,7 @@ export interface PolicyDraft {
|
||||
export interface AgentPermissionRow {
|
||||
agent: Agent;
|
||||
override: PermissionSet | null;
|
||||
shadowed: PermissionShadowReport | null;
|
||||
systemOverride: SystemPermissionSet | null;
|
||||
resolvedSystem: ResolvedAgentSystemPermissions | null;
|
||||
}
|
||||
@ -132,6 +135,9 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
const [resolvedSystemByAgent, setResolvedSystemByAgent] = useState<
|
||||
Record<string, ResolvedAgentSystemPermissions>
|
||||
>({});
|
||||
const [resolvedPermissionsByAgent, setResolvedPermissionsByAgent] = useState<
|
||||
Record<string, ResolvedAgentPermissions>
|
||||
>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -156,9 +162,29 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
}
|
||||
}),
|
||||
);
|
||||
const resolvedPermissionPairs = await Promise.all(
|
||||
agentList.map(async (candidate) => {
|
||||
try {
|
||||
return [
|
||||
candidate.id,
|
||||
await permission.resolveAgentPermissions(projectId, candidate.id),
|
||||
] as const;
|
||||
} catch {
|
||||
return [candidate.id, null] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
setAgents(agentList);
|
||||
setDocument(permissionDoc);
|
||||
setSystemDocument(systemPermissionDoc);
|
||||
setResolvedPermissionsByAgent(
|
||||
Object.fromEntries(
|
||||
resolvedPermissionPairs.filter(
|
||||
(pair): pair is readonly [string, ResolvedAgentPermissions] =>
|
||||
pair[1] !== null,
|
||||
),
|
||||
),
|
||||
);
|
||||
setResolvedSystemByAgent(
|
||||
Object.fromEntries(
|
||||
resolvedPairs.filter(
|
||||
@ -191,10 +217,17 @@ export function usePermissions(projectId: string): PermissionsViewModel {
|
||||
return agents.map((candidate) => ({
|
||||
agent: candidate,
|
||||
override: overrides.get(candidate.id) ?? null,
|
||||
shadowed: resolvedPermissionsByAgent[candidate.id]?.shadowed ?? null,
|
||||
systemOverride: systemOverrides.get(candidate.id) ?? null,
|
||||
resolvedSystem: resolvedSystemByAgent[candidate.id] ?? null,
|
||||
}));
|
||||
}, [agents, document, systemDocument, resolvedSystemByAgent]);
|
||||
}, [
|
||||
agents,
|
||||
document,
|
||||
systemDocument,
|
||||
resolvedPermissionsByAgent,
|
||||
resolvedSystemByAgent,
|
||||
]);
|
||||
|
||||
const projectDraft = useMemo(
|
||||
() => draftFromSet(document?.projectDefaults ?? null),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user