From fa474ae97fef995f0375bf909acd5f983a277078 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 2 Aug 2026 22:51:53 +0200 Subject: [PATCH 1/9] =?UTF-8?q?docs(architecture):=20=C2=A722.1=20?= =?UTF-8?q?=E2=80=94=20contrat=20de=20service=20des=20assets=20idea-plugin?= =?UTF-8?q?://=20multi-fichiers=20ESM=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cadrage architecture du ticket #133 : asset_allowed autorise tout chemin relatif confiné dès lors que registre actif + content_hash + confinement canonicalize sont satisfaits, sans gater fichier par fichier. Débloque #134/#135. Co-Authored-By: Claude Opus 4.8 --- ARCHITECTURE.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0036379..13d887b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2375,4 +2375,26 @@ 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//` ; 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/` + 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. + *Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.* From a5e56885d9f4c6827b62b21e951d977ef137e645 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 2 Aug 2026 22:52:13 +0200 Subject: [PATCH 2/9] =?UTF-8?q?docs(architecture):=20=C2=A722.2=20?= =?UTF-8?q?=E2=80=94=20persistance=20plugin-owned=20hors=20projet=20&=20pu?= =?UTF-8?q?rge=20=C3=A0=20la=20d=C3=A9sinstallation=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cadrage architecture du ticket #138 : séparation project-owned/plugin-owned, API canonique ctx.storage seule, stockage sous app_data/plugins/data// (frère de installed/), purge intégrale à l'uninstall. Débloque #139. Co-Authored-By: Claude Opus 4.8 --- ARCHITECTURE.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 13d887b..29de2a6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2397,4 +2397,24 @@ Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/")) **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("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//` : `app_data/plugins/data//`. Séparé de `installed/` pour que réinstall/mise à jour du package (qui peut re-écrire `installed//` 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//` (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/` + entrée registre), supprimer intégralement `plugins/data//`. 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//`, commandes Tauri, câblage réel dans `loader.ts` (aujourd'hui absent), confinement identique en esprit à #133/#135 (jamais d'écriture hors `plugins/data//`). +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//` 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.* From 71d08795d963a6d9f7c072a235116a364c138af8 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 2 Aug 2026 23:47:41 +0200 Subject: [PATCH 3/9] =?UTF-8?q?feat(plugins):=20sert=20tout=20fichier=20co?= =?UTF-8?q?nfin=C3=A9=20du=20package=20install=C3=A9=20(multi-fichiers=20E?= =?UTF-8?q?SM)=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémente §22.1 : asset_allowed autorise tout chemin relatif dès lors que les gardes déjà en place sont satisfaites (entrée registre active, content_hash du package, confinement canonicalize en aval), au lieu de restreindre au triplet main/icon/assets. Débloque les imports ESM multi-fichiers (./constants.js, ./core/x.js) sans affaiblir le modèle de menace fixé à l'install (#135). Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/plugins.rs | 204 +++++++++++++++++++++++++++++++- 1 file changed, 199 insertions(+), 5 deletions(-) diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index 4352f2a..096194c 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -504,7 +504,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 +527,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(future: F) -> F::Output { @@ -768,6 +766,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(); From 171c6c923c033dc01068977da9425720d707988d Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 3 Aug 2026 11:06:23 +0200 Subject: [PATCH 4/9] feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit État d'intégration confiné à la branche batch. Les tickets #119 (skills → capacités agent découvrables), #122 (override permissions par défaut), #131 (effort par agent/presets) et #132 (outil MCP d'édition du contexte projet) sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs, backend/dto.rs), inséparable sans staging interactif (indisponible ici). Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée. NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 + Cargo.lock | 51 ++ crates/app-tauri/src/commands.rs | 71 ++- crates/app-tauri/src/lib.rs | 62 +++ crates/app-tauri/src/plugins.rs | 49 +- crates/app-tauri/tests/dto_agents.rs | 38 +- crates/application/Cargo.toml | 1 + crates/application/src/agent/lifecycle.rs | 273 +++++++---- crates/application/src/agent/mod.rs | 5 +- crates/application/src/error.rs | 6 + crates/application/src/lib.rs | 23 +- .../src/orchestrator/context_guard.rs | 423 ++++++++++++++++- crates/application/src/orchestrator/mod.rs | 5 +- .../application/src/orchestrator/service.rs | 47 +- crates/application/src/permission.rs | 8 +- crates/application/src/plugin/mod.rs | 284 ++++++++++- crates/application/src/skill/usecases.rs | 6 +- crates/application/tests/agent_lifecycle.rs | 238 +++++++++- .../application/tests/permission_usecases.rs | 70 ++- crates/application/tests/skill_usecases.rs | 5 +- crates/backend/src/dto.rs | 152 +++++- crates/backend/src/events.rs | 41 ++ crates/backend/src/lib.rs | 63 ++- crates/domain/src/agent.rs | 58 ++- crates/domain/src/events.rs | 10 + crates/domain/src/lib.rs | 18 +- crates/domain/src/orchestrator.rs | 46 ++ crates/domain/src/permission.rs | 249 ++++++++++ crates/domain/src/ports.rs | 42 ++ crates/domain/src/profile.rs | 141 ++++++ crates/infrastructure/Cargo.toml | 1 + crates/infrastructure/src/lib.rs | 4 +- .../src/orchestrator/mcp/tools.rs | 90 +++- crates/infrastructure/src/plugin/mod.rs | 442 ++++++++++++++++-- crates/infrastructure/tests/mcp_server.rs | 1 + .../tests/orchestrator_watcher.rs | 1 + .../tests/plugin_install_load.rs | 122 ++++- crates/web-server/src/lib.rs | 57 ++- frontend/src/adapters/agent.test.ts | 21 + frontend/src/adapters/agent.ts | 11 + frontend/src/adapters/http/index.ts | 2 + .../adapters/http/requestResponseGateways.ts | 15 +- frontend/src/adapters/http/streamGateways.ts | 10 + frontend/src/adapters/http/unsupported.ts | 19 + frontend/src/adapters/index.ts | 3 + frontend/src/adapters/mock/index.ts | 116 ++++- frontend/src/adapters/permission.ts | 6 +- frontend/src/adapters/pluginStorage.ts | 26 ++ frontend/src/adapters/skill.ts | 2 + frontend/src/domain/index.ts | 45 ++ frontend/src/features/agents/AgentsPanel.tsx | 160 ++++++- frontend/src/features/agents/agents.test.tsx | 122 +++++ frontend/src/features/agents/useAgents.ts | 27 ++ .../features/permissions/usePermissions.ts | 35 +- .../plugins/PluginRuntimeProvider.tsx | 1 + frontend/src/plugins/runtime/loader.test.ts | 55 +++ frontend/src/plugins/runtime/loader.ts | 26 +- frontend/src/plugins/runtime/registry.ts | 2 + frontend/src/ports/index.ts | 34 +- 59 files changed, 3654 insertions(+), 291 deletions(-) create mode 100644 frontend/src/adapters/pluginStorage.ts diff --git a/.gitignore b/.gitignore index 5a5c37c..50cc9c0 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Cargo.lock b/Cargo.lock index d492f13..e4ce67d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index ac2b2bb..075a264 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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 { + 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, ErrorDto> { +) -> Result { 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 diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index ca1600a..72c9f3a 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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( 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"); diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index 096194c..c1e3f37 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -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, 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 { + 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( diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index f71dc41..b001fd7 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -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(); diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 55c9888..bac78c9 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -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 diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index dad2a93..3f83c40 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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, - /// 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, /// Project-memory recall selected for this launch. pub memory: Vec, @@ -281,6 +284,8 @@ pub struct ListAgentsOutput { pub agents: Vec, /// Resolved discoverable capabilities per agent. pub capabilities: Vec, + /// The manifest's resolved orchestrator. + pub effective_orchestrator: Option, } /// Resolved capabilities for one listed agent. @@ -301,6 +306,8 @@ pub struct AgentDiscoveryEntry { pub agent: Agent, /// Resolved capability affordances. pub capabilities: Vec, + /// 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, +} + +/// 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, +} + +impl UpdateAgentEffort { + /// Builds the use case. + #[must_use] + pub fn new(contexts: Arc) -> 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 { + 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 -/// `**** — ()` (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 `**** — ()` (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/.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** — ()`. - 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 `## ` 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/.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] diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index b470ca7..77a478f 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -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, diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index 7fcdc09..0534ced 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -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", diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 754ddce..615e36f 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/orchestrator/context_guard.rs b/crates/application/src/orchestrator/context_guard.rs index dfff2a0..f3c9dd4 100644 --- a/crates/application/src/orchestrator/context_guard.rs +++ b/crates/application/src/orchestrator/context_guard.rs @@ -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, +} + 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 { + pub async fn execute(&self, input: ReadContextInput) -> Result { 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, + contexts: Arc, + fs: Arc, + events: Arc, + clock: Arc, +} + +/// 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, + /// 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, + contexts: Arc, + fs: Arc, + events: Arc, + clock: Arc, + ) -> 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 { + 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>); + + impl SpyBus { + fn events(&self) -> Vec { + 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 { 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, + Arc::clone(&bus) as Arc, + 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, + 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, + 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, + 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, + 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, + Arc::clone(&bus) as Arc, + 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, + ); + let updater = UpdateProjectContext::new( + guard(), + contexts, + Arc::clone(&fs) as Arc, + 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)); diff --git a/crates/application/src/orchestrator/mod.rs b/crates/application/src/orchestrator/mod.rs index bc27242..ce1a8fa 100644 --- a/crates/application/src/orchestrator/mod.rs +++ b/crates/application/src/orchestrator/mod.rs @@ -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, diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 3ef787d..0cd943c 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -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, /// Proposition/écriture d'un contexte `.md` IdeA sous le garde. pub propose_context: Arc, + /// Écriture directe stricte du contexte projet global. + pub update_project_context: Arc, /// Lecture mémoire sous read-lease. pub read_memory: Arc, /// É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, requester: ConversationParty, ) -> Result { - 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")); + } 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, + requester: ConversationParty, + ) -> Result { + 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?; diff --git a/crates/application/src/permission.rs b/crates/application/src/permission.rs index 6b621e6..ee1f0f4 100644 --- a/crates/application/src/permission.rs +++ b/crates/application/src/permission.rs @@ -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, + /// Diagnostic report for agent-level allows shadowed by project defaults. + pub shadowed: PermissionShadowReport, } diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index 767be89..6035ba2 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -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, @@ -157,6 +157,28 @@ pub struct PluginRuntimePlugin { 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 +2045,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), @@ -2516,9 +2546,91 @@ pub struct UninstallPluginInput { pub plugin_id: String, } +/// Plugin-owned key/value storage facade. +pub struct PluginStorageAccess { + storage: Arc, + registry: Arc, +} + +impl PluginStorageAccess { + /// Builds the facade. + #[must_use] + pub fn new( + storage: Arc, + registry: Arc, + ) -> Self { + Self { storage, registry } + } + + /// Reads one plugin-owned JSON value. + pub async fn get( + &self, + input: PluginStorageGetInput, + ) -> Result, 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 { + 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 { + 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, + storage: Arc, registry: Arc, events: Arc, mcp: Arc, @@ -2529,12 +2641,14 @@ impl UninstallPlugin { #[must_use] pub fn new( packages: Arc, + storage: Arc, registry: Arc, events: Arc, mcp: Arc, ) -> Self { Self { packages, + storage, registry, events, mcp, @@ -2562,6 +2676,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, @@ -3214,7 +3332,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 +3530,69 @@ mod tests { } } + #[derive(Default)] + struct FakeStorage { + values: Mutex>, + purged: Mutex>, + } + + #[async_trait::async_trait] + impl PluginStorageStore for FakeStorage { + async fn get( + &self, + plugin_id: &PluginId, + key: &str, + ) -> Result, 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 { + 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 { + 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>, @@ -3802,6 +3984,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 +3992,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 +4009,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 +4020,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 +4112,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 +4120,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 diff --git a/crates/application/src/skill/usecases.rs b/crates/application/src/skill/usecases.rs index c5315f2..67d2266 100644 --- a/crates/application/src/skill/usecases.rs +++ b/crates/application/src/skill/usecases.rs @@ -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, + /// 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 }) } diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index b618d4b..d3fb802 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -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>>, + efforts: Arc>>>, envs: Arc>>>, policies: Arc>>>, 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> { + self.efforts.lock().unwrap().clone() + } + fn envs(&self) -> Vec> { 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, + 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) { 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, + registry: Option>, + perm_doc: Option, + env: Vec<(String, String)>, +) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc) { 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 diff --git a/crates/application/tests/permission_usecases.rs b/crates/application/tests/permission_usecases.rs index 6949c60..84b49e0 100644 --- a/crates/application/tests/permission_usecases.rs +++ b/crates/application/tests/permission_usecases.rs @@ -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()); } diff --git a/crates/application/tests/skill_usecases.rs b/crates/application/tests/skill_usecases.rs index 4cee8a8..ee877b4 100644 --- a/crates/application/tests/skill_usecases.rs +++ b/crates/application/tests/skill_usecases.rs @@ -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(), diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 0f47846..7755f13 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -337,6 +337,47 @@ impl From 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 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 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 +2418,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 +2454,8 @@ pub struct AgentDto { pub agent: Agent, /// Resolved discoverable capabilities. pub capabilities: Vec, + /// Whether this agent is the effective project orchestrator. + pub is_orchestrator: bool, } impl AgentDto { @@ -2422,6 +2465,7 @@ impl AgentDto { Self { agent, capabilities: Vec::new(), + is_orchestrator: false, } } } @@ -2443,6 +2487,7 @@ impl From for AgentListDto { .into_iter() .map(AgentCapabilityDto::from) .collect(), + is_orchestrator: entry.is_orchestrator, }) .collect(), ) @@ -2512,6 +2557,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, + /// Diagnostic report for agent-level allows shadowed by project defaults. + pub shadowed: PermissionShadowReport, +} + +impl From 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 +2831,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, +} + /// 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 +4073,16 @@ pub struct CreateSkillRequestDto { pub project_id: String, /// Display name. pub name: String, + /// Optional one-line affordance description. + #[serde(default)] + pub description: Option, /// 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 +4785,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 +4837,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 +4978,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 { diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index 1444c72..8d645e1 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -579,6 +579,16 @@ pub enum DomainEventDto { #[serde(skip_serializing_if = "Option::is_none")] orchestrator: Option, }, + /// 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)); diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 4b87189..415b2b1 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -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, /// Update one agent permission override. pub update_agent_permissions: Arc, + /// Update one agent effort override. + pub update_agent_effort: Arc, /// Resolve effective permissions for one agent. pub resolve_agent_permissions: Arc, /// Read the project system permission document. @@ -1142,6 +1146,8 @@ pub struct BackendCore { pub plugin_workspace_access: Arc, /// Public plugin structured config document facade. pub plugin_config_documents: Arc, + /// Public plugin-owned storage facade. + pub plugin_storage_access: Arc, /// Public plugin project-structure query use case. pub query_project_structure: Arc, /// Public plugin command/task facade. @@ -1416,11 +1422,13 @@ impl BackendCore { let events_port = Arc::clone(&event_bus) as Arc; 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; let plugin_registry_store = Arc::clone(&plugin_registry) as Arc; + let plugin_storage_store = Arc::clone(&plugin_storage) as Arc; let plugin_manifest_validator = Arc::clone(&plugin_validator) as Arc; 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, )), + 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, + )), 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, @@ -5977,6 +6009,7 @@ mod mcp_e2e_loopback_tests { synchronized: false, synced_template_version: None, skills: Vec::new(), + effort: None, }); id } diff --git a/crates/domain/src/agent.rs b/crates/domain/src/agent.rs index a63b593..278551d 100644 --- a/crates/domain/src/agent.rs +++ b/crates/domain/src/agent.rs @@ -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, + /// 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, } 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) -> 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, + /// Per-agent effort selection. Missing in older manifests means no override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, } 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); + } } diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 38fef5c..3e3a5f1 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -637,6 +637,16 @@ pub enum DomainEvent { /// (the oldest agent orchestrates). orchestrator: Option, }, + /// 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. diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 5ddfa40..f35b976 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -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::{ @@ -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, }; diff --git a/crates/domain/src/orchestrator.rs b/crates/domain/src/orchestrator.rs index 74f6172..ea1568b 100644 --- a/crates/domain/src/orchestrator.rs +++ b/crates/domain/src/orchestrator.rs @@ -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, + /// Optional optimistic-concurrency version for `context.update`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub if_match: Option, /// 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, + /// 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!( diff --git a/crates/domain/src/permission.rs b/crates/domain/src/permission.rs index fa35ab0..5ebeb56 100644 --- a/crates/domain/src/permission.rs +++ b/crates/domain/src/permission.rs @@ -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 { + 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] diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index e2f8ac9..9b0ffc4 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -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//`. +#[async_trait] +pub trait PluginStorageStore: Send + Sync { + /// Reads one plugin-owned value. + async fn get( + &self, + plugin_id: &PluginId, + key: &str, + ) -> Result, 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; + + /// Purges every plugin-owned value for one plugin. + async fn purge_plugin( + &self, + plugin_id: &PluginId, + ) -> Result; +} + /// Store for installed plugin packages under the global app data directory. #[async_trait] pub trait PluginPackageStore: Send + Sync { diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index 89dc887..6f55a72 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -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, +} + +/// 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 { + 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, + /// 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, /// 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) -> 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( diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index cb8ced4..86fd50e 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -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. diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 8441c14..1ecb3d9 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -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; diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index e0a5435..ccf41b0 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -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 { 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 { "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, + 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!( diff --git a/crates/infrastructure/src/plugin/mod.rs b/crates/infrastructure/src/plugin/mod.rs index 6277db8..0f35048 100644 --- a/crates/infrastructure/src/plugin/mod.rs +++ b/crates/infrastructure/src/plugin/mod.rs @@ -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 { 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) -> 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//`. +#[derive(Debug, Clone)] +pub struct FsPluginStorageStore { + root: PathBuf, +} + +impl FsPluginStorageStore { + /// Builds the store. + #[must_use] + pub fn new(app_data_dir: impl Into) -> 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, 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 { + 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 { + 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 { @@ -574,6 +720,99 @@ mod tests { .unwrap(); } + struct ZipEntrySpec<'a> { + name: &'a str, + contents: &'a [u8], + unix_mode: Option, + } + + fn write_u16(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn write_u32(out: &mut Vec, 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>, @@ -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()); diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index a30dd30..29f5466 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -108,6 +108,7 @@ impl FakeContexts { synchronized: false, synced_template_version: None, skills: Vec::new(), + effort: None, }); id } diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 7833589..a5bae6e 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -97,6 +97,7 @@ impl FakeContexts { synchronized: false, synced_template_version: None, skills: Vec::new(), + effort: None, }); id } diff --git a/crates/infrastructure/tests/plugin_install_load.rs b/crates/infrastructure/tests/plugin_install_load.rs index 49af4a0..01cf70f 100644 --- a/crates/infrastructure/tests/plugin_install_load.rs +++ b/crates/infrastructure/tests/plugin_install_load.rs @@ -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] diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 00901b9..2523c32 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -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 Result { + let request = required_request::("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", diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index e3ae6a2..4f77b6c 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -139,4 +139,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 }, + }); + }); }); diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 491607e..4ff3d07 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -16,6 +16,7 @@ import { Channel, invoke } from "@tauri-apps/api/core"; import type { Agent, + EffortSelection, ResumableAgent, TerminalSession, } from "@/domain"; @@ -105,6 +106,16 @@ export class TauriAgentGateway implements AgentGateway { ); } + updateAgentEffort( + projectId: string, + agentId: string, + effort: EffortSelection | null, + ): Promise { + return invoke("update_agent_effort", { + request: { projectId, agentId, effort }, + }); + } + readContext(projectId: string, agentId: string): Promise { return invoke("read_agent_context", { projectId, agentId }); } diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index fe6be6b..750d56c 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -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(), }; } diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 01c3ef4..0af3231 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -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 { return this.http.invoke("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 { @@ -381,8 +388,8 @@ export class HttpPermissionGateway implements PermissionGateway { request: { projectId, agentId, permissions }, }); } - resolveAgentPermissions(projectId: string, agentId: string): Promise { - return this.http.invoke("resolve_agent_permissions", { + resolveAgentPermissions(projectId: string, agentId: string): Promise { + return this.http.invoke("resolve_agent_permissions", { request: { projectId, agentId }, }); } diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index fe45103..b1edfd9 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -23,6 +23,7 @@ import type { Agent, AppExitWorkGuardState, DomainEvent, + EffortSelection, HealthReport, ReplyChunk, ResumableAgent, @@ -223,6 +224,15 @@ export class HttpAgentGateway implements AgentGateway { request: { projectId, agentId, profileId, rows, cols }, }); } + updateAgentEffort( + projectId: string, + agentId: string, + effort: EffortSelection | null, + ): Promise { + return this.http.invoke("update_agent_effort", { + request: { projectId, agentId, effort }, + }); + } readContext(projectId: string, agentId: string): Promise { return this.http.invoke("read_agent_context", { projectId, agentId }); } diff --git a/frontend/src/adapters/http/unsupported.ts b/frontend/src/adapters/http/unsupported.ts index b747907..096944f 100644 --- a/frontend/src/adapters/http/unsupported.ts +++ b/frontend/src/adapters/http/unsupported.ts @@ -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 { + return unsupportedOnWeb("Plugin storage"); + } + + async set(_input: PluginStorageSetInput): Promise { + return unsupportedOnWeb("Plugin storage"); + } + + async delete(_input: PluginStorageGetInput): Promise { + return unsupportedOnWeb("Plugin storage"); + } +} diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 6bc8e3d..8f9a7b8 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -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, }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 3faa9e1..d27b47e 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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 { + 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 { + ): Promise { 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(); + + 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 { + const value = this.values.get(this.storageKey(input)); + return value === undefined ? null : cloneJson(value); + } + + async set(input: PluginStorageSetInput): Promise { + this.values.set(this.storageKey(input), cloneJson(input.value)); + } + + async delete(input: PluginStorageGetInput): Promise { + 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(), }; } diff --git a/frontend/src/adapters/permission.ts b/frontend/src/adapters/permission.ts index db8ad1a..8d9d9c2 100644 --- a/frontend/src/adapters/permission.ts +++ b/frontend/src/adapters/permission.ts @@ -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 { - return invoke("resolve_agent_permissions", { + ): Promise { + return invoke("resolve_agent_permissions", { request: { projectId, agentId }, }); } diff --git a/frontend/src/adapters/pluginStorage.ts b/frontend/src/adapters/pluginStorage.ts new file mode 100644 index 0000000..e421e19 --- /dev/null +++ b/frontend/src/adapters/pluginStorage.ts @@ -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 { + return invoke("plugin_storage_get", { input }); + } + + async set(input: PluginStorageSetInput): Promise { + await invoke("plugin_storage_set", { input }); + } + + delete(input: PluginStorageGetInput): Promise { + return invoke("plugin_storage_delete", { input }); + } +} diff --git a/frontend/src/adapters/skill.ts b/frontend/src/adapters/skill.ts index e4a858f..7187eac 100644 --- a/frontend/src/adapters/skill.ts +++ b/frontend/src/adapters/skill.ts @@ -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, }, diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 69f20a2..92382e3 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -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,8 @@ 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; } /** @@ -1284,6 +1326,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 +1335,8 @@ export type SkillScope = "global" | "project"; export interface Skill { id: string; name: string; + description?: string | null; + kind: SkillKind; contentMd: string; scope: SkillScope; } diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 7a2b245..0e2613c 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -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) { ]} /> )} + + void vm.updateAgentEffort(a.id, effort) + } + /> {agentDrift && (