diff --git a/.ideai/agents.json b/.ideai/agents.json index 418a75a..488523b 100644 --- a/.ideai/agents.json +++ b/.ideai/agents.json @@ -35,6 +35,13 @@ "mdPath": "agents/qa.md", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "synchronized": false + }, + { + "agentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "name": "Git", + "mdPath": "agents/git.md", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "synchronized": false } ] } diff --git a/.ideai/agents/codextest.md b/.ideai/agents/codextest.md new file mode 100644 index 0000000..e69de29 diff --git a/.ideai/agents/git.md b/.ideai/agents/git.md new file mode 100644 index 0000000..cabb0c8 --- /dev/null +++ b/.ideai/agents/git.md @@ -0,0 +1,116 @@ +# Git — Agent de gestion du dépôt git local + +> Tu es l'**agent Git** d'IdeA. Ton unique responsabilité est la **gestion du dépôt +> git local** : commits de l'application, création et bascule de branches, merges et +> rebases. Tu es le **seul** à décider de la topologie des branches et à manipuler +> l'historique local. Main te sollicite ; tu décides et tu exécutes. + +--- + +## 1. Ton rôle (et ses limites) + +Tu t'occupes **du local du repo git**, rien d'autre : + +- **Commits** : tu transformes le travail réalisé par les agents de dev en commits + propres, atomiques, au bon endroit (bonne branche), avec des messages cohérents. +- **Branches** : tu **crées, checkout, switch** les branches selon ce qui est en cours. +- **Intégration** : tu **merges** et **rebases** les branches entre elles selon le + modèle ci-dessous. +- Tu **décides** : quand Main t'annonce une nouvelle feature (après cadrage Architect), + c'est **toi** qui tranches s'il faut une nouvelle branche, un checkout/switch, ou rien. + Après chaque implémentation, Main revient vers toi pour que tu décides si un **merge** + doit avoir lieu quelque part, ou non. + +**Hors périmètre :** +- Tu **n'écris pas de code de feature** (c'est DevBackend/DevFrontend). +- Tu ne fais **aucune action sortante** (`push`, publication, création de PR distante) + sans validation explicite de Main / de l'utilisateur. Ton terrain est **local**. +- Tu ne prends pas de décision produit/archi : si un choix dépend de l'architecture, + tu remontes à Main. + +--- + +## 2. Modèle de branches (git-flow simplifié) + +Le dépôt s'articule autour de trois niveaux : + +``` +main ← branche de RELEASE. Stable, livrable. On n'y commite jamais en direct. + │ +develop ← branche d'INTÉGRATION. On y merge chaque feature une fois TERMINÉE et VERTE. + │ +feature/* ← une branche PAR nouvelle feature. C'est là que le dev se fait. +``` + +- **`main`** : reçoit uniquement des releases (merge depuis `develop` quand on décide + de livrer). Jamais de dev direct. +- **`develop`** : base d'intégration. Toute feature terminée (tests verts) y est mergée. + C'est le point de départ de chaque nouvelle branche de feature. +- **`feature/`** : une branche par feature, créée **depuis `develop`**. Nom + dérivé du sujet de la feature (ex. `feature/sandbox-allow-fallback`, + `feature/sidebar-tabs-responsive`). + +> Si le dépôt ne possède pas encore `main`/`develop`, c'est à toi de les établir +> proprement (création de `develop` depuis `main`) lors de ta première sollicitation. + +--- + +## 3. Le cycle, vu de Git + +Tu interviens à **deux moments** du cycle de dev (cf. CLAUDE.md §3), encadré par Main : + +``` +1. Main : « nouvelle feature X » (architecture cadrée par Architect) + → TOI : décider de la branche. + - nouvelle feature indépendante → créer feature/X depuis develop, switch dessus + - reprise/extension d'un travail en cours → rester / switch sur la branche existante + - simple correctif sur une feature vivante → rester sur sa branche + → tu annonces à Main sur quelle branche le dev va se faire. + +2. Dev (DevBackend/DevFrontend) + Test (QA) implémentent sur cette branche. + +3. Implémentation terminée → Main revient vers TOI : + → committer le travail (commits atomiques, message clair) sur la branche de feature. + → décider d'un éventuel merge : + - feature TERMINÉE et VERTE → merge feature/X → develop + (rebase préalable sur develop si l'historique a divergé, pour rester linéaire), + puis suppression de la branche de feature si plus utile. + - feature pas finie / tests KO → on NE merge PAS, on reste sur feature/X. + - décision de release → merge develop → main (sur validation explicite). +``` + +**Règle d'or partagée** : aucune feature n'est mergée dans `develop` tant que ses +**tests ne passent pas**. Si on te demande de merger une feature rouge, tu refuses et +tu le dis. + +--- + +## 4. Conventions + +- **Messages de commit** : en **français**, style Conventional Commits cohérent avec + l'historique : `feat(scope): …`, `fix(scope): …`, `chore(scope): …`, `docs(scope): …`, + `refactor(scope): …`. Corps multi-ligne expliquant le **pourquoi** quand utile. +- **Atomicité** : un commit = une intention cohérente. Tu sépares le code de feature de + l'état runtime (`.ideai/` conversations, layouts, manifestes) et des docs. +- **Co-author** : termine les messages de commit par + `Co-Authored-By: Claude Opus 4.8 ` (convention de l'environnement). +- **Branches** : `feature/`, dérivé du sujet. Pas d'espaces, pas de majuscules. +- **Historique linéaire** privilégié sur les features : **rebase** avant merge quand la + base a avancé ; merge `--no-ff` vers `develop`/`main` pour garder la trace de + l'intégration de la feature. +- **Pas d'interactif** : pas de `rebase -i` / `add -i` (non supportés dans l'environnement). +- **Jamais** d'action destructive hors-projet ni de réécriture d'historique déjà poussé + sans validation explicite. + +--- + +## 5. Délégation & collaboration + +- Tu réponds à Main via le protocole d'orchestration IdeA (`idea_reply`). Quand Main te + délègue une tâche (message `[IdeA · tâche de … · ticket …]`), tu traites puis tu + appelles **impérativement** `idea_reply(result=…)`. +- Tu rends compte clairement : branche courante, ce que tu as committé (hash + message + court), ce que tu as mergé/rebasé, et **ta décision** (pourquoi cette branche, pourquoi + ce merge ou ce non-merge). +- En cas de conflit de merge/rebase, tu le signales à Main avec le détail ; tu ne forces + pas une résolution hasardeuse. diff --git a/.ideai/agents/newtest.md b/.ideai/agents/newtest.md new file mode 100644 index 0000000..e69de29 diff --git a/.ideai/agents/testconversation.md b/.ideai/agents/testconversation.md new file mode 100644 index 0000000..e69de29 diff --git a/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md new file mode 100644 index 0000000..df6fd65 --- /dev/null +++ b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md @@ -0,0 +1,26 @@ +--- +upTo: 26a58843-91dd-4d78-ad39-c8c223b1f92c +objective: Tâche : ajouter UN test fonctionnel anti-régression de la communication inter-agent (round-trip question/réponse) au plus haut niveau de fidélité possible SANS lancer de vrai CLI IA (zéro token). Cont +--- +**Objectif :** Tâche : ajouter UN test fonctionnel anti-régression de la communication inter-agent (round-trip question/réponse) au plus haut niveau de fidélité possible SANS lancer de vrai CLI IA (zéro token). Cont + +- **Response:** ✅ LP3-1 (domaine) — TOUS LES TESTS VERTS. 9 tests ajoutés, suite complète `cargo test -p domain` et `-p application` : 0 échec. TESTS AJOUTÉS (par emplacement) 1) crates/domain/src/permission.rs (module `#[cfg(test)] mod tests`) - `projector_key_serialises_to_stable_camel_case` : ProjectorKey::Claude→"\"claude\"", Codex→"\"codex\"" (forme fil stable, point 1). - `projector_key_round_trips` : round-trip serde_json des 2 variantes + désérialisation des littéraux "claude"/"codex" → variante attendue. - `permission_projection_empty_is_fully_empty` : `PermissionProjection::empty()` → files/args/env tous vides (invariant eff==None, point 6). 2) crates/domain/src/profile.rs (module `mcp_tests`) - `profile_default_has_no_projector` : `AgentProfile::new(...)` → projector None. - `profile_without_projector_omits_key_in_json` : skip_serializing_if → clé `projector` ABSENTE quand None (point 3). - `legacy_json_without_projector_deserialises_to_none` : JSON de profil SANS champ `projector` → None (COMPAT, point 2). - `with_projector_sets_and_round_trips_camel_case` : builder pose Some(Claude), sérialise `"projector":"claude"`, round-trip identique (points 2 & 4). 3) crates/application/src/agent/catalogue.rs (module `mcp_tests`) - `claude_and_codex_seed_their_projector_key` : builtin claude→Some(Claude), codex→Some(Codex) (point 5). - `gemini_and_aider_have_no_projector` : Gemini/Aider→None (point 5). Le trait `PermissionProjector` n'est PAS testé directement (pas d'impl concrète avant LP3-2 ; pas de projecteur factice ajouté pour ne pas sur-tester — la forme du contrat est déjà couverte par PermissionProjection::empty()). SORTIE RÉELLE (cargo test, ciblage par nom) test permission::tests::permission_projection_empty_is_fully_empty ... ok test permission::tests::projector_key_round_trips ... ok test permission::tests::projector_key_serialises_to_stable_camel_case ... ok test profile::mcp_tests::legacy_json_without_projector_deserialises_to_none ... ok test profile::mcp_tests::profile_default_has_no_projector ... ok test profile::mcp_tests::profile_without_projector_omits_key_in_json ... ok test profile::mcp_tests::with_projector_sets_and_round_trips_camel_case ... ok test agent::catalogue::mcp_tests::claude_and_codex_seed_their_projector_key ... ok test agent::catalogue::mcp_tests::gemini_and_aider_have_no_projector ... ok Suites globales : `cargo test -p domain` → toutes les cibles `test result: ok. 0 failed` (lib 20+ tests, structured_session_d0 16, window 4, doctests 0). `cargo test -p application` → `0 failed` sur toutes les cibles. Aucune régression. Rien à renvoyer à DevBackend — lot LP3-1 validé. +- **Prompt:** Tests du lot LP3-2 (infrastructure) — `ClaudePermissionProjector` et `CodexPermissionProjector`. DevBackend a extrait la logique de traduction (verbatim) derrière le trait `PermissionProjector`. Build `cargo build -p infrastructure` OK. À toi d'écrire/exécuter les tests. EMPLACEMENT : crates/infrastructure/src/permission/ (mod.rs, claude.rs, codex.rs). Mets les tests en `#[cfg(test)]` dans claude.rs / codex.rs (ou un mod tests dans permission/), cohérent avec le style infra existant. Référence utile : les tests de traduction d'origine étaient dans application/src/agent/lifecycle.rs ~l.2957-3068 — réutilise/relocalise leur intention. CONTRAT testé (rappel) : - `project(None, ctx)` → `PermissionProjection::empty()` (files/args/env vides) pour LES DEUX projecteurs. Cas invariant produit, à couvrir explicitement. - Claude `project(Some(eff), ctx)` → 1 fichier `Replace { rel_path: ".claude/settings.local.json", contents: }`, args/env vides ; `owned_replace_paths() == [".claude/settings.local.json"]`. - Codex `project(Some(eff), ctx)` → 1 fichier `MergeToml { rel_path: ".codex/config.toml", managed_keys: ["sandbox_mode","approval_policy"], contents: toml partiel }` + args `["--sandbox", , "--ask-for-approval", ]` ; `owned_replace_paths() == []`. CAS À COUVRIR : Claude : 1. Posture Allow / Ask / Deny → `defaultMode` attendu dans le JSON (bypassPermissions / acceptEdits / plan). Vérifie le mapping exact tel qu'implémenté. 2. deny-wins : un eff avec deny spécifique + allow large → l'entrée deny apparaît dans la liste deny du JSON. 3. additionalDirectories contient bien `ctx.project_root` (et JSON-escaping correct si le chemin contient un caractère à échapper — teste avec un chemin contenant un backslash/guillemet si pertinent). 4. Garde-fous `merge_default_deny` (sudo/rm -rf/mkfs/dd/shutdown/reboot) présents dans la liste deny. 5. Le JSON produit est un JSON VALIDE (parse-le avec serde_json::Value pour ne pas figer le format au caractère près — préfère des assertions sémantiques sur la valeur parsée plutôt que des comparaisons de chaîne brutes quand c'est possible). Codex : 6. Mapping posture→sandbox_mode : Deny→read-only, Ask/Allow→workspace-write. Et approval_policy : Allow→never, Ask/Deny→on-request. Couvre les 3 postures. 7. Les args reflètent les mêmes valeurs que le TOML (cohérence args ↔ contents). 8. owned_replace_paths vide. Construis les `EffectivePermissions` d'entrée via l'API domaine (resolve / PermissionSet / PermissionRule) comme dans les tests permission existants. Privilégie des assertions sémantiques robustes (JSON parsé, contains sur les listes) plutôt que des golden strings fragiles, sauf si un golden est clairement plus lisible. Exécute `cargo test -p infrastructure`. Réponds via idea_reply : tests ajoutés + emplacement + SORTIE RÉELLE cargo test. Tout échec → rapport d'erreur clair pour DevBackend. +- **Response:** ✅ LP3-2 (infrastructure) — TOUS LES TESTS VERTS. 11 tests ajoutés, `cargo test -p infrastructure` : 0 échec, aucune régression (147 tests lib + 0 fail sur toutes les cibles d'intégration). TESTS AJOUTÉS (par emplacement) A) crates/infrastructure/src/permission/claude.rs (`#[cfg(test)] mod tests`, 8 tests) - `project_none_is_empty` : `project(None, ctx)` → files/args/env vides (invariant produit). - `owned_replace_paths_is_the_settings_file` : == [".claude/settings.local.json"]. - `default_mode_maps_each_posture` (cas 1) : Allow→"bypassPermissions", Ask→"acceptEdits", Deny→"plan" (assertion sur JSON parsé `permissions.defaultMode`). - `specific_deny_with_broad_allow_appears_in_deny_list` (cas 2, deny-wins) : Write Deny `.ideai/**` + Write Allow `**` → la liste `deny` contient `Edit(.ideai/**)` ET `Write(.ideai/**)` (le Write se déploie en Edit+Write), et le `**` reste dans `allow`. - `additional_directories_contains_project_root_escaped` (cas 3) : root = `C:\Users\a"b\proj` (backslash + guillemet) → après parse JSON, `additionalDirectories[0]` == chemin brut verbatim (prouve l'échappement correct). - `default_deny_guardrails_are_present` (cas 4) : sudo / rm -rf / / rm -rf ~ / $HOME* / mkfs* / dd if=* / shutdown* / reboot* présents dans `deny`. - `produced_settings_has_expected_static_shape` (cas 5) : doc parsé via serde_json → `enabledMcpjsonServers[0]=="idea"`, `skipDangerousModePermissionPrompt==true`, `sandbox.enabled==false`. - `empty_rules_fall_back_to_broad_default_allow` : eff sans règle → `allow == [Read,Edit,Write,Bash]`. NB : toutes les assertions Claude passent par serde_json::from_str (pas de golden string fragile) ; le helper `project_json` vérifie au passage la forme du contrat (1 `Replace { rel_path == ".claude/settings.local.json" }`, args/env vides) et que le contenu est un JSON valide. B) crates/infrastructure/src/permission/codex.rs (`#[cfg(test)] mod tests`, 3 tests) - `project_none_is_empty` : `project(None, ctx)` → files/args/env vides (invariant produit). - `owned_replace_paths_is_empty` : == [] (config.toml co-owned, jamais supprimé). - `posture_maps_sandbox_and_approval_in_file_and_args` (cas 6+7+8) : sur les 3 postures Deny → sandbox "read-only", approval "on-request" Ask → sandbox "workspace-write", approval "on-request" Allow → sandbox "workspace-write", approval "never" Vérifie pour chacune : 1 `MergeToml { rel_path == ".codex/config.toml", managed_tables vide, managed_keys == ["sandbox_mode","approval_policy"] }`, le `contents` contient bien `sandbox_mode = ""` et `approval_policy = ""`, ET args == ["--sandbox", , "--ask-for-approval", ] (cohérence args↔contents), env vide. Les `EffectivePermissions` d'entrée sont construits via l'API domaine (`resolve(Some(&PermissionSet::new(rules, fallback)), None)` + `PermissionRule::file`), comme les tests `permission` existants. SORTIE RÉELLE (ciblage par nom, lib) test permission::claude::tests::project_none_is_empty ... ok test permission::claude::tests::owned_replace_paths_is_the_settings_file ... ok test permission::claude::tests::default_mode_maps_each_posture ... ok test permission::claude::tests::specific_deny_with_broad_allow_appears_in_deny_list ... ok test permission::claude::tests::additional_directories_contains_project_root_escaped ... ok test permission::claude::tests::default_deny_guardrails_are_present ... ok test permission::claude::tests::produced_settings_has_expected_static_shape ... ok test permission::claude::tests::empty_rules_fall_back_to_broad_default_allow ... ok test permission::codex::tests::project_none_is_empty ... ok test permission::codex::tests::owned_replace_paths_is_empty ... ok test permission::codex::tests::posture_maps_sandbox_and_approval_in_file_and_args ... ok test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 136 filtered out Suite complète `cargo test -p infrastructure` : lib 147 passed; 0 failed, et toutes les cibles d'intégration `0 failed`. Rien à renvoyer à DevBackend — lot LP3-2 validé. +- **Prompt:** Tests du lot LP3-3 (application) — l'étape `apply_permission_projection` + registre + découplage MCP dans LaunchAgent. DevBackend a câblé et a déjà fait passer `cargo test -p application` (en adaptant les anciens tests). À toi d'AJOUTER les tests qui prouvent le nouveau comportement de bout en bout (niveau application, FS mocké). Contexte API exposée : - `application::PermissionProjectorRegistry` : `new()`, `with(Arc)`, `insert(..)`, `get(key)`. - `LaunchAgent::with_permission_projectors(Arc)` (builder optionnel ; absent ⇒ aucune projection). - Projecteurs concrets dans `infrastructure` : `ClaudePermissionProjector`, `CodexPermissionProjector` (utilise-les pour peupler le registre dans les tests, ou des doubles de test si plus simple — à ton appréciation). - Sélection : `profile.projector` sinon fallback legacy (CLAUDE.md⇒Claude ; StructuredAdapter::Codex / TomlConfigHome⇒Codex). - Étape insérée après apply_injection + apply_mcp_config, avant le split structuré/PTY. Regarde d'abord les tests d'intégration existants de LaunchAgent (crates/application/tests/agent_lifecycle.rs et le FS mock utilisé, `fs.seed_writes()` etc.) pour réutiliser les fixtures/mocks en place. CAS À COUVRIR : 1. Sélection de clé : (a) profil avec `projector=Some(Claude)` → projecteur Claude utilisé ; (b) fallback legacy : profil sans projector mais convention-file CLAUDE.md → Claude ; (c) fallback legacy : profil sans projector mais StructuredAdapter::Codex (ou TomlConfigHome) → Codex ; (d) profil non projetable + pas de fallback → aucune projection. 2. Clobber `Replace` : profil Claude lancé 2× → `.claude/settings.local.json` est RÉÉCRIT (clobber), pas conservé tel quel. Prouve l'inversion vs l'ancien non-clobbering. 3. Merge `MergeToml` (Codex) : un `.codex/config.toml` préexistant contenant une table/clé NON gérée (ex. une entrée user ou mcp_servers) → après projection, sandbox_mode/approval_policy sont upsertées ET la clé non gérée est préservée. Idempotence : 2 projections ne dupliquent pas les clés. 4. Fold args/env : profil Codex → le `spec` final porte les args `--sandbox …/--ask-for-approval …` (vérifie via le mock/spawn capturé), pour le chemin PTY ET structuré si testable. 5. **Découplage MCP (le cas clé du lot)** : profil Codex SANS configuration MCP → la sandbox est tout de même projetée (args + .codex/config.toml). C'est la preuve que la projection ne dépend plus de apply_mcp_config. 6. No-op : (a) registre absent (builder non appelé) → aucun fichier de permission écrit (`fs.seed_writes()` / writes vides) ; (b) eff == None (aucune permission posée pour le projet/agent) → projection vide. 7. eff résolu : permissions posées au niveau projet/agent → bien reflétées dans le fichier projeté (au moins un smoke : posture Deny → mode plan côté Claude). Mets ces tests là où c'est cohérent (tests d'intégration agent_lifecycle.rs ou un nouveau tests/permission_projection.rs). Exécute `cargo test -p application` (et `-p infrastructure` si tu réutilises les projecteurs concrets — vérifie que rien ne casse). Réponds via idea_reply : tests ajoutés + emplacement + SORTIE RÉELLE. Tout échec → rapport clair pour DevBackend (c'est le lot le plus à risque, sois rigoureux sur le découplage MCP et le clobber). +- **Response:** ✅ LP3-3 (application) — TOUS LES TESTS VERTS. 11 tests d'intégration ajoutés, `cargo test -p application` ET `-p infrastructure` : 0 échec, aucune régression. EMPLACEMENT : crates/application/tests/agent_lifecycle.rs (réutilise les fakes en place ; section « LP3-3 — permission projection wiring » en fin de fichier). CHOIX ARCHITECTURAL (important) : l'application ne dépend QUE des ports domaine (cf. description du crate). Je n'ai donc PAS ajouté `infrastructure` en dev-dependency. À la place, deux **doubles fidèles** `FakeClaudeProjector` / `FakeCodexProjector` (impl du trait domaine `PermissionProjector`) reproduisant le mapping réel posture→mode (Allow→bypassPermissions, Ask→acceptEdits, Deny→plan ; Deny→read-only/on-request, Ask→workspace-write/on-request, Allow→workspace-write/never). La fidélité de traduction réelle est déjà couverte par LP3-2 ; ces tests prouvent le CÂBLAGE applicatif (sélection de clé, clobber Replace, merge MergeToml, fold args/env, découplage MCP, no-op). Ajouts utilitaires aux fakes existants : `FakeFs.seed_read` + lecture « last-write-wins » (pour merge/idempotence), `FakePermissionStore`, `full_registry()`, `perm_doc(posture)`, `codex_profile()`, helper `launch_with_projection(...)`. TESTS AJOUTÉS (11) 1) Sélection de clé : - `projection_selects_claude_from_explicit_projector_field` (1a) : projector=Some(Claude) gagne même avec convention GEMINI.md (le champ explicite prime sur l'heuristique) → seed Claude écrit, pas de config Codex. - `projection_falls_back_to_claude_from_convention_file` (1b) : pas de projector + CLAUDE.md → Claude. - `projection_falls_back_to_codex_from_structured_adapter` (1c) : pas de projector + StructuredAdapter::Codex → Codex (config + args --sandbox). - `projection_noop_for_unprojectable_profile` (1d) : GEMINI.md, ni projector ni signal Codex → aucune projection (aucun fichier, aucun arg). 2) `claude_replace_seed_is_clobbered_on_relaunch` : 2 lancements (session retirée entre les deux pour lever la garde singleton), seed pré-marqué existant → 2 écritures sur le MÊME chemin (clobber prouvé, inversion vs régime non-clobber MCP). 3) `codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged` : `.codex/config.toml` préexistant avec `user_key`+table `[mcp_servers.idea]` non gérés → après projection, sandbox_mode/approval_policy upsertés ET user_key+table préservés ; 2e projection → 1 seule occurrence de chaque clé gérée (idempotence), user_key toujours là. 4) `codex_projection_folds_args_into_spawn_spec` : args `--sandbox workspace-write` / `--ask-for-approval on-request` (posture Ask) présents dans l'ordre CLI dans le spec spawné (chemin PTY ; le chemin structuré hérite du même spec car le fold précède le split — pas de factory structurée câblée dans ces fixtures). 5) `codex_sandbox_projected_without_any_mcp_capability` (CAS CLÉ découplage MCP) : profil Codex SANS McpCapability (`profile.mcp.is_none()` vérifié) → la sandbox est tout de même projetée (`.codex/config.toml` écrit, `sandbox_mode="read-only"` posture Deny) + args foldés. Prouve que la projection ne dépend plus de apply_mcp_config. 6) No-op : - `no_registry_means_no_projection` (6a) : registre absent (builder non appelé) → aucun fichier de permission, même avec policy posée. - `no_policy_posed_means_empty_projection` (6b) : `ProjectPermissions::default()` (project_defaults=None) ⇒ resolve_for=None ⇒ projection vide même registre câblé. 7) `resolved_deny_posture_reflected_as_plan_mode` : policy projet Deny → resolve_for → projecteur → JSON parsé `permissions.defaultMode=="plan"` ET `additionalDirectories[0]=="/home/me/proj"` (le project_root a bien transité par le ProjectionContext). SORTIE RÉELLE cargo test -p application --test agent_lifecycle : test result: ok. 59 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (dont les 11 ci-dessus : projection_selects_claude_from_explicit_projector_field, projection_falls_back_to_claude_from_convention_file, projection_falls_back_to_codex_from_structured_adapter, projection_noop_for_unprojectable_profile, claude_replace_seed_is_clobbered_on_relaunch, codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged, codex_projection_folds_args_into_spawn_spec, codex_sandbox_projected_without_any_mcp_capability, no_registry_means_no_projection, no_policy_posed_means_empty_projection, resolved_deny_posture_reflected_as_plan_mode — tous ... ok) cargo test -p application (toutes cibles) : chaque `test result: ok. … 0 failed`. cargo test -p infrastructure (réutilisé indirectement, vérif non-régression) : toutes cibles `0 failed` (lib 147, etc.). Rien à renvoyer à DevBackend — lot LP3-3 validé. Note : le découplage MCP (cas 5) et le clobber (cas 2) sont prouvés explicitement comme demandé. +- **Prompt:** Tests du lot LP3-4 (application) — nettoyage des fichiers de permission orphelins au swap cross-profile dans `ChangeAgentProfile`. DevBackend a câblé l'étape 5b (entre invalidate_engine_link et relaunch). `cargo test -p application` déjà vert ; à toi d'ajouter les tests de swap qui prouvent le nettoyage. EMPLACEMENT : crates/application/tests/ (le fichier des tests ChangeAgentProfile existants ; réutilise les fixtures). Regarde d'abord les tests de swap existants pour réutiliser les fakes. POINT IMPORTANT (signalé par DevBackend) : `FileSystem::remove_file` a une impl PAR DÉFAUT no-op (Ok sans rien supprimer). Pour ASSERTER une suppression, ton fake FileSystem doit OVERRIDE `remove_file` (enregistrer les chemins supprimés, et réellement retirer de son état interne). Ajoute ça au fake utilisé par les tests de swap. Câblage à reproduire dans les tests : injecter le MÊME registre via `ChangeAgentProfile::with_permission_projectors(...)` (et `LaunchAgent::with_permission_projectors(...)` pour la relance). Tu peux réutiliser les doubles fidèles `FakeClaudeProjector`/`FakeCodexProjector` créés en LP3-3 (Claude → owned_replace_paths=[".claude/settings.local.json"] ; Codex → []). SCÉNARIOS À COUVRIR : 1. **Claude→Codex** : avant swap, `.claude/settings.local.json` existe dans le run dir de l'agent. Après swap → ce fichier est SUPPRIMÉ (présent dans les remove enregistrés par le fake / absent de l'état FS), ET la config Codex (`.codex/config.toml` + args) est projetée par la relance. C'est le scénario phare. 2. **Claude→Claude** (changement de profil même famille) : `owned(ancien) − owned(nouveau)` = vide → `.claude/settings.local.json` N'EST PAS supprimé (il est re-clobbé par la relance, pas retiré). 3. **Codex→Claude** : Codex n'a pas de Replace → rien supprimé côté nettoyage ; la relance écrit `.claude/settings.local.json`. (Le `.codex/config.toml` reste, jamais supprimé — vérifie qu'il n'est pas dans les remove.) 4. **No-op** : (a) registre absent (builder non appelé) → aucune suppression ; (b) profil précédent introuvable (supprimé entre-temps) → nettoyage sauté, pas d'échec. 5. **Best-effort** : suppression d'un fichier déjà absent → le swap réussit quand même (pas d'erreur propagée). 6. **NON-RÉGRESSION P8d** : sur un swap, vérifie que l'id de paire (conversation_id stable) / le handoff restent comme avant — réutilise/garde vert le(s) test(s) de swap existant(s) qui couvrent ça (ne les casse pas ; ajoute une assertion si pertinent). Exécute `cargo test -p application`. Réponds via idea_reply : tests ajoutés + emplacement + modifs au fake FileSystem + SORTIE RÉELLE. Tout échec → rapport clair pour DevBackend. Sois rigoureux sur le scénario 1 (Claude→Codex) et le 2 (Claude→Claude ne supprime pas). +- **Response:** ✅ LP3-4 (application) — TOUS LES TESTS VERTS. 7 tests de swap ajoutés, `cargo test -p application` : 0 échec sur les 28 cibles, aucune régression (dont les tests P8d existants restés verts). EMPLACEMENT : crates/application/tests/change_agent_profile.rs (réutilise les fixtures swap en place ; nouvelle section « LP3-4 — cleanup of orphan permission files at a cross-profile swap »). MODIFS AU FAKE FILESYSTEM (comme signalé par DevBackend) - `FakeFsInner` : ajout d'un champ `removed: Vec`. - Override de `remove_file` (l'impl par défaut du port est no-op) : enregistre le chemin dans `removed` ET retire réellement l'entrée de `files` → une suppression est assertable, et l'idempotence (remove d'un absent) reste Ok. - Accesseurs ajoutés : `removed()` (liste ordonnée des chemins supprimés), `has_file(path)` (présence dans l'état). + `FakePty::last_spawn_args()` pour asserter les args foldés par la relance. CÂBLAGE TEST (fidèle au prod) : nouvelle fixture `fixture_with_projection(agent, profiles, registry, perm_doc)` qui injecte le MÊME `full_registry()` via `ChangeAgentProfile::with_permission_projectors(...)` ET `LaunchAgent::with_permission_projectors(...)`, plus `LaunchAgent::with_permission_store(Allow)` pour que la projection de la relance soit non-vide. Doubles fidèles `FakeClaudeProjector` (owned_replace_paths=[".claude/settings.local.json"]) / `FakeCodexProjector` (owned=[], MergeToml `.codex/config.toml` + args --sandbox/--ask-for-approval) — application gardée sans dépendance à infrastructure. TESTS AJOUTÉS (7) 1) `swap_claude_to_codex_removes_claude_seed_and_projects_codex` (PHARE) : `.claude/settings.local.json` pré-existant dans le run dir stable → après swap : présent dans `removed()` ET absent de l'état FS ; la relance projette `.codex/config.toml` (sandbox_mode=workspace-write) + args `--sandbox` dans le spawn. 2) `swap_claude_to_claude_does_not_remove_seed` : owned(old)−owned(new)=∅ → le seed n'est PAS supprimé (absent de `removed()`) et reste présent (re-clobbé par la relance, jamais retiré). 3) `swap_codex_to_claude_removes_nothing_and_keeps_codex_config` : Codex n'a pas de Replace → `removed()` vide ; `.codex/config.toml` pré-existant toujours présent et JAMAIS dans `removed()` ; la relance écrit le seed Claude. 4a) `swap_without_registry_removes_nothing` : fixture par défaut SANS registre sur le swap → aucune suppression même sur Claude→Codex avec seed présent. 4b) `swap_with_unknown_previous_profile_skips_cleanup` : l'agent porte pid(1) mais le store ne connaît que pid(2)/pid(3) (ancien profil supprimé) → cleanup sauté, `removed()` vide, swap réussit sans erreur. 5) `swap_claude_to_codex_succeeds_when_seed_absent` : seed NON semé → la suppression est tout de même tentée (best-effort idempotent : présente dans `removed()`) et le swap réussit. 6) `swap_with_cleanup_preserves_pair_id_and_handoff` (NON-RÉGRESSION P8d) : swap Claude→Codex live avec cleanup qui fire ET handoff semé sous l'id de paire du leaf → après swap : seed supprimé, l'id de paire (conversation_id) est PRÉSERVÉ sur le leaf persisté, agent_was_running remis à false, et le handoff est ré-injecté dans le convention file de la relance (preuve que le cleanup ne perturbe pas l'id de paire/handoff). Les tests P8d préexistants (`live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume`, `swap_preserves_uuid_pair_id_and_clears_engine_cache`, etc.) restent verts, inchangés. POINT D'AJUSTEMENT (pas un bug) : le `FakeRuntime` partagé de ce fichier matérialise toujours le convention file en `CLAUDE.md` quel que soit le profil ; le test 6 lit donc `CLAUDE.md` (le contenu/handoff est ce qui importe, pas le nom de fichier figé par le fake). SORTIE RÉELLE cargo test -p application --test change_agent_profile : test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (dont : swap_claude_to_codex_removes_claude_seed_and_projects_codex, swap_claude_to_claude_does_not_remove_seed, swap_codex_to_claude_removes_nothing_and_keeps_codex_config, swap_without_registry_removes_nothing, swap_with_unknown_previous_profile_skips_cleanup, swap_claude_to_codex_succeeds_when_seed_absent, swap_with_cleanup_preserves_pair_id_and_handoff — tous ok) cargo test -p application (toutes cibles) : 28× `test result: ok. … 0 failed`, aucune régression. Aucun warning sur les deux fichiers de test touchés. Rien à renvoyer à DevBackend — lot LP3-4 validé. Scénarios 1 (Claude→Codex supprime + projette) et 2 (Claude→Claude ne supprime pas) prouvés explicitement. +- **Prompt:** Ping de diagnostic. Calcule 9 + 6 et réponds-moi uniquement avec le résultat via idea_reply. +- **Prompt:** [Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong QA OK » pour confirmer que le canal fonctionne. +- **Response:** pong QA OK +- **Prompt:** [LP4-0 — suite de tests unitaires domaine] DevBackend a livré le sous-lot LP4-0 (domaine pur) sur le chantier permissions/enforcement OS. Écris et exécute une suite de tests unitaires exhaustive, puis relaie le résultat réel (vert/rouge avec sortie). Cible : `crates/domain/src/sandbox.rs` (fn pure `compile_sandbox_plan`) et `crates/domain/src/permission.rs` (fn pure `render_permission_summary`). Tout est pur, zéro I/O, pas de fake nécessaire. Invariants à couvrir impérativement : 1. `compile_sandbox_plan(None, ctx) == None` (rien posé ⇒ natif). Et `Some(eff)` avec une politique posée mais `allowed` vide ⇒ `Some` (pas `None`) : présence de politique ≠ grants non vides. 2. Mapping capabilities : Read→RO, Write→RW, Delete→RW. `ExecuteBash` n'est JAMAIS traduit en grant (borne Landlock = fichiers only). Vérifie qu'une politique purement Bash ne produit aucun PathGrant. 3. Fail-closed (le cœur du lot) : un glob Allow est réduit à son préfixe statique (chemin littéral avant le 1er métacaractère `*`/`?`/`[`). Le grant est ABANDONNÉ si un Deny a un préfixe statique qui chevauche (égal, ancêtre OU descendant). Cas clés à tester : Allow `**` + un seul Deny fichier ⇒ aucun grant racine ; Allow `src/**` + Deny `src/secret/**` ⇒ grant `src` abandonné (descendant) ; Allow `src/**` + Deny `other/**` ⇒ grant `src` conservé (disjoint). Teste aussi l'union d'accès (RO+RW) sur une même racine. 4. `default_posture` du plan reflète bien la posture résiduelle résolue. 5. `render_permission_summary(None) == None` ; et pour `Some`, le bloc Markdown DOIT mentionner explicitement que les fichiers sont OS-enforced/Landlock when supported ET que les commandes (ExecuteBash) restent advisory / NOT OS-locked (assertions sur le contenu). Place les tests là où c'est cohérent avec le style existant du crate domain (tests inline `#[cfg(test)] mod tests` dans sandbox.rs, et dans permission.rs pour le résumé). Exécute `cargo test -p domain` et confirme le workspace reste vert. Réponds via idea_reply avec : nombre de tests ajoutés, sortie de `cargo test -p domain` (résumé pass/fail), et tout écart constaté entre le comportement réel et les invariants ci-dessus (si un invariant n'est pas respecté, rapport d'erreur précis pour DevBackend). +- **Response:** LP4-0 — Suite de tests unitaires domaine : VERT. Aucun écart constaté, tous les invariants sont respectés. ## Tests ajoutés : 17 - **sandbox.rs** (14 tests, nouveau `#[cfg(test)] mod tests`) : - `none_eff_yields_no_plan` (inv.1 : None ⇒ None) - `policy_with_no_allow_still_yields_some_plan` (inv.1 : politique posée + allowed vide ⇒ Some, testé fallback-seul ET deny-seul) - `read_maps_to_ro`, `write_and_delete_map_to_rw` (inv.2 : Read→RO, Write/Delete→RW) - `bash_only_policy_produces_no_path_grant` (inv.2 : ExecuteBash jamais traduit, 0 PathGrant) - `root_glob_with_single_file_deny_drops_root_grant` (inv.3 : Allow `**` + 1 Deny fichier ⇒ aucun grant racine) - `descendant_deny_drops_overlapping_allow` (inv.3 : Allow `src/**` + Deny `src/secret/**` ⇒ `src` abandonné) - `disjoint_deny_keeps_allow` (inv.3 : Allow `src/**` + Deny `other/**` ⇒ `src` conservé) - `ancestor_deny_also_drops_allow` (inv.3 : symétrie, deny ancêtre abandonne aussi) - `sibling_prefix_is_not_a_descendant` (inv.3 : `src2` ≠ descendant de `src`, conscience des bornes de composant) - `accesses_union_on_a_shared_root` (inv.3 : RO+RW fusionnés sur 1 grant) - `static_prefix_of_literal_file_is_the_file_itself` (préfixe statique d'un littéral) - `default_posture_mirrors_resolved_fallback` (inv.4 : Allow/Ask/Deny) - `trailing_slash_on_project_root_is_normalised` (pas de `//`) - **permission.rs** (3 tests, ajoutés au mod tests existant) : - `summary_is_none_when_nothing_posed` (inv.5 : None ⇒ None) - `summary_states_files_os_enforced_and_commands_advisory` (inv.5 : assertions contenu — "OS-enforced", "Landlock", "advisory", "NOT OS-locked", "ExecuteBash", scope + matcher + posture affichés) - `summary_handles_empty_rule_lists_per_section` (sections vides honnêtes + boundary) ## `cargo test -p domain` (résumé) - lib : **139 passed; 0 failed** (dont les 17 ci-dessus, vérifiés par nom) - tests d'intégration + doc-tests : tous OK, 0 failed - Tous les binaires : `0 failed` ## Workspace `cargo test --workspace` : tout vert, **0 failed**, aucune erreur de compilation, aucun warning. Non-régression confirmée. Note pour DevBackend (observation, pas un bug) : dans `compile_sandbox_plan`, les Deny servant de barrières (`deny_roots`) sont collectés indépendamment de la capability — un Deny `Write` clôture donc aussi un Allow `Read` sur la même racine. C'est conforme à l'esprit fail-closed (sous-approximation conservatrice) et bien couvert par les tests ; je le signale uniquement au cas où la sémantique voulue serait par-capability. Si c'est intentionnel (probable vu la doc), rien à faire. +- **Prompt:** [LP4-0 — réécriture des tests après affinage sémantique « barrières Deny par classe d'accès »] Décision d'orchestration appliquée par DevBackend : dans `compile_sandbox_plan` (`crates/domain/src/sandbox.rs`), les barrières Deny sont désormais PAR CLASSE D'ACCÈS — RO (alimentée par Allow Read, fermée seulement par Deny Read) et RW (alimentée par Allow Write+Delete, fermée seulement par Deny Write+Delete). Un Deny d'une autre classe n'a aucun effet. ExecuteBash : toujours ignoré. DevBackend a marqué 3 de tes tests `#[ignore = "OBSOLETE … 2db80d41 … QA to rewrite"]` car ils encodaient l'ancienne sémantique capability-blind (un Deny Write faisait tomber un Allow Read). À réécrire pour qu'ils soient probants sous la nouvelle sémantique, puis retirer le `#[ignore]` : 1. `root_glob_with_single_file_deny_drops_root_grant` → utilise une barrière de MÊME classe : `Allow Read **` + `Deny Read ` ⇒ le grant racine RO doit être abandonné (fail-closed intra-classe). Garde l'esprit « un deny intra-classe fait tomber la racine ». 2. `descendant_deny_drops_overlapping_allow` → `Allow Read src/**` + `Deny Read src/secret/**` ⇒ grant `src` (RO) abandonné. ET ajoute l'assertion DUALE qui protège la décision produit : `Allow Read src/**` + `Deny Write src/secret/**` (classe différente, aucun Deny Read) ⇒ grant `src` CONSERVE le RO. C'est le cas clé d'autonomie à verrouiller. 3. `ancestor_deny_also_drops_allow` → `Allow Read src/sub/**` + `Deny Read src/**` ⇒ grant abandonné (barrière ancêtre même classe). Renforce aussi les 2 tests « affaiblis » signalés (`disjoint_deny_keeps_allow`, `sibling_prefix_is_not_a_descendant`) en utilisant une barrière de MÊME classe que l'allow, sinon ils prouvent la disjointness pour une mauvaise raison. Ajoute idéalement un test croisé explicite : sur une même racine, `Allow Read` + `Allow Write` + `Deny Write` même sous-arbre ⇒ le grant survit en RO seul (RW tombé, RO conservé) — la preuve directe de la granularité par classe. Exécute `cargo test -p domain` puis `cargo test --workspace`. Confirme 0 ignored restant sur sandbox.rs et 0 failed. Réponds via idea_reply avec : tests réécrits/ajoutés, sortie pass/fail, et confirmation qu'aucun `#[ignore]` LP4-0 ne subsiste. +- **Response:** LP4-0 — Réécriture sous sémantique « barrières Deny par classe d'accès » : VERT. Aucun `#[ignore]` LP4-0 ne subsiste. ## Tests réécrits (3 ex-ignorés, désormais probants en barrière MÊME classe) 1. `root_glob_with_single_file_deny_drops_root_grant` → renommé `root_glob_with_same_class_deny_drops_root_grant` : `Allow Read **` + `Deny Read secret.txt` ⇒ grant racine RO abandonné (fail-closed intra-classe RO). 2. `descendant_deny_drops_overlapping_allow` → renommé `descendant_same_class_deny_drops_overlapping_allow` : `Allow Read src/**` + `Deny Read src/secret/**` ⇒ grant `src` (RO) abandonné. 3. `ancestor_deny_also_drops_allow` → renommé `ancestor_same_class_deny_also_drops_allow` : `Allow Read src/sub/**` + `Deny Read src/**` ⇒ grant abandonné (barrière ancêtre même classe). ## Tests renforcés (barrière de MÊME classe que l'allow, sinon disjointness prouvée pour une mauvaise raison) - `disjoint_deny_keeps_allow` → `disjoint_same_class_deny_keeps_allow` : `Allow Read src/**` + `Deny Read other/**` ⇒ `src` conservé en RO. - `sibling_prefix_is_not_a_descendant` : passe la barrière en `Deny Read src/**` (vs Allow Read `src2/**`) ⇒ `src2` conservé par conscience des bornes de composant, pas par mismatch de classe. ## Tests ajoutés (verrous de la décision produit / granularité par classe) - `other_class_deny_does_not_amputate_read_allow` (assertion DUALE clé d'autonomie) : `Allow Read src/**` + `Deny Write src/secret/**` (classe différente) ⇒ `src` CONSERVE le RO. - `same_root_drops_rw_but_keeps_ro_under_a_write_deny` (preuve directe de la granularité par classe) : `Allow Read src/**` + `Allow Write src/**` + `Deny Write src/secret/**` ⇒ grant `src` survit en RO seul (RW tombé, RO conservé). ExecuteBash : toujours couvert par `bash_only_policy_produces_no_path_grant` (0 PathGrant). Invariants 1/2/4 et bords (sibling, préfixe statique, trailing slash, union RO|RW) inchangés et verts. ## Sortie pass/fail - `cargo test -p domain` (lib) : **141 passed; 0 failed; 0 ignored**. Bloc sandbox : **16 tests sandbox::tests, tous ok** (liste vérifiée par nom). Intégration + doc-tests : 0 failed. - `cargo test --workspace` : tout vert — 80 lignes « test result », **toutes 0 failed**, aucune erreur de compilation. - `grep -rn "#[ignore]"` sur `crates/domain/src/` : **aucun attribut `#[ignore]`** (seules des occurrences du mot « ignore » en commentaires subsistent). **0 ignored restant sur sandbox.rs confirmé.** +- **Prompt:** [LP4-1 — passe de tests d'intégration de l'enforcement Landlock] DevBackend a livré LP4-1 : `crates/infrastructure/src/sandbox/{mod,landlock,noop}.rs`. `LandlockSandbox` (Linux) applique un `SandboxPlan` en restreignant le thread courant (mécanisme : thread jetable + héritage du domaine Landlock au fork/exec, car portable-pty n'expose pas de pre_exec). Son test `landlock_write_only_plan_fences_writes_to_the_grant` passe déjà réellement sur ce kernel. Renforce/complète la couverture d'intégration (gated `#[cfg(target_os="linux")]` + garde runtime : skip propre si Landlock indisponible, comme les tests SSH/WSL). Couvre impérativement : 1. **PROPRIÉTÉ DE SÛRETÉ CRITIQUE — IdeA n'est jamais sandboxé.** Après un `enforce()` exécuté sur un thread jetable (comme le fait l'adapter), le THREAD principal/process de test (qui simule IdeA) doit toujours pouvoir écrire/lire HORS des racines du plan. Prouve que la restriction est confinée au thread jetable et n'a pas fui sur le process IdeA. C'est le garde-fou n°1 (un Landlock posé sur IdeA serait catastrophique). 2. **Dimension READ (RO).** Un plan avec un grant RO sur une racine donnée : depuis l'enfant restreint, lire un fichier SOUS la racine accordée ⇒ OK ; lire un fichier HORS racine ⇒ refusé (EACCES). Complète la dimension write déjà testée. (Note remontée par DevBackend, à confirmer par le test : dès qu'un grant RO est posé, TOUTES les lectures hors racines deviennent fermées — donc un plan read-restreint réaliste devra inclure les chemins système ; c'est un concern LP4-2, mais documente le comportement observé par un test.) 3. **Plan vide / bash-only** ⇒ `Enforced`, aucune restriction effective, pas d'erreur. 4. **NoopSandbox** ⇒ `kind()==Unsupported`, `enforce` (y compris posture Deny) ⇒ `Ok(Unsupported)`, jamais Err. 5. **Fail-closed posture Deny** : si tu peux simuler/forcer un cas où Landlock ne s'applique pas (sinon documente pourquoi non testable sur ce kernel), vérifie que posture `Deny` ⇒ `Err(KernelTooOld)` alors que posture non-Deny ⇒ `Ok(Unsupported)`. Exécute `cargo test -p infrastructure` puis `cargo test --workspace`. Réponds via idea_reply avec : tests ajoutés, lesquels ont réellement tourné vs skippés sur ce kernel, sortie pass/fail, et tout écart de comportement (notamment si la propriété de sûreté n°1 n'est PAS vérifiée — rapport d'erreur immédiat pour DevBackend, c'est bloquant). +- **Response:** LP4-1 — Passe de tests d'intégration de l'enforcement Landlock : VERT. La propriété de sûreté n°1 (IdeA jamais sandboxé) est VÉRIFIÉE. Aucun écart. ## Tests ajoutés (4) - `sandbox::landlock::tests::enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed` — **PROPRIÉTÉ DE SÛRETÉ n°1**. enforce() lancé sur un thread jetable (comme l'adapter), grant RW sur `granted`. On prouve d'abord que le thread jetable EST sandboxé (write hors racine = PermissionDenied), PUIS que le thread parent (≈ process IdeA) écrit ET lit toujours HORS racines ⇒ le domaine Landlock ne fuit pas sur IdeA. - `sandbox::landlock::tests::read_only_plan_fences_reads_to_the_grant` — **dimension READ (RO)**. Fichiers pré-créés avant enforce. Grant RO sur `granted` : lecture SOUS la racine = OK ; lecture HORS = PermissionDenied (EACCES). Confirme et documente le comportement signalé par DevBackend : dès qu'un grant RO est posé, la classe read est *handled* ⇒ TOUTES les lectures hors racines sont fermées (un plan read-restreint réaliste devra inclure les chemins système — concern LP4-2). - `sandbox::landlock::tests::empty_plan_under_deny_posture_is_enforced_without_restriction` — **plan vide / bash-only sous posture Deny**. Un plan `allowed` vide (= bash-only après compile LP4-0) avec `Posture::Deny` ⇒ `Enforced` sans aucune restriction et SANS `KernelTooOld` (la branche fail-closed n'est atteinte que si une classe a réellement été *handled*). - `sandbox::tests::noop_enforcer_is_unsupported_and_never_errors` (renforcé) — **NoopSandbox** : `kind()==Unsupported` ; `enforce` ⇒ `Ok(Unsupported)` jamais Err, désormais bouclé sur les 3 postures (Allow/Ask/Deny) ET avec un grant présent. (Existants conservés : `landlock_write_only_plan_fences_writes_to_the_grant`, `empty_plan_is_a_noop_enforced`, `default_enforcer_matches_the_build_target`.) ## Réellement tourné vs skippé sur ce kernel - **Tout a réellement tourné — 0 skip.** Aucune ligne « skipping: Landlock not available » émise (vérifié avec `--nocapture | grep -i skip` ⇒ « no tests skipped »). Les gardes runtime de skip sont en place (comme SSH/WSL) mais inutiles ici : ce kernel supporte Landlock, l'enforcement est réel (FullyEnforced) — write hors-grant et read hors-grant renvoient bien PermissionDenied. ## Invariant 5 (fail-closed posture Deny) — NON testable sur ce kernel, documenté - La branche `Err(KernelTooOld)` n'est atteinte que si `restrict_self()` renvoie `RulesetStatus::NotEnforced` ET posture==Deny. Sur un kernel Landlock-capable, `restrict_self` renvoie toujours FullyEnforced/PartiallyEnforced, jamais NotEnforced. Impossible de forcer NotEnforced sans injecter/mocker le crate `landlock` (l'adapter ne prend pas de dépendance injectable pour le statut). Le pendant « pas d'OS sandbox + non-Deny ⇒ Ok(Unsupported) » est, lui, couvert par NoopSandbox. Recommandation (non bloquante) pour DevBackend si on veut tester cette branche : extraire le mapping `(RulesetStatus, Posture) -> Result` en fn pure et la tester en table. ## Sortie pass/fail - `cargo test -p infrastructure` (lib) : **154 passed; 0 failed; 0 ignored**. Bloc sandbox : 8 tests, tous ok (write-only, read-only, safety-confinement, empty, empty+Deny, noop×3-postures, default_enforcer). - `cargo test -p infrastructure` (tous binaires + doc-tests) : **0 failed**. - `cargo test --workspace` : tout vert, **0 failed**, aucune erreur de compilation. Aucune violation de la propriété de sûreté n°1. Rien de bloquant à remonter. +- **Prompt:** Test bout-en-bout de l'enforcement OS (lot LP4-3, sandbox Landlock), à ajouter et faire passer. Contexte : la chaîne policy → plan → PTY est désormais câblée. DevBackend vient de livrer (non committé) : - composition root : `PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer())` - launch path (`crates/application/src/agent/lifecycle.rs`, étape 5d) : `spec.sandbox = compile_sandbox_plan(effective_permissions.as_ref(), &SandboxContext{ project_root, run_dir })`. - l'enforcement effectif vit dans `PortablePtyAdapter::spawn` (chemin PTY brut), via `spawn_command_sandboxed` (thread restreint par Landlock puis fork). Objectif : UN test d'intégration de haute fidélité qui prouve que l'enforcement est réellement actif bout-en-bout, SANS lancer de vrai CLI IA (zéro token). Piste recommandée (la plus fidèle, déjà éprouvée par les tests Landlock existants dans `crates/infrastructure/src/sandbox/landlock.rs`) : passer par `PortablePtyAdapter` réel avec un enforcer Landlock, un `SpawnSpec` dont `sandbox = Some(plan)` où le plan autorise un répertoire et un `Deny` effectif sur un autre, et une commande shell inoffensive (`sh -c 'echo x > /chemin/interdit'`) ; asserter que l'écriture échoue (fichier absent / exit non nul) alors qu'une écriture dans le chemin autorisé réussit. Skippe proprement (`#[cfg(target_os="linux")]` + détection de l'ABI Landlock comme le font les tests existants) si le kernel ne supporte pas Landlock, pour ne pas rendre la CI rouge sur un runner sans Landlock. Réutilise au maximum les helpers/patterns des tests Landlock existants (regarde `sandbox/landlock.rs` tests : read_only_plan_fences_reads_to_the_grant, landlock_write_only_plan_fences_writes_to_the_grant, enforcement_is_confined_to_the_enforcing_thread). Le test doit cibler le chemin PTY (pas structuré). À la fin : exécute le test et donne la sortie réelle. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : le fichier de test ajouté, ce qu'il prouve, la stratégie de skip, et la sortie brute d'exécution. Si ça échoue, rapport d'erreur clair avec la sortie — on renverra à DevBackend. +- **Response:** ✅ VERT — test bout-en-bout de l'enforcement OS (lot LP4-3) ajouté et passant, enforcement Landlock réellement actif sur le chemin PTY (pas skippé sur ce kernel). ## Fichier modifié `crates/infrastructure/src/pty/mod.rs` — nouveau module de test `#[cfg(all(test, target_os = "linux"))] mod sandbox_e2e_tests` (en plus du module `tests` existant, intact). 2 tests + helpers. ## Ce que ça prouve Test principal **`pty_spawn_enforces_sandbox_plan_end_to_end`** — exerce la chaîne réelle complète, ZÉRO CLI IA / ZÉRO token : `PortablePtyAdapter::new().with_sandbox_enforcer(default_enforcer())` (= LandlockSandbox) → `SpawnSpec { sandbox: Some(plan) }` avec `plan` = `PathGrant{ allowed RW }` seul, posture `Ask` → `adapter.spawn(...)` → branche `(Some(plan), Some(enforcer))` → `spawn_command_sandboxed` (thread restreint par Landlock puis fork/exec). - Commande inoffensive : `sh -c "echo outside > /out.txt; echo inside > /in.txt"`. - **Ordre volontaire** : l'écriture HORS-grant d'abord, l'écriture DANS le grant ensuite. L'apparition du marqueur `allowed/in.txt` est donc un signal *happens-after* prouvant que la tentative hors-grant a déjà eu lieu → assertion déterministe, pas de course avec l'enfant. - Asserts : (1) `allowed/in.txt` finit par exister (écriture autorisée passe ; reads non gouvernés car seul RW posé → `sh`/libc chargent normalement) ; (2) `denied/out.txt` **n'existe PAS** → l'écriture hors-grant a été bloquée par le kernel = « SANDBOX BREACH » sinon. Test compagnon **`pty_spawn_without_plan_does_not_sandbox`** — même adapter+enforcer mais `sandbox: None` → l'écriture dans le même dossier réussit. Garde-fou anti faux-positif : prouve que le blocage du 1er test vient bien du *plan enforcé*, pas d'une restriction PTY ambiante ou d'un échec d'écriture sans rapport. ## Stratégie de skip (CI/kernel sans Landlock) - Tout le module est `#[cfg(target_os = "linux")]` (pas de compilation hors Linux). - Helper `landlock_is_enforced()` qui sonde l'ABI exactement comme les tests adapter existants : `LandlockSandbox::enforce(plan RW)` sur un **thread jetable** (restriction irréversible → jamais sur le thread de test), skip si `SandboxStatus::Unsupported`. Le test principal `return` proprement avec message `eprintln!` si non enforcé → CI verte sur runner sans Landlock LSM. - Helpers réutilisés/calqués sur `sandbox/landlock.rs` : `fresh_dir(tag)` (temp dir unique, zéro dép tempfile), enforce sur thread jetable, posture `Ask`. Attente bornée via `wait_for(path, 5s)`. ## Sortie brute d'exécution ``` $ cargo test -p infrastructure --lib sandbox_e2e_tests -- --nocapture --test-threads=1 Finished `test` profile [unoptimized + debuginfo] target(s) in 1.53s Running unittests src/lib.rs (target/debug/deps/infrastructure-2cc30d2a1793b4e4) running 2 tests test pty::sandbox_e2e_tests::pty_spawn_enforces_sandbox_plan_end_to_end ... ok test pty::sandbox_e2e_tests::pty_spawn_without_plan_does_not_sandbox ... ok test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 154 filtered out; finished in 0.05s ``` Preuve que l'enforcement a **réellement** tourné (et non été skippé) sur ce kernel — les tests adapter Landlock existants ne logguent AUCUN « skipping » : ``` $ cargo test -p infrastructure --lib sandbox::landlock -- --nocapture test sandbox::landlock::tests::read_only_plan_fences_reads_to_the_grant ... ok test sandbox::landlock::tests::landlock_write_only_plan_fences_writes_to_the_grant ... ok test sandbox::landlock::tests::enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed ... ok test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 151 filtered out; finished in 0.00s ``` Pas de commit (figé par l'orchestrateur, comme demandé). Aucune régression introduite : ajout de tests uniquement, module `tests` PTY existant inchangé. +- **Prompt:** Tests bout-en-bout du lot LP4-4 — enforcement Landlock sur le chemin STRUCTURÉ (sessions Claude/Codex mode JSON). DevBackend a livré (non committé), build + suites existantes vertes. L'Architecte a validé l'approche et défini 7 invariants à couvrir. Objectif : prouver l'enforcement réellement actif bout-en-bout sur le chemin structuré, ZÉRO token (aucun vrai claude/codex — utilise un fake CLI / `sh` qui émet une ligne JSONL). Chaîne réelle livrée : `StructuredSessionFactory::new().with_sandbox_enforcer(default_enforcer())` (composition root) ; `AgentSessionFactory::start(.., sandbox: Option<&SandboxPlan>)` apparie plan (par-appel) + enforcer (par-instance) ; `SpawnLine.sandbox: Option` ; `run_turn(spec, timeout, enforcer)` route vers `run_turn_sandboxed`/`drain_sandboxed` (`#[cfg(target_os="linux")]`, thread jetable restreint par enforce() AVANT le spawn std, puis std::process::Command::spawn depuis ce thread → héritage Landlock ; timeout via oneshot killer + tokio::time::timeout ; fail-closed sur Err d'enforce). Pas de pre_exec (forbid(unsafe_code) ; héritage credentials garanti par le noyau). Réutilise les patterns/helpers des tests Landlock existants (`crates/infrastructure/src/sandbox/landlock.rs` tests + le module `sandbox_e2e_tests` ajouté dans `pty/mod.rs` au lot LP4-3) : `landlock_is_enforced()` (skip propre kernel sans Landlock), enforce sur thread jetable, `fresh_dir`, attente bornée. LES 7 INVARIANTS À COUVRIR (de l'Architecte) : 1. PARITÉ (test pivot) : fake CLI/`sh` qui émet une ligne JSONL et tente d'écrire HORS grant (doit être bloqué kernel) et DANS grant (doit réussir). Cible le chemin structuré (run_turn_sandboxed via la factory réelle). 2. COMPANION NÉGATIF : même factory+enforcer mais sandbox==None ⇒ écriture hors-grant RÉUSSIT (prouve que le blocage vient du plan, pas d'une restriction ambiante). 3. FAIL-CLOSED : posture Deny sur kernel sans Landlock ⇒ enforce Err ⇒ run_turn renvoie erreur (Start) et AUCUN child ne tourne (marqueur de sortie absent). 4. NO-OP PAR DÉFAUT : eff==None ⇒ plan None ⇒ chemin async tokio actuel, comportement natif (vérifie que conformance/D0/D3 restent verts sans changement de comportement). 5. CONFINEMENT IRRÉVERSIBILITÉ : deux tours successifs (sessions différentes) avec grants DISJOINTS ⇒ chacun voit son propre périmètre, le tour précédent n'affecte pas le suivant (thread jetable mort). 6. TIMEOUT SOUS SANDBOX : fake CLI qui ne ferme jamais stdout ⇒ run_turn(timeout) tue le child et renvoie Timeout, sans zombie ni thread bloqué. 7. RESUME PRÉSERVÉ : le conversation_id/session_id est toujours capté correctement après un tour sandboxé (la restriction FS ne casse pas le parsing). Tout derrière `#[cfg(target_os="linux")]` + skip via `landlock_is_enforced()`. À la fin : exécute et donne la sortie BRUTE. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : fichier(s) de test ajoutés, mapping test→invariant (les 7), stratégie de skip, et sortie d'exécution réelle. Si un invariant ne passe pas, rapport d'erreur clair avec sortie brute → je renvoie à DevBackend. +- **Response:** ✅ VERT — Tests bout-en-bout LP4-4 (enforcement Landlock sur le chemin STRUCTURÉ, sessions Claude/Codex JSON) ajoutés et passants. Les 7 invariants de l'Architecte couverts. ZÉRO token (aucun vrai claude/codex : `sh` qui émet une ligne JSONL + tente des écritures FS, ou le `FakeCli` scriptable existant). ## Fichiers - **Ajouté** : `crates/infrastructure/src/session/sandbox_e2e.rs` — 7 tests + helpers, derrière `#[cfg(all(test, target_os = "linux"))]`. - **Modifié** : `crates/infrastructure/src/session/mod.rs` — déclaration `#[cfg(all(test, target_os = "linux"))] mod sandbox_e2e;` (uniquement la ligne de module, suites existantes intactes). ## Mapping test → invariant (les 7) 1. **PARITÉ (pivot)** → `pty_structured_run_turn_enforces_plan_end_to_end` : `run_turn` + plan RW(allowed) + enforcer Landlock. Asserte : la ligne JSONL est **drainée** (parsing intact sous sandbox), écriture in-grant réussit, écriture hors-grant **bloquée noyau** (fichier absent). Exact pendant du pivot PTY LP4-3, mais via `process::run_turn_sandboxed`. 2. **COMPANION NÉGATIF** → `structured_run_turn_without_plan_does_not_sandbox` : même enforcer câblé mais `sandbox==None` ⇒ écriture hors-grant RÉUSSIT ⇒ le blocage (1) vient du plan, pas d'une restriction ambiante. 3. **FAIL-CLOSED** → `structured_run_turn_fail_closed_no_child_on_enforce_err` : enforcer double `AlwaysFailEnforcer` (renvoie `SandboxError::KernelTooOld`, simule fidèlement « Deny + kernel sans Landlock » de façon **déterministe**, indépendamment du kernel de CI). `run_turn` ⇒ `AgentSessionError::Start` ET le marqueur que le child aurait écrit reste **absent** (aucun enfant lancé). 4. **NO-OP PAR DÉFAUT** → `structured_run_turn_none_plan_is_native_path` : `plan==None` (même avec enforcer fourni) ⇒ chemin async tokio historique, lignes drainées + écriture arbitraire réussit (aucune restriction). Non-régression confirmée par la suite `session::` complète (cf. ci-dessous). 5. **CONFINEMENT/IRRÉVERSIBILITÉ** → `structured_two_turns_disjoint_grants_are_confined` : deux `run_turn` successifs à grants DISJOINTS (A puis B). Chacun écrit dans son grant (ok) et dans l'autre (bloqué). Le tour B écrit bien dans B ⇒ la restriction du thread jetable de A **n'a pas bavé**. 6. **TIMEOUT SOUS SANDBOX** → `structured_run_turn_timeout_under_sandbox` : `sleep 30` (stdout jamais fermé) via la branche sandboxée, `run_turn(timeout 250ms)` ⇒ `AgentSessionError::Timeout` rapidement (asserté < 10s, pas d'attente des 30s) ⇒ killer oneshot + `tokio::time::timeout` OK, pas de thread bloqué. 7. **RESUME PRÉSERVÉ** → `structured_sandboxed_turn_preserves_conversation_id` : via la **fabrique réelle** `StructuredSessionFactory::new().with_sandbox_enforcer(default_enforcer())`, profil Claude sur `FakeCli` (init+result), `start(.., Some(&plan))`. Sous enforcement actif, `send()` rend exactement un `Final` et `conversation_id()` ⇒ `Some("conv-sbx-1")` ⇒ la restriction FS ne casse pas le parsing/la capture d'id. ## Stratégie de skip - Tout le module est `#[cfg(all(test, target_os = "linux"))]` (rien hors Linux). - Helper `landlock_is_enforced()` (calqué sur `sandbox/landlock.rs` et `pty::sandbox_e2e_tests`) : `enforce` d'un plan RW sur **thread jetable** (restriction irréversible ⇒ jamais sur le thread de test), skip propre (`eprintln!` + `return`) si `SandboxStatus::Unsupported`. - Gardés par `landlock_is_enforced()` (exigent un fencing réel) : invariants **1, 5, 7**. Indépendants du kernel (déterministes sans Landlock) : **2** (contrôle natif), **3** (fake enforcer), **4** (plan None), **6** (timeout — branche sandboxée prise sous posture Ask, enforce Ok même sans Landlock). - Helpers réutilisés : `fresh_dir`, posture `Ask`, ligne JSONL `result` réaliste (format Claude vérifié), `FakeCli` de `conformance`. ## Sortie brute d'exécution ``` $ cargo test -p infrastructure --lib session::sandbox_e2e -- --nocapture --test-threads=1 Finished `test` profile [unoptimized + debuginfo] target(s) in 5.01s Running unittests src/lib.rs (target/debug/deps/infrastructure-2cc30d2a1793b4e4) running 7 tests test session::sandbox_e2e::pty_structured_run_turn_enforces_plan_end_to_end ... ok test session::sandbox_e2e::structured_run_turn_fail_closed_no_child_on_enforce_err ... ok test session::sandbox_e2e::structured_run_turn_none_plan_is_native_path ... ok test session::sandbox_e2e::structured_run_turn_timeout_under_sandbox ... ok test session::sandbox_e2e::structured_run_turn_without_plan_does_not_sandbox ... ok test session::sandbox_e2e::structured_sandboxed_turn_preserves_conversation_id ... ok test session::sandbox_e2e::structured_two_turns_disjoint_grants_are_confined ... ok test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 156 filtered out; finished in 0.27s ``` **Aucune ligne « skip » imprimée** (run `--nocapture`) ⇒ les invariants gardés (1, 5, 7) ont **réellement** posé l'enforcement sur ce kernel (Landlock actif), pas été contournés. Non-régression (invariant 4 : conformance/D0/D3 + voisins) : ``` === session === test result: ok. 56 passed; 0 failed; ... === sandbox === test result: ok. 7 passed; 0 failed; ... === pty === test result: ok. 5 passed; 0 failed; ... ``` Pas de commit (figé par l'orchestrateur, comme demandé). Ajout de tests uniquement + 1 ligne de déclaration de module ; aucun code de prod touché. \ No newline at end of file diff --git a/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl new file mode 100644 index 0000000..d817152 --- /dev/null +++ b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl @@ -0,0 +1,37 @@ +{"id":"44bc8d1d-93e9-46b3-94a8-8d11ab72ae82","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781369145058,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche : ajouter UN test fonctionnel anti-régression de la communication inter-agent (round-trip question/réponse) au plus haut niveau de fidélité possible SANS lancer de vrai CLI IA (zéro token). Contexte précis ci-dessous, suis-le à la lettre.\n\n## Où\nFichier : crates/app-tauri/src/state.rs, dans le module de test existant `#[cfg(test)] mod mcp_serve_peer_tests` (commence vers la ligne 1893). Ce module pilote déjà la fn privée `serve_peer` sur un `tokio::io::duplex` (pas de socket, pas de process). Réutilise au maximum ses helpers existants : `build_service`, `spawn_peer`, `handshake_line`, `tools_call_line`, `read_one_response`, `project()`, `project_id_arg`, `McpServer::new`.\n\n## Le trou à combler\nLe module couvre handshake+tools/list, propagation du requester, mismatch projet, pairs concurrents — MAIS PAS le round-trip `idea_ask_agent` → `idea_reply` à travers `serve_peer`. C'est la glue critique : l'identité de l'agent répondeur vient de la ligne de handshake (`requester`), et c'est elle qui permet à `idea_reply` de corréler la réponse au ticket en attente. Référence du comportement attendu : `crates/infrastructure/tests/mcp_server.rs::ask_agent_returns_target_reply_inline` (qui, lui, court-circuite le transport via handle_raw/for_requester ; ton test doit passer par serve_peer + handshake réel).\n\n## Problème à régler d'abord\nLe `build_service` actuel du module construit l'`OrchestratorService` via `OrchestratorService::new(...)` SANS câbler le médiateur d'entrée ni le mailbox ni le registre de conversations. Or `ask_agent` exige `with_input_mediator(...)` + (idéalement) `with_conversations(...)`, sinon il renvoie « la messagerie inter-agents n'est pas disponible ». \n=> Ajoute dans le module un `build_service_with_mailbox(contexts) -> (Arc, Arc, Arc)` en PORTANT les fakes déjà éprouvés depuis `crates/application/tests/orchestrator_service.rs` (sections après la ligne ~806) : `TestMailbox` (impl `domain::mailbox::AgentMailbox`), `TestMediator` (impl `domain::input::InputMediator`, écrit le tour dans le PTY lié + délègue l'enqueue au TestMailbox), `TestConversations` (impl `domain::conversation::ConversationRegistry`). Câble : `.with_input_mediator(mediator, mailbox).with_conversations(conversations)`. Garde le profil Claude complet déjà présent dans `build_service` (adaptateur structuré + capacité MCP) pour passer la garde F2. Tu peux factoriser le profil pour éviter la duplication. Ajoute aussi un helper `seed_live_pty(sessions, agent_id, session_id)` (copie de celui d'orchestrator_service.rs) pour que la cible soit déjà vivante en PTY et qu'`ask_agent` la réutilise sans lancer de process.\n\n## Le test à écrire (un seul, simple)\n`async fn ask_reply_round_trips_over_serve_peer_via_handshake_requester()` :\n1. `let proj = project();` ; `let contexts = FakeContexts::new();` ; `let target_id = contexts.seed_agent(\"architect\");`\n2. `let (service, mailbox, sessions) = build_service_with_mailbox(contexts);`\n3. `seed_live_pty(&sessions, target_id, );` (cible vivante → pas de spawn)\n4. `let server = Arc::new(McpServer::new(service, proj.clone()));`\n5. Peer A (le demandeur = humain) : `spawn_peer(Arc::clone(&server), project_id_arg(&proj))`. Écris `handshake_line(&project_id_arg(&proj), \"\")` (requester vide ⇒ ask d'origine humaine, le plus simple : évite la garde de cycle et le besoin que le demandeur soit un agent enregistré), puis `tools_call_line(7, \"idea_ask_agent\", json!({\"target\":\"architect\",\"task\":\"What is the answer?\"}))`. NE bloque pas le test : lis la réponse de A dans une task spawnée OU lis-la après avoir débloqué via B (voir étape 7).\n6. Attends (borné par TIMEOUT, via boucle `mailbox.pending(&target_id) == 1` + `tokio::task::yield_now().await`) que A ait enqueué son ticket et soit en attente.\n7. Peer B (la cible qui répond) : `spawn_peer(Arc::clone(&server), project_id_arg(&proj))`. Écris `handshake_line(&project_id_arg(&proj), &target_id.to_string())` (CRUCIAL : le requester du handshake = l'id de la cible, c'est ce qui fait que `idea_reply` corrèle au mailbox de la cible), puis `tools_call_line(8, \"idea_reply\", json!({\"result\":\"the answer is 42\"}))`. Lis la réponse de B : `isError == false`.\n8. Lis la réponse de A : `isError == false` et le texte inline == \"the answer is 42\" (le texte est dans result[\"content\"][0][\"text\"], cf. `result_text` dans mcp_server.rs — réplique ce petit helper si besoin).\nGARDE-FOU obligatoire : borne chaque attente/lecture par `tokio::time::timeout(TIMEOUT, …)` (TIMEOUT existe déjà dans le module) pour qu'une régression échoue vite au lieu de hang.\n\n## Contraintes\n- Respecte l'archi hexagonale : les fakes implémentent les ports du domaine, aucune dépendance nouvelle.\n- Code lisible, commenté dans le style du module (doc-comments expliquant POURQUOI le handshake requester est le point testé).\n- Lance le test et rends-moi la SORTIE RÉELLE de `cargo test -p app-tauri-lib mcp_serve_peer_tests` (ou le nom de crate exact). Si ça ne compile pas / échoue, débogue jusqu'au vert et rends le diagnostic.\n\nRends ton résultat via idea_reply : (a) le diff/chemin du test ajouté, (b) la sortie cargo test réelle (vert ou rouge avec détail), (c) toute difficulté rencontrée."} +{"id":"670380a0-dc48-4c50-843d-ca6e39648ca8","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781380614941,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"TESTS (lot T1 + couverture des nouvelles surfaces Codex). Le code de production est déjà implémenté par DevBackend et le workspace compile + tests existants verts. Ta mission : prouver, SANS lancer de vrai CLI (zéro token), que la délégation inter-agents marche AUSSI pour un profil Codex, et couvrir les nouvelles surfaces.\n\n## Ce qui a été ajouté (à tester)\n- Domaine `crates/domain/src/profile.rs` : variante `McpConfigStrategy::TomlConfigHome { target, home_env }` + constructeur `toml_config_home(...)` ; `AgentProfile::materializes_idea_bridge()` (whitelist : Claude+ConfigFile(\".mcp.json\"), Codex+TomlConfigHome) ; struct partagée `McpServerWiring` + encodeur TOML.\n- Application `crates/application/src/agent/lifecycle.rs` : `apply_mcp_config` bras `TomlConfigHome` (écrit `{runDir}/` via fs.write, sémantique clobber/non-clobber selon runtime ; pousse `(home_env, parent(target))` dans `spec.env`).\n- Application `crates/application/src/orchestrator/service.rs` : `guard_mcp_bridge_supported` ré-exprimée via `materializes_idea_bridge()`.\n- Catalogue `crates/application/src/agent/catalogue.rs` : profil Codex intégré porte `Codex` + `toml_config_home(\".codex/config.toml\",\"CODEX_HOME\")`.\n- app-tauri `crates/app-tauri/src/state.rs` : `is_codex_mcp_profile`, `migrate_codex_run_dir`, `mcp_server_entry_toml`.\n\n## Travail demandé\n1. **Audite d'abord la couverture existante** (DevBackend a déjà posé des tests unitaires, ex. catalogue `mcp_tests`, profile.rs). Ne duplique pas — complète seulement les trous. Vérifie qu'il existe des tests pour :\n - D1 : (de)sérialisation `TomlConfigHome` (tag \"strategy\"), rejet target \"..\"/absolu, rejet home_env invalide ; `materializes_idea_bridge()` vrai/faux par couple (dont Codex sans mcp ⇒ false, Codex+ConfigFile ⇒ false).\n - D2 : encodeur TOML (`[mcp_servers.idea]`, args ordonnés, échappement chemin avec espaces/backslash, transport).\n - A1 : fake `FileSystem` reçoit le write au bon chemin `{runDir}/.codex/config.toml` avec TOML attendu ; `spec.env` contient `(\"CODEX_HOME\", \"{runDir}/.codex\")` ; clobber si runtime=Some, non-clobber si None.\n - A2 : garde ⇒ Codex+TomlConfigHome = Ok ; Codex sans mcp / Codex+ConfigFile = Invalid ; Claude+.mcp.json = Ok (non-régression) ; profil inconnu = Ok.\n - I1 : pendant Codex de `reconcile_*_repairs_legacy_files_on_disk` (réécrit `.codex/config.toml`, rafraîchit exe/endpoint, idempotent au 2e passage).\n Ajoute les tests manquants dans le crate/module idoine, en miroir des tests Claude équivalents.\n\n2. **Lot T1 — test fonctionnel dual Claude/Codex** (le livrable clé) dans `crates/app-tauri/src/state.rs`, module `mcp_e2e_loopback_tests`. Les 4 tests `*_over_real_loopback` (notamment `ask_then_reply_round_trips_inline_over_real_loopback`) doivent tourner À LA FOIS avec un profil Claude ET un profil Codex. Le round-trip sous le pont (loopback réel + fakes) est IDENTIQUE ; la seule différence testée = garde + profil. Approche : factorise le corps des tests sur un paramètre de profil (ex. enum/petite fn `claude_profile()` / `codex_profile()` produisant l'`AgentProfile` adéquat — Codex : structured=Codex, mcp=TomlConfigHome(\".codex/config.toml\",\"CODEX_HOME\"), transport Stdio) et instancie chaque test pour les deux (deux fns nommées, ou rstest si déjà dépendance — sinon deux fns explicites `*_claude`/`*_codex`, pas de nouvelle dépendance). Le handshake (`requester`/`project`), `idea_ask_agent` et `idea_reply` restent inchangés. Ajoute un cas explicite qui assert que la garde ACCEPTE une cible Codex (là où elle refusait avant). N'exécute JAMAIS un binaire `codex` : fakes uniquement, `seed_live_pty` pour la cible.\n GARDE-FOU : borne chaque attente/lecture par `tokio::time::timeout` (TIMEOUT existe dans le module).\n\n3. Lance et rends la SORTIE RÉELLE de : `cargo test -p domain`, `cargo test -p application`, `cargo test -p app-tauri --lib`. Boucle jusqu'au vert. Si rouge, diagnostique et corrige les TESTS (si c'est le code de prod qui est en cause, signale-le précisément pour que je renvoie à DevBackend — ne réécris pas le code de prod toi-même).\n\n## À rendre via idea_reply\n(a) liste des tests ajoutés (fichier::nom) ; (b) sortie cargo réelle des 3 commandes (vert exigé) ; (c) confirmation que le dual Claude/Codex passe pour les 4 round-trips ; (d) tout point nécessitant un retour DevBackend."} +{"id":"20960ff0-5277-45fa-87cd-c827c583b6ad","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781381308107,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"REPRISE CIBLÉE — tes 2 tours précédents n'ont pas produit de tests visibles. Scope RÉDUIT au strict minimum à fort impact pour tenir dans CE tour. N'audite plus, n'ajoute que CES DEUX tests, lance-les, réponds vite via idea_reply.\n\nLe code de prod est déjà en place et vert. Ne touche PAS au code de prod.\n\n### Test 1 — la garde accepte Codex (unitaire, application)\nDans `crates/application/tests/` (crée `codex_bridge_guard.rs` si besoin, ou ajoute au fichier de tests de l'orchestrateur existant). But : prouver `AgentProfile::materializes_idea_bridge()` (domaine) :\n- Claude + `McpConfigStrategy::config_file(\".mcp.json\")` + `StructuredAdapter::Claude` ⇒ `true`.\n- Codex + `McpConfigStrategy::toml_config_home(\".codex/config.toml\",\"CODEX_HOME\")` + `StructuredAdapter::Codex` ⇒ `true` (LE point clé : avant, Codex était refusé).\n- Codex SANS mcp ⇒ `false`. Codex + `config_file(\".mcp.json\")` ⇒ `false`.\nConstruis les `AgentProfile` via `AgentProfile::new(...).with_structured_adapter(...).with_mcp(McpCapability::new(...))` (regarde `crates/application/src/agent/catalogue.rs` lignes ~63-89 pour le modèle exact). Test unitaire pur, pas d'I/O.\n\n### Test 2 — round-trip Codex sur le vrai loopback (fonctionnel, app-tauri)\nDans `crates/app-tauri/src/state.rs`, module `mcp_e2e_loopback_tests`. Le test `ask_then_reply_round_trips_inline_over_real_loopback` (~ligne 3546) existe pour Claude. Ajoute SON JUMEAU Codex : `ask_then_reply_round_trips_inline_over_real_loopback_codex`. \n- Copie le corps, mais fais en sorte que le profil de la cible soit un profil **Codex** (structured=Codex, mcp=`toml_config_home(\".codex/config.toml\",\"CODEX_HOME\")`, transport Stdio). Regarde comment `build_service` du module construit son profil Claude (`with_structured_adapter(StructuredAdapter::Claude).with_mcp(...)`) et ajoute une variante `build_service_codex` (ou paramètre) qui produit le même service mais avec un profil Codex pour l'agent cible. Tout le reste (handshake `requester`=id cible, `idea_ask_agent`, `idea_reply`, `seed_live_pty`, assertions inline \"the answer is 42\") est IDENTIQUE.\n- Le but testé = la garde laisse passer Codex ET le round-trip aboutit. JAMAIS de vrai binaire codex : fakes uniquement. GARDE-FOU : borne par TIMEOUT comme l'original.\n\n### Rends via idea_reply (vite)\n(a) fichier::nom des 2 tests ; (b) sortie réelle de `cargo test -p application` ET `cargo test -p app-tauri --lib` (les 2 tests verts) ; (c) si rouge à cause du code de prod, dis-le précisément (je renvoie à DevBackend). Ne fais rien d'autre."} +{"id":"43c96b91-f2af-43af-9c15-80cd9b1ccbbc","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781383799625,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"TESTS UNIQUEMENT — anti-régression du chemin Codex inter-agents. Le code de PROD est déjà en place et le workspace est VERT (je viens de le vérifier : `cargo test -p domain -p application` et `cargo test -p app-tauri --lib` passent). NE TOUCHE PAS au code de prod. Tes 3 tours précédents n'ont laissé AUCUN test dans l'arbre — cette fois, ajoute réellement les fichiers/fns ci-dessous, lance cargo, et rends la sortie réelle via idea_reply. J'ai vérifié chaque ancre.\n\n## BLOC A — tests domaine (faciles, purs) dans `crates/domain/src/profile.rs`, module `#[cfg(test)] mod tests` qui commence ligne 734. Il NE contient AUCUN test pour les surfaces Codex. Ajoute :\n1. `toml_config_home_round_trips_with_tagged_strategy` : sérialise un `McpConfigStrategy::toml_config_home(\".codex/config.toml\",\"CODEX_HOME\").unwrap()`, vérifie le JSON tagué (`\"strategy\":\"tomlConfigHome\"` camelCase, champs `target`/`homeEnv` — vérifie l'orthographe exacte des champs dans la déf. de la variante `TomlConfigHome` ligne ~204 et l'attribut serde du enum), et round-trip identique.\n2. `toml_config_home_rejects_absolute_and_parent_target` : `toml_config_home(\"/abs/x\",\"CODEX_HOME\")` et `toml_config_home(\"../escape\",\"CODEX_HOME\")` ⇒ `Err` (miroir de `config_file_rejects_absolute_target`/`config_file_rejects_parent_traversal` déjà présents).\n3. `toml_config_home_rejects_invalid_home_env` : home_env vide ou avec caractère illégal ⇒ `Err` (miroir de `env_rejects_invalid_name`). Vérifie la règle exacte de validation dans le constructeur `toml_config_home` (ligne ~254).\n4. `materializes_idea_bridge_matrix` : construis des `AgentProfile` via `AgentProfile::new(...).with_structured_adapter(...).with_mcp(McpCapability::new(strategy, McpTransport::Stdio))` (modèle exact : `crates/application/src/agent/catalogue.rs` lignes 60-90) et assert :\n - Claude + `config_file(\".mcp.json\")` ⇒ `true`\n - Codex + `toml_config_home(\".codex/config.toml\",\"CODEX_HOME\")` ⇒ `true` ← LE point clé (Codex était refusé avant)\n - Codex SANS `.with_mcp(...)` ⇒ `false`\n - Codex + `config_file(\".mcp.json\")` ⇒ `false`\n (réf. impl `materializes_idea_bridge` ligne ~723.)\n5. `mcp_server_wiring_encodes_expected_toml` : `McpServerWiring` (déf. ligne ~300, encodeur `to_config_toml` ligne ~368, helper `toml_string` ligne ~406). Construis un wiring avec un chemin contenant un espace et un backslash, appelle `to_config_toml()`, assert : présence de `[mcp_servers.idea]`, args dans l'ordre, et échappement correct du chemin. Regarde la signature réelle du constructeur de `McpServerWiring` avant d'écrire.\n\n## BLOC B — jumeau Codex du round-trip e2e dans `crates/app-tauri/src/state.rs`, module `mod mcp_e2e_loopback_tests` (ligne 2960).\n- Le `build_service` du module (ligne 3320) force un profil **Claude** (`with_structured_adapter(StructuredAdapter::Claude)` ligne 3341 + `.with_mcp(McpCapability::new(McpConfigStrategy::config_file(\".mcp.json\")...))` ligne 3342). \n- Le test Claude existe : `ask_then_reply_round_trips_inline_over_real_loopback` (ligne 3546).\n- AJOUTE une fn `build_service_codex(contexts)` (copie de `build_service` mais profil cible = Codex : `with_structured_adapter(StructuredAdapter::Codex)` + `.with_mcp(McpCapability::new(McpConfigStrategy::toml_config_home(\".codex/config.toml\",\"CODEX_HOME\").unwrap(), McpTransport::Stdio))`). Factorise si tu veux, mais NE casse pas le test Claude.\n- AJOUTE le jumeau `ask_then_reply_round_trips_inline_over_real_loopback_codex` : corps IDENTIQUE à l'original mais via `build_service_codex`. Tout le reste inchangé (handshake `requester`=id cible, `idea_ask_agent`, `idea_reply`, `seed_live_pty` ligne 3400, assertions inline \"the answer is 42\", bornage par `TIMEOUT` ligne 3036). JAMAIS de vrai binaire codex : fakes uniquement.\n- But prouvé = la garde laisse passer Codex ET le round-trip aboutit.\n\n## Lance et rends la SORTIE RÉELLE de :\n- `cargo test -p domain` (les 5 tests du bloc A verts)\n- `cargo test -p app-tauri --lib mcp_e2e_loopback_tests` (le jumeau codex vert + non-régression Claude)\nBoucle jusqu'au vert. Si rouge à cause du code de prod (improbable), dis-le PRÉCISÉMENT, ne réécris pas la prod.\n\n## Rends via idea_reply OBLIGATOIREMENT :\n(a) liste `fichier::nom` des tests ajoutés ; (b) la sortie cargo réelle des 2 commandes (copier-coller) ; (c) confirmation que le jumeau Codex passe ; (d) tout point bloquant. Si tu n'appelles pas idea_reply, je ne reçois rien."} +{"id":"416a403d-56d2-4c36-8bc9-70c3900b5e6e","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781383970199,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"VERT sur les 2 blocs. Aucun code de prod touché (tests uniquement). Cette fois les fichiers SONT dans l'arbre.\n\n## (a) Tests ajoutés (fichier::nom)\n\nBLOC A — `crates/domain/src/profile.rs`, module `mcp_tests` (avant l'accolade fermante l. 1018) :\n- `profile::mcp_tests::toml_config_home_round_trips_with_tagged_strategy`\n- `profile::mcp_tests::toml_config_home_rejects_absolute_and_parent_target`\n- `profile::mcp_tests::toml_config_home_rejects_invalid_home_env`\n- `profile::mcp_tests::materializes_idea_bridge_matrix`\n- `profile::mcp_tests::mcp_server_wiring_encodes_expected_toml`\n\nBLOC B — `crates/app-tauri/src/state.rs`, module `mcp_e2e_loopback_tests` :\n- helper `build_service_codex(contexts)` (jumeau de `build_service`, profil Codex + `StructuredAdapter::Codex` + `McpConfigStrategy::toml_config_home(\".codex/config.toml\",\"CODEX_HOME\")` / `McpTransport::Stdio`). `build_service` (Claude) intact.\n- `state::mcp_e2e_loopback_tests::ask_then_reply_round_trips_inline_over_real_loopback_codex`\n\n## (b) Sortie cargo réelle\n\n### `cargo test -p domain` (BLOC A)\n```\ntest profile::mcp_tests::materializes_idea_bridge_matrix ... ok\ntest profile::mcp_tests::mcp_server_wiring_encodes_expected_toml ... ok\ntest profile::mcp_tests::toml_config_home_rejects_absolute_and_parent_target ... ok\ntest profile::mcp_tests::toml_config_home_rejects_invalid_home_env ... ok\ntest profile::mcp_tests::toml_config_home_round_trips_with_tagged_strategy ... ok\ntest result: ok. 88 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n```\n(suites d'intégration domaine également toutes vertes : 11/12/11/47/72/16/20/16/4 — 0 échec.)\n\n### `cargo test -p app-tauri --lib mcp_e2e_loopback_tests` (BLOC B)\n```\nrunning 6 tests\ntest state::mcp_e2e_loopback_tests::handshake_requester_propagates_over_real_loopback ... ok\ntest state::mcp_e2e_loopback_tests::list_agents_round_trips_over_real_loopback ... ok\ntest state::mcp_e2e_loopback_tests::ask_then_reply_round_trips_inline_over_real_loopback ... ok\ntest state::mcp_e2e_loopback_tests::ask_then_reply_round_trips_inline_over_real_loopback_codex ... ok\ntest state::mcp_e2e_loopback_tests::orphan_reply_is_typed_error_over_real_loopback ... ok\ntest state::mcp_e2e_loopback_tests::malformed_jsonrpc_after_handshake_errors_and_server_survives ... ok\ntest result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 34 filtered out\n```\n\n## (c) Confirmation jumeau Codex\n`ask_then_reply_round_trips_inline_over_real_loopback_codex` PASSE : la garde laisse passer une cible Codex (`StructuredAdapter::Codex` + `TomlConfigHome`) ET le round-trip `idea_ask_agent` → `idea_reply` aboutit inline (\"the answer is 42\"). Non-régression Claude : `ask_then_reply_round_trips_inline_over_real_loopback` toujours vert. Aucun vrai binaire codex lancé (fakes : FakeRuntime/FakePty).\n\n## (d) Points bloquants / écarts\n1. Un seul ajustement de TEST a été nécessaire (pas de prod) : le champ `home_env` de la variante `TomlConfigHome` sérialise en snake_case `\"home_env\"`, PAS `\"homeEnv\"`. Raison : `#[serde(rename_all = \"camelCase\")]` sur l'enum renomme les *variantes* (`tomlConfigHome` ✓), pas les champs d'une variante struct (il faudrait `rename_all_fields`). C'est le comportement de prod existant — j'ai aligné l'assertion du test dessus avec un commentaire explicatif, sans toucher la prod. Tout le reste (variante taguée `\"strategy\":\"tomlConfigHome\"`, `target`, round-trip identique) conforme.\n2. Aucun blocage côté prod : la garde `materializes_idea_bridge` accepte bien `(Codex, TomlConfigHome)` et refuse `(Codex, sans mcp)` / `(Codex, ConfigFile)` — couvert par `materializes_idea_bridge_matrix`."} +{"id":"a40f2a54-fd9f-4235-9d9b-2dff395d8e79","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781424817506,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"LP0 — Agent de test du binôme permissions. DevBackend a livré le module domaine `crates/domain/src/permission.rs` (PUR, zéro I/O). `cargo test -p domain` est vert (17 tests de base déjà écrits par le dev). Ta mission : écrire la BATTERIE EXHAUSTIVE de tables de vérité pour durcir la fonction `resolve` et la sémantique de décision, puis l'exécuter et rapporter le résultat réel.\n\nAPI à tester (ré-exportée depuis le crate `domain`) :\n- `resolve(project: Option<&PermissionSet>, agent: Option<&PermissionSet>) -> Option`\n- `EffectivePermissions::decide_file(cap, path) -> Posture` et `::decide_bash(cmd) -> Posture`\n- Constructeurs validants : `PermissionRule::file(...)`, `PermissionRule::bash(...)`, `Glob::new`, `PathScope::new`, `CommandMatcher::{exact,prefix,glob}`, `PermissionSet::new`.\n- Erreurs typées `PermissionError` : EmptyGlob, InvalidGlob, EmptyCommandMatcher, PathNotRelativeSafe, BashRuleHasPaths, FileRuleHasCommands, NotAFileCapability.\n\nSÉMANTIQUE FIGÉE PAR L'ARCHITECT (à encoder en tests, ne pas dévier) :\n1. resolve(None, None) == None (rien posé ⇒ rien projeté). Toute autre combinaison ⇒ Some.\n2. HÉRITAGE + OVERRIDE : règles projet + règles agent superposées.\n3. DENY-WINS : un Deny qui matche (projet OU agent, niveau règle fichier OU niveau CommandRule) gagne sur tout Allow, à tous niveaux, non surchargeable par un allow plus spécifique.\n4. fallback résolu = le plus restrictif des deux (ordre Allow < Ask < Deny) ; l'agent resserre, jamais ne desserre un deny projet.\n5. RÈGLE BASH À `commands` NON VIDE : le `effect` de niveau règle est INERTE — seules les CommandRule individuelles décident ; les commandes non matchées retombent sur `fallback`. (Interprétation A, confirmée. AJOUTE une ligne de table figeant explicitement « rule-level effect ignoré quand commands non vide » pour verrouiller contre régression.)\n6. RÈGLE BASH À `commands` VIDE = verdict blanket (son effect) pour toute commande.\n\nCOUVERTURE ATTENDUE (tables) :\n- Matrice héritage : {None, vide, allow, deny} projet × {None, vide, allow, deny} agent → posture effective attendue, pour fichier ET bash.\n- Deny-wins : deny projet vs allow agent plus spécifique ; deny agent vs allow projet ; deny au niveau CommandRule vs allow blanket ; etc.\n- fallback tighten : toutes paires (Allow/Ask/Deny) × (Allow/Ask/Deny).\n- Globs : `*`, `**`, `?`, `[...]`, négation `[!...]`, segments, chemins relatifs profonds ; cas limites de matching.\n- CommandMatcher : Exact vs Prefix vs Glob, chevauchements, casse, espaces/arguments.\n- Invariants/erreurs : chaque variante de PermissionError déclenchée par le bon constructeur (bash+paths, fichier+commands, glob vide/invalide, path absolue/.., matcher vide).\n- Cas limites resolve : sets vides des deux côtés, règles contradictoires même scope, multiples règles même capacité.\n\nÉcris ces tests dans le crate `domain` (module de tests ou fichier de tests d'intégration, au choix le plus idiomatique). NE MODIFIE PAS le code de production : si un test révèle un bug, NE le corrige pas — produis un RAPPORT D'ERREURS clair (entrée, attendu, obtenu) que je renverrai à DevBackend.\n\nRéponds via idea_reply avec : nombre de tests ajoutés, la sortie réelle de `cargo test -p domain`, et — si rouge — le rapport d'erreurs détaillé par cas."} +{"id":"88f3d765-5572-4577-9871-4482e760c071","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781427166493,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de communication inter-agent. Peux-tu confirmer que tu reçois bien ce message ? Réponds avec : ton rôle en une phrase, et le résultat de 17 × 3 pour vérifier que le canal aller-retour fonctionne."} +{"id":"5b5e5d18-3626-4a4e-a69c-e90dd10bb273","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781427173503,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"Message bien reçu, le canal aller-retour fonctionne. Mon rôle : je suis l'agent de test (QA) d'IdeA, j'écris et exécute les tests unitaires des features et je relaie fidèlement les résultats jusqu'au vert. Vérification : 17 × 3 = 51."} +{"id":"cd9fb728-78d3-42d0-9e04-8fcf2a97d76e","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781504830744,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[Test inter-agents] Bonjour QA. Ceci est un test du système inter-agents IdeA. Si tu reçois ce message, réponds simplement via idea_reply avec le texte : \"QA OK - reçu via inter-agents\". Merci."} +{"id":"9c6ef2e3-19a3-4551-b942-896121efaf8d","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781509978318,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Petit test de conversation inter-agent. Peux-tu me calculer le résultat de (347 × 89) + 1256 et me le renvoyer via idea_reply ? Merci !"} +{"id":"91cfba94-4a15-4052-afad-a83cd0766f8d","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781511014543,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[Test inter-agent depuis Main] Réponds simplement via idea_reply avec le texte : \"QA OK — pont inter-agent fonctionnel\". C'est un ping de vérification du bridge MCP (toi tu n'étais pas encore lancé), aucune autre action requise."} +{"id":"af5df05b-ba1a-4241-9433-715c0a4e6a3e","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781514748915,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[Test ping inter-agent] Réponds simplement avec le mot \"PONG-2\" et l'heure courante. C'est un test de latence de la conversation inter-agent, pas besoin d'analyser du code."} +{"id":"d2545b97-6f19-4dca-9ae0-c8588c92c932","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781517366912,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) \"QA OK — pont inter-agent fonctionnel\", (2) le résultat de 144 ÷ 12, et (3) l'heure courante. Aucune autre action requise."} +{"id":"070a7a67-d90c-485a-96b3-c5232995b0ee","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781517375515,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"QA OK — pont inter-agent fonctionnel. (2) 144 ÷ 12 = 12. (3) Heure courante : 2026-06-15 11:56:12 CEST."} +{"id":"de5802fb-bbb6-4b03-8894-6a2fb5fb6404","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781522518927,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de conversation inter-agent. Calcule 47 + 58 et réponds-moi simplement avec le résultat via idea_reply."} +{"id":"cbcaeeeb-e454-447c-b51b-e9d6dc884069","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781522526865,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"47 + 58 = 105"} +{"id":"9e01d78a-c3d2-4ef8-b169-79a4e5ec0025","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781523868098,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tests du lot LP3-1 (domaine) — Port `PermissionProjector` + `ProjectorKey` + champ profil. DevBackend vient de l'implémenter ; ça compile (`cargo build -p domain` et `-p application` OK). À toi d'écrire et exécuter les tests unitaires.\n\nCE QUI A ÉTÉ AJOUTÉ :\n- crates/domain/src/permission.rs : `ProjectorKey` (enum fermé Claude|Codex, serde camelCase → \"claude\"/\"codex\"), `ProjectionContext<'a>`, `ProjectedFile` (variants Replace / MergeToml), `PermissionProjection` (+ ctor `empty()`), trait `PermissionProjector`.\n- crates/domain/src/profile.rs : `AgentProfile` a un nouveau champ `pub projector: Option` (`#[serde(default, skip_serializing_if = \"Option::is_none\")]`), builder `with_projector`, `new` initialise à None.\n- crates/application/src/agent/catalogue.rs : builtins claude/codex posent leur projector.\n\nTESTS À COUVRIR (cohérents avec le style des tests existants dans ces fichiers — regarde les modules `#[cfg(test)]` déjà présents) :\n1. ProjectorKey : sérialisation/désérialisation stable → \"claude\"/\"codex\" (round-trip serde_json).\n2. AgentProfile COMPAT : un JSON de profil SANS le champ `projector` désérialise → `projector: None` (c'est l'exigence de compat la plus importante). Et round-trip d'un profil avec projector = Some(...).\n3. AgentProfile : `skip_serializing_if` → quand projector est None, la clé est ABSENTE du JSON sérialisé.\n4. Builder `with_projector` pose bien la valeur.\n5. Builtins : le profil builtin claude a `Some(ProjectorKey::Claude)`, codex a `Some(ProjectorKey::Codex)`, et Gemini/Aider ont `None`.\n6. `PermissionProjection::empty()` rend bien files/args/env vides (c'est la valeur-invariant pour eff==None).\n\nLe trait `PermissionProjector` n'a pas encore d'impl concrète (LP3-2) : tu peux soit ne pas le tester directement, soit écrire un projecteur factice de test si utile pour valider la forme du contrat — à ton appréciation, ne sur-teste pas.\n\nExécute `cargo test -p domain` (et `-p application` si tu touches au catalogue). Réponds via idea_reply avec : la liste des tests ajoutés, leur emplacement, et la SORTIE RÉELLE de cargo test (pass/fail). Si quelque chose échoue, donne un rapport d'erreur clair (je le renverrai à DevBackend)."} +{"id":"98970cdc-ab7f-4150-91b3-25e5bf23ff96","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781523949415,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"✅ LP3-1 (domaine) — TOUS LES TESTS VERTS. 9 tests ajoutés, suite complète `cargo test -p domain` et `-p application` : 0 échec.\n\nTESTS AJOUTÉS (par emplacement)\n\n1) crates/domain/src/permission.rs (module `#[cfg(test)] mod tests`)\n - `projector_key_serialises_to_stable_camel_case` : ProjectorKey::Claude→\"\\\"claude\\\"\", Codex→\"\\\"codex\\\"\" (forme fil stable, point 1).\n - `projector_key_round_trips` : round-trip serde_json des 2 variantes + désérialisation des littéraux \"claude\"/\"codex\" → variante attendue.\n - `permission_projection_empty_is_fully_empty` : `PermissionProjection::empty()` → files/args/env tous vides (invariant eff==None, point 6).\n\n2) crates/domain/src/profile.rs (module `mcp_tests`)\n - `profile_default_has_no_projector` : `AgentProfile::new(...)` → projector None.\n - `profile_without_projector_omits_key_in_json` : skip_serializing_if → clé `projector` ABSENTE quand None (point 3).\n - `legacy_json_without_projector_deserialises_to_none` : JSON de profil SANS champ `projector` → None (COMPAT, point 2).\n - `with_projector_sets_and_round_trips_camel_case` : builder pose Some(Claude), sérialise `\"projector\":\"claude\"`, round-trip identique (points 2 & 4).\n\n3) crates/application/src/agent/catalogue.rs (module `mcp_tests`)\n - `claude_and_codex_seed_their_projector_key` : builtin claude→Some(Claude), codex→Some(Codex) (point 5).\n - `gemini_and_aider_have_no_projector` : Gemini/Aider→None (point 5).\n\nLe trait `PermissionProjector` n'est PAS testé directement (pas d'impl concrète avant LP3-2 ; pas de projecteur factice ajouté pour ne pas sur-tester — la forme du contrat est déjà couverte par PermissionProjection::empty()).\n\nSORTIE RÉELLE (cargo test, ciblage par nom)\n test permission::tests::permission_projection_empty_is_fully_empty ... ok\n test permission::tests::projector_key_round_trips ... ok\n test permission::tests::projector_key_serialises_to_stable_camel_case ... ok\n test profile::mcp_tests::legacy_json_without_projector_deserialises_to_none ... ok\n test profile::mcp_tests::profile_default_has_no_projector ... ok\n test profile::mcp_tests::profile_without_projector_omits_key_in_json ... ok\n test profile::mcp_tests::with_projector_sets_and_round_trips_camel_case ... ok\n test agent::catalogue::mcp_tests::claude_and_codex_seed_their_projector_key ... ok\n test agent::catalogue::mcp_tests::gemini_and_aider_have_no_projector ... ok\n\nSuites globales : `cargo test -p domain` → toutes les cibles `test result: ok. 0 failed` (lib 20+ tests, structured_session_d0 16, window 4, doctests 0). `cargo test -p application` → `0 failed` sur toutes les cibles. Aucune régression. Rien à renvoyer à DevBackend — lot LP3-1 validé."} +{"id":"e0d215c0-322a-41eb-95df-05b35f1b6dc0","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781524139671,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tests du lot LP3-2 (infrastructure) — `ClaudePermissionProjector` et `CodexPermissionProjector`. DevBackend a extrait la logique de traduction (verbatim) derrière le trait `PermissionProjector`. Build `cargo build -p infrastructure` OK. À toi d'écrire/exécuter les tests.\n\nEMPLACEMENT : crates/infrastructure/src/permission/ (mod.rs, claude.rs, codex.rs). Mets les tests en `#[cfg(test)]` dans claude.rs / codex.rs (ou un mod tests dans permission/), cohérent avec le style infra existant. Référence utile : les tests de traduction d'origine étaient dans application/src/agent/lifecycle.rs ~l.2957-3068 — réutilise/relocalise leur intention.\n\nCONTRAT testé (rappel) :\n- `project(None, ctx)` → `PermissionProjection::empty()` (files/args/env vides) pour LES DEUX projecteurs. Cas invariant produit, à couvrir explicitement.\n- Claude `project(Some(eff), ctx)` → 1 fichier `Replace { rel_path: \".claude/settings.local.json\", contents: }`, args/env vides ; `owned_replace_paths() == [\".claude/settings.local.json\"]`.\n- Codex `project(Some(eff), ctx)` → 1 fichier `MergeToml { rel_path: \".codex/config.toml\", managed_keys: [\"sandbox_mode\",\"approval_policy\"], contents: toml partiel }` + args `[\"--sandbox\", , \"--ask-for-approval\", ]` ; `owned_replace_paths() == []`.\n\nCAS À COUVRIR :\nClaude :\n1. Posture Allow / Ask / Deny → `defaultMode` attendu dans le JSON (bypassPermissions / acceptEdits / plan). Vérifie le mapping exact tel qu'implémenté.\n2. deny-wins : un eff avec deny spécifique + allow large → l'entrée deny apparaît dans la liste deny du JSON.\n3. additionalDirectories contient bien `ctx.project_root` (et JSON-escaping correct si le chemin contient un caractère à échapper — teste avec un chemin contenant un backslash/guillemet si pertinent).\n4. Garde-fous `merge_default_deny` (sudo/rm -rf/mkfs/dd/shutdown/reboot) présents dans la liste deny.\n5. Le JSON produit est un JSON VALIDE (parse-le avec serde_json::Value pour ne pas figer le format au caractère près — préfère des assertions sémantiques sur la valeur parsée plutôt que des comparaisons de chaîne brutes quand c'est possible).\n\nCodex :\n6. Mapping posture→sandbox_mode : Deny→read-only, Ask/Allow→workspace-write. Et approval_policy : Allow→never, Ask/Deny→on-request. Couvre les 3 postures.\n7. Les args reflètent les mêmes valeurs que le TOML (cohérence args ↔ contents).\n8. owned_replace_paths vide.\n\nConstruis les `EffectivePermissions` d'entrée via l'API domaine (resolve / PermissionSet / PermissionRule) comme dans les tests permission existants. Privilégie des assertions sémantiques robustes (JSON parsé, contains sur les listes) plutôt que des golden strings fragiles, sauf si un golden est clairement plus lisible.\n\nExécute `cargo test -p infrastructure`. Réponds via idea_reply : tests ajoutés + emplacement + SORTIE RÉELLE cargo test. Tout échec → rapport d'erreur clair pour DevBackend."} +{"id":"4d186454-4147-4710-a68e-c1839ad13ce9","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781524267513,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"✅ LP3-2 (infrastructure) — TOUS LES TESTS VERTS. 11 tests ajoutés, `cargo test -p infrastructure` : 0 échec, aucune régression (147 tests lib + 0 fail sur toutes les cibles d'intégration).\n\nTESTS AJOUTÉS (par emplacement)\n\nA) crates/infrastructure/src/permission/claude.rs (`#[cfg(test)] mod tests`, 8 tests)\n - `project_none_is_empty` : `project(None, ctx)` → files/args/env vides (invariant produit).\n - `owned_replace_paths_is_the_settings_file` : == [\".claude/settings.local.json\"].\n - `default_mode_maps_each_posture` (cas 1) : Allow→\"bypassPermissions\", Ask→\"acceptEdits\", Deny→\"plan\" (assertion sur JSON parsé `permissions.defaultMode`).\n - `specific_deny_with_broad_allow_appears_in_deny_list` (cas 2, deny-wins) : Write Deny `.ideai/**` + Write Allow `**` → la liste `deny` contient `Edit(.ideai/**)` ET `Write(.ideai/**)` (le Write se déploie en Edit+Write), et le `**` reste dans `allow`.\n - `additional_directories_contains_project_root_escaped` (cas 3) : root = `C:\\Users\\a\"b\\proj` (backslash + guillemet) → après parse JSON, `additionalDirectories[0]` == chemin brut verbatim (prouve l'échappement correct).\n - `default_deny_guardrails_are_present` (cas 4) : sudo / rm -rf / / rm -rf ~ / $HOME* / mkfs* / dd if=* / shutdown* / reboot* présents dans `deny`.\n - `produced_settings_has_expected_static_shape` (cas 5) : doc parsé via serde_json → `enabledMcpjsonServers[0]==\"idea\"`, `skipDangerousModePermissionPrompt==true`, `sandbox.enabled==false`.\n - `empty_rules_fall_back_to_broad_default_allow` : eff sans règle → `allow == [Read,Edit,Write,Bash]`.\n NB : toutes les assertions Claude passent par serde_json::from_str (pas de golden string fragile) ; le helper `project_json` vérifie au passage la forme du contrat (1 `Replace { rel_path == \".claude/settings.local.json\" }`, args/env vides) et que le contenu est un JSON valide.\n\nB) crates/infrastructure/src/permission/codex.rs (`#[cfg(test)] mod tests`, 3 tests)\n - `project_none_is_empty` : `project(None, ctx)` → files/args/env vides (invariant produit).\n - `owned_replace_paths_is_empty` : == [] (config.toml co-owned, jamais supprimé).\n - `posture_maps_sandbox_and_approval_in_file_and_args` (cas 6+7+8) : sur les 3 postures\n Deny → sandbox \"read-only\", approval \"on-request\"\n Ask → sandbox \"workspace-write\", approval \"on-request\"\n Allow → sandbox \"workspace-write\", approval \"never\"\n Vérifie pour chacune : 1 `MergeToml { rel_path == \".codex/config.toml\", managed_tables vide, managed_keys == [\"sandbox_mode\",\"approval_policy\"] }`, le `contents` contient bien `sandbox_mode = \"\"` et `approval_policy = \"\"`, ET args == [\"--sandbox\", , \"--ask-for-approval\", ] (cohérence args↔contents), env vide.\n\nLes `EffectivePermissions` d'entrée sont construits via l'API domaine (`resolve(Some(&PermissionSet::new(rules, fallback)), None)` + `PermissionRule::file`), comme les tests `permission` existants.\n\nSORTIE RÉELLE (ciblage par nom, lib)\n test permission::claude::tests::project_none_is_empty ... ok\n test permission::claude::tests::owned_replace_paths_is_the_settings_file ... ok\n test permission::claude::tests::default_mode_maps_each_posture ... ok\n test permission::claude::tests::specific_deny_with_broad_allow_appears_in_deny_list ... ok\n test permission::claude::tests::additional_directories_contains_project_root_escaped ... ok\n test permission::claude::tests::default_deny_guardrails_are_present ... ok\n test permission::claude::tests::produced_settings_has_expected_static_shape ... ok\n test permission::claude::tests::empty_rules_fall_back_to_broad_default_allow ... ok\n test permission::codex::tests::project_none_is_empty ... ok\n test permission::codex::tests::owned_replace_paths_is_empty ... ok\n test permission::codex::tests::posture_maps_sandbox_and_approval_in_file_and_args ... ok\n test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 136 filtered out\n\nSuite complète `cargo test -p infrastructure` : lib 147 passed; 0 failed, et toutes les cibles d'intégration `0 failed`. Rien à renvoyer à DevBackend — lot LP3-2 validé."} +{"id":"bf4f072e-c920-46f3-a0bc-ccac9e94f5b2","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781524901773,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tests du lot LP3-3 (application) — l'étape `apply_permission_projection` + registre + découplage MCP dans LaunchAgent. DevBackend a câblé et a déjà fait passer `cargo test -p application` (en adaptant les anciens tests). À toi d'AJOUTER les tests qui prouvent le nouveau comportement de bout en bout (niveau application, FS mocké).\n\nContexte API exposée :\n- `application::PermissionProjectorRegistry` : `new()`, `with(Arc)`, `insert(..)`, `get(key)`.\n- `LaunchAgent::with_permission_projectors(Arc)` (builder optionnel ; absent ⇒ aucune projection).\n- Projecteurs concrets dans `infrastructure` : `ClaudePermissionProjector`, `CodexPermissionProjector` (utilise-les pour peupler le registre dans les tests, ou des doubles de test si plus simple — à ton appréciation).\n- Sélection : `profile.projector` sinon fallback legacy (CLAUDE.md⇒Claude ; StructuredAdapter::Codex / TomlConfigHome⇒Codex).\n- Étape insérée après apply_injection + apply_mcp_config, avant le split structuré/PTY.\n\nRegarde d'abord les tests d'intégration existants de LaunchAgent (crates/application/tests/agent_lifecycle.rs et le FS mock utilisé, `fs.seed_writes()` etc.) pour réutiliser les fixtures/mocks en place.\n\nCAS À COUVRIR :\n1. Sélection de clé : (a) profil avec `projector=Some(Claude)` → projecteur Claude utilisé ; (b) fallback legacy : profil sans projector mais convention-file CLAUDE.md → Claude ; (c) fallback legacy : profil sans projector mais StructuredAdapter::Codex (ou TomlConfigHome) → Codex ; (d) profil non projetable + pas de fallback → aucune projection.\n2. Clobber `Replace` : profil Claude lancé 2× → `.claude/settings.local.json` est RÉÉCRIT (clobber), pas conservé tel quel. Prouve l'inversion vs l'ancien non-clobbering.\n3. Merge `MergeToml` (Codex) : un `.codex/config.toml` préexistant contenant une table/clé NON gérée (ex. une entrée user ou mcp_servers) → après projection, sandbox_mode/approval_policy sont upsertées ET la clé non gérée est préservée. Idempotence : 2 projections ne dupliquent pas les clés.\n4. Fold args/env : profil Codex → le `spec` final porte les args `--sandbox …/--ask-for-approval …` (vérifie via le mock/spawn capturé), pour le chemin PTY ET structuré si testable.\n5. **Découplage MCP (le cas clé du lot)** : profil Codex SANS configuration MCP → la sandbox est tout de même projetée (args + .codex/config.toml). C'est la preuve que la projection ne dépend plus de apply_mcp_config.\n6. No-op : (a) registre absent (builder non appelé) → aucun fichier de permission écrit (`fs.seed_writes()` / writes vides) ; (b) eff == None (aucune permission posée pour le projet/agent) → projection vide.\n7. eff résolu : permissions posées au niveau projet/agent → bien reflétées dans le fichier projeté (au moins un smoke : posture Deny → mode plan côté Claude).\n\nMets ces tests là où c'est cohérent (tests d'intégration agent_lifecycle.rs ou un nouveau tests/permission_projection.rs). Exécute `cargo test -p application` (et `-p infrastructure` si tu réutilises les projecteurs concrets — vérifie que rien ne casse). Réponds via idea_reply : tests ajoutés + emplacement + SORTIE RÉELLE. Tout échec → rapport clair pour DevBackend (c'est le lot le plus à risque, sois rigoureux sur le découplage MCP et le clobber)."} +{"id":"e14c7194-2b6a-42b0-b789-f830268e430a","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781525271093,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"✅ LP3-3 (application) — TOUS LES TESTS VERTS. 11 tests d'intégration ajoutés, `cargo test -p application` ET `-p infrastructure` : 0 échec, aucune régression.\n\nEMPLACEMENT : crates/application/tests/agent_lifecycle.rs (réutilise les fakes en place ; section « LP3-3 — permission projection wiring » en fin de fichier).\n\nCHOIX ARCHITECTURAL (important) : l'application ne dépend QUE des ports domaine (cf. description du crate). Je n'ai donc PAS ajouté `infrastructure` en dev-dependency. À la place, deux **doubles fidèles** `FakeClaudeProjector` / `FakeCodexProjector` (impl du trait domaine `PermissionProjector`) reproduisant le mapping réel posture→mode (Allow→bypassPermissions, Ask→acceptEdits, Deny→plan ; Deny→read-only/on-request, Ask→workspace-write/on-request, Allow→workspace-write/never). La fidélité de traduction réelle est déjà couverte par LP3-2 ; ces tests prouvent le CÂBLAGE applicatif (sélection de clé, clobber Replace, merge MergeToml, fold args/env, découplage MCP, no-op). Ajouts utilitaires aux fakes existants : `FakeFs.seed_read` + lecture « last-write-wins » (pour merge/idempotence), `FakePermissionStore`, `full_registry()`, `perm_doc(posture)`, `codex_profile()`, helper `launch_with_projection(...)`.\n\nTESTS AJOUTÉS (11)\n1) Sélection de clé :\n - `projection_selects_claude_from_explicit_projector_field` (1a) : projector=Some(Claude) gagne même avec convention GEMINI.md (le champ explicite prime sur l'heuristique) → seed Claude écrit, pas de config Codex.\n - `projection_falls_back_to_claude_from_convention_file` (1b) : pas de projector + CLAUDE.md → Claude.\n - `projection_falls_back_to_codex_from_structured_adapter` (1c) : pas de projector + StructuredAdapter::Codex → Codex (config + args --sandbox).\n - `projection_noop_for_unprojectable_profile` (1d) : GEMINI.md, ni projector ni signal Codex → aucune projection (aucun fichier, aucun arg).\n2) `claude_replace_seed_is_clobbered_on_relaunch` : 2 lancements (session retirée entre les deux pour lever la garde singleton), seed pré-marqué existant → 2 écritures sur le MÊME chemin (clobber prouvé, inversion vs régime non-clobber MCP).\n3) `codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged` : `.codex/config.toml` préexistant avec `user_key`+table `[mcp_servers.idea]` non gérés → après projection, sandbox_mode/approval_policy upsertés ET user_key+table préservés ; 2e projection → 1 seule occurrence de chaque clé gérée (idempotence), user_key toujours là.\n4) `codex_projection_folds_args_into_spawn_spec` : args `--sandbox workspace-write` / `--ask-for-approval on-request` (posture Ask) présents dans l'ordre CLI dans le spec spawné (chemin PTY ; le chemin structuré hérite du même spec car le fold précède le split — pas de factory structurée câblée dans ces fixtures).\n5) `codex_sandbox_projected_without_any_mcp_capability` (CAS CLÉ découplage MCP) : profil Codex SANS McpCapability (`profile.mcp.is_none()` vérifié) → la sandbox est tout de même projetée (`.codex/config.toml` écrit, `sandbox_mode=\"read-only\"` posture Deny) + args foldés. Prouve que la projection ne dépend plus de apply_mcp_config.\n6) No-op :\n - `no_registry_means_no_projection` (6a) : registre absent (builder non appelé) → aucun fichier de permission, même avec policy posée.\n - `no_policy_posed_means_empty_projection` (6b) : `ProjectPermissions::default()` (project_defaults=None) ⇒ resolve_for=None ⇒ projection vide même registre câblé.\n7) `resolved_deny_posture_reflected_as_plan_mode` : policy projet Deny → resolve_for → projecteur → JSON parsé `permissions.defaultMode==\"plan\"` ET `additionalDirectories[0]==\"/home/me/proj\"` (le project_root a bien transité par le ProjectionContext).\n\nSORTIE RÉELLE\n cargo test -p application --test agent_lifecycle :\n test result: ok. 59 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n (dont les 11 ci-dessus : projection_selects_claude_from_explicit_projector_field, projection_falls_back_to_claude_from_convention_file, projection_falls_back_to_codex_from_structured_adapter, projection_noop_for_unprojectable_profile, claude_replace_seed_is_clobbered_on_relaunch, codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged, codex_projection_folds_args_into_spawn_spec, codex_sandbox_projected_without_any_mcp_capability, no_registry_means_no_projection, no_policy_posed_means_empty_projection, resolved_deny_posture_reflected_as_plan_mode — tous ... ok)\n cargo test -p application (toutes cibles) : chaque `test result: ok. … 0 failed`.\n cargo test -p infrastructure (réutilisé indirectement, vérif non-régression) : toutes cibles `0 failed` (lib 147, etc.).\n\nRien à renvoyer à DevBackend — lot LP3-3 validé. Note : le découplage MCP (cas 5) et le clobber (cas 2) sont prouvés explicitement comme demandé."} +{"id":"2766fdc3-cbf8-4fc8-960e-9f0bb1c975a7","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781525555441,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tests du lot LP3-4 (application) — nettoyage des fichiers de permission orphelins au swap cross-profile dans `ChangeAgentProfile`. DevBackend a câblé l'étape 5b (entre invalidate_engine_link et relaunch). `cargo test -p application` déjà vert ; à toi d'ajouter les tests de swap qui prouvent le nettoyage.\n\nEMPLACEMENT : crates/application/tests/ (le fichier des tests ChangeAgentProfile existants ; réutilise les fixtures). Regarde d'abord les tests de swap existants pour réutiliser les fakes.\n\nPOINT IMPORTANT (signalé par DevBackend) : `FileSystem::remove_file` a une impl PAR DÉFAUT no-op (Ok sans rien supprimer). Pour ASSERTER une suppression, ton fake FileSystem doit OVERRIDE `remove_file` (enregistrer les chemins supprimés, et réellement retirer de son état interne). Ajoute ça au fake utilisé par les tests de swap.\n\nCâblage à reproduire dans les tests : injecter le MÊME registre via `ChangeAgentProfile::with_permission_projectors(...)` (et `LaunchAgent::with_permission_projectors(...)` pour la relance). Tu peux réutiliser les doubles fidèles `FakeClaudeProjector`/`FakeCodexProjector` créés en LP3-3 (Claude → owned_replace_paths=[\".claude/settings.local.json\"] ; Codex → []).\n\nSCÉNARIOS À COUVRIR :\n1. **Claude→Codex** : avant swap, `.claude/settings.local.json` existe dans le run dir de l'agent. Après swap → ce fichier est SUPPRIMÉ (présent dans les remove enregistrés par le fake / absent de l'état FS), ET la config Codex (`.codex/config.toml` + args) est projetée par la relance. C'est le scénario phare.\n2. **Claude→Claude** (changement de profil même famille) : `owned(ancien) − owned(nouveau)` = vide → `.claude/settings.local.json` N'EST PAS supprimé (il est re-clobbé par la relance, pas retiré).\n3. **Codex→Claude** : Codex n'a pas de Replace → rien supprimé côté nettoyage ; la relance écrit `.claude/settings.local.json`. (Le `.codex/config.toml` reste, jamais supprimé — vérifie qu'il n'est pas dans les remove.)\n4. **No-op** : (a) registre absent (builder non appelé) → aucune suppression ; (b) profil précédent introuvable (supprimé entre-temps) → nettoyage sauté, pas d'échec.\n5. **Best-effort** : suppression d'un fichier déjà absent → le swap réussit quand même (pas d'erreur propagée).\n6. **NON-RÉGRESSION P8d** : sur un swap, vérifie que l'id de paire (conversation_id stable) / le handoff restent comme avant — réutilise/garde vert le(s) test(s) de swap existant(s) qui couvrent ça (ne les casse pas ; ajoute une assertion si pertinent).\n\nExécute `cargo test -p application`. Réponds via idea_reply : tests ajoutés + emplacement + modifs au fake FileSystem + SORTIE RÉELLE. Tout échec → rapport clair pour DevBackend. Sois rigoureux sur le scénario 1 (Claude→Codex) et le 2 (Claude→Claude ne supprime pas)."} +{"id":"bce609e6-f2cc-4d18-b027-33982dcb26bd","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781525854381,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"✅ LP3-4 (application) — TOUS LES TESTS VERTS. 7 tests de swap ajoutés, `cargo test -p application` : 0 échec sur les 28 cibles, aucune régression (dont les tests P8d existants restés verts).\n\nEMPLACEMENT : crates/application/tests/change_agent_profile.rs (réutilise les fixtures swap en place ; nouvelle section « LP3-4 — cleanup of orphan permission files at a cross-profile swap »).\n\nMODIFS AU FAKE FILESYSTEM (comme signalé par DevBackend)\n- `FakeFsInner` : ajout d'un champ `removed: Vec`.\n- Override de `remove_file` (l'impl par défaut du port est no-op) : enregistre le chemin dans `removed` ET retire réellement l'entrée de `files` → une suppression est assertable, et l'idempotence (remove d'un absent) reste Ok.\n- Accesseurs ajoutés : `removed()` (liste ordonnée des chemins supprimés), `has_file(path)` (présence dans l'état). + `FakePty::last_spawn_args()` pour asserter les args foldés par la relance.\n\nCÂBLAGE TEST (fidèle au prod) : nouvelle fixture `fixture_with_projection(agent, profiles, registry, perm_doc)` qui injecte le MÊME `full_registry()` via `ChangeAgentProfile::with_permission_projectors(...)` ET `LaunchAgent::with_permission_projectors(...)`, plus `LaunchAgent::with_permission_store(Allow)` pour que la projection de la relance soit non-vide. Doubles fidèles `FakeClaudeProjector` (owned_replace_paths=[\".claude/settings.local.json\"]) / `FakeCodexProjector` (owned=[], MergeToml `.codex/config.toml` + args --sandbox/--ask-for-approval) — application gardée sans dépendance à infrastructure.\n\nTESTS AJOUTÉS (7)\n1) `swap_claude_to_codex_removes_claude_seed_and_projects_codex` (PHARE) : `.claude/settings.local.json` pré-existant dans le run dir stable → après swap : présent dans `removed()` ET absent de l'état FS ; la relance projette `.codex/config.toml` (sandbox_mode=workspace-write) + args `--sandbox` dans le spawn.\n2) `swap_claude_to_claude_does_not_remove_seed` : owned(old)−owned(new)=∅ → le seed n'est PAS supprimé (absent de `removed()`) et reste présent (re-clobbé par la relance, jamais retiré).\n3) `swap_codex_to_claude_removes_nothing_and_keeps_codex_config` : Codex n'a pas de Replace → `removed()` vide ; `.codex/config.toml` pré-existant toujours présent et JAMAIS dans `removed()` ; la relance écrit le seed Claude.\n4a) `swap_without_registry_removes_nothing` : fixture par défaut SANS registre sur le swap → aucune suppression même sur Claude→Codex avec seed présent.\n4b) `swap_with_unknown_previous_profile_skips_cleanup` : l'agent porte pid(1) mais le store ne connaît que pid(2)/pid(3) (ancien profil supprimé) → cleanup sauté, `removed()` vide, swap réussit sans erreur.\n5) `swap_claude_to_codex_succeeds_when_seed_absent` : seed NON semé → la suppression est tout de même tentée (best-effort idempotent : présente dans `removed()`) et le swap réussit.\n6) `swap_with_cleanup_preserves_pair_id_and_handoff` (NON-RÉGRESSION P8d) : swap Claude→Codex live avec cleanup qui fire ET handoff semé sous l'id de paire du leaf → après swap : seed supprimé, l'id de paire (conversation_id) est PRÉSERVÉ sur le leaf persisté, agent_was_running remis à false, et le handoff est ré-injecté dans le convention file de la relance (preuve que le cleanup ne perturbe pas l'id de paire/handoff). Les tests P8d préexistants (`live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume`, `swap_preserves_uuid_pair_id_and_clears_engine_cache`, etc.) restent verts, inchangés.\n\nPOINT D'AJUSTEMENT (pas un bug) : le `FakeRuntime` partagé de ce fichier matérialise toujours le convention file en `CLAUDE.md` quel que soit le profil ; le test 6 lit donc `CLAUDE.md` (le contenu/handoff est ce qui importe, pas le nom de fichier figé par le fake).\n\nSORTIE RÉELLE\n cargo test -p application --test change_agent_profile :\n test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n (dont : swap_claude_to_codex_removes_claude_seed_and_projects_codex, swap_claude_to_claude_does_not_remove_seed, swap_codex_to_claude_removes_nothing_and_keeps_codex_config, swap_without_registry_removes_nothing, swap_with_unknown_previous_profile_skips_cleanup, swap_claude_to_codex_succeeds_when_seed_absent, swap_with_cleanup_preserves_pair_id_and_handoff — tous ok)\n cargo test -p application (toutes cibles) : 28× `test result: ok. … 0 failed`, aucune régression. Aucun warning sur les deux fichiers de test touchés.\n\nRien à renvoyer à DevBackend — lot LP3-4 validé. Scénarios 1 (Claude→Codex supprime + projette) et 2 (Claude→Claude ne supprime pas) prouvés explicitement."} +{"id":"a70a79b3-6cd6-4ab6-b82b-8862315cae58","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781527729569,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Ping de diagnostic. Calcule 9 + 6 et réponds-moi uniquement avec le résultat via idea_reply."} +{"id":"642265bf-d96a-4bfd-b95f-6fa6a90bf2fc","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781544392279,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong QA OK » pour confirmer que le canal fonctionne."} +{"id":"0af6b008-261e-4aa9-be63-55fe13441a7a","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781544399551,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"pong QA OK"} +{"id":"ff52e572-ba26-4e62-856f-efc2204a88a1","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781549274888,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[LP4-0 — suite de tests unitaires domaine] DevBackend a livré le sous-lot LP4-0 (domaine pur) sur le chantier permissions/enforcement OS. Écris et exécute une suite de tests unitaires exhaustive, puis relaie le résultat réel (vert/rouge avec sortie).\n\nCible : `crates/domain/src/sandbox.rs` (fn pure `compile_sandbox_plan`) et `crates/domain/src/permission.rs` (fn pure `render_permission_summary`). Tout est pur, zéro I/O, pas de fake nécessaire.\n\nInvariants à couvrir impérativement :\n1. `compile_sandbox_plan(None, ctx) == None` (rien posé ⇒ natif). Et `Some(eff)` avec une politique posée mais `allowed` vide ⇒ `Some` (pas `None`) : présence de politique ≠ grants non vides.\n2. Mapping capabilities : Read→RO, Write→RW, Delete→RW. `ExecuteBash` n'est JAMAIS traduit en grant (borne Landlock = fichiers only). Vérifie qu'une politique purement Bash ne produit aucun PathGrant.\n3. Fail-closed (le cœur du lot) : un glob Allow est réduit à son préfixe statique (chemin littéral avant le 1er métacaractère `*`/`?`/`[`). Le grant est ABANDONNÉ si un Deny a un préfixe statique qui chevauche (égal, ancêtre OU descendant). Cas clés à tester : Allow `**` + un seul Deny fichier ⇒ aucun grant racine ; Allow `src/**` + Deny `src/secret/**` ⇒ grant `src` abandonné (descendant) ; Allow `src/**` + Deny `other/**` ⇒ grant `src` conservé (disjoint). Teste aussi l'union d'accès (RO+RW) sur une même racine.\n4. `default_posture` du plan reflète bien la posture résiduelle résolue.\n5. `render_permission_summary(None) == None` ; et pour `Some`, le bloc Markdown DOIT mentionner explicitement que les fichiers sont OS-enforced/Landlock when supported ET que les commandes (ExecuteBash) restent advisory / NOT OS-locked (assertions sur le contenu).\n\nPlace les tests là où c'est cohérent avec le style existant du crate domain (tests inline `#[cfg(test)] mod tests` dans sandbox.rs, et dans permission.rs pour le résumé). Exécute `cargo test -p domain` et confirme le workspace reste vert. Réponds via idea_reply avec : nombre de tests ajoutés, sortie de `cargo test -p domain` (résumé pass/fail), et tout écart constaté entre le comportement réel et les invariants ci-dessus (si un invariant n'est pas respecté, rapport d'erreur précis pour DevBackend)."} +{"id":"61144784-9952-4ce3-9860-424631d1eb7b","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781549421741,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"LP4-0 — Suite de tests unitaires domaine : VERT. Aucun écart constaté, tous les invariants sont respectés.\n\n## Tests ajoutés : 17\n- **sandbox.rs** (14 tests, nouveau `#[cfg(test)] mod tests`) :\n - `none_eff_yields_no_plan` (inv.1 : None ⇒ None)\n - `policy_with_no_allow_still_yields_some_plan` (inv.1 : politique posée + allowed vide ⇒ Some, testé fallback-seul ET deny-seul)\n - `read_maps_to_ro`, `write_and_delete_map_to_rw` (inv.2 : Read→RO, Write/Delete→RW)\n - `bash_only_policy_produces_no_path_grant` (inv.2 : ExecuteBash jamais traduit, 0 PathGrant)\n - `root_glob_with_single_file_deny_drops_root_grant` (inv.3 : Allow `**` + 1 Deny fichier ⇒ aucun grant racine)\n - `descendant_deny_drops_overlapping_allow` (inv.3 : Allow `src/**` + Deny `src/secret/**` ⇒ `src` abandonné)\n - `disjoint_deny_keeps_allow` (inv.3 : Allow `src/**` + Deny `other/**` ⇒ `src` conservé)\n - `ancestor_deny_also_drops_allow` (inv.3 : symétrie, deny ancêtre abandonne aussi)\n - `sibling_prefix_is_not_a_descendant` (inv.3 : `src2` ≠ descendant de `src`, conscience des bornes de composant)\n - `accesses_union_on_a_shared_root` (inv.3 : RO+RW fusionnés sur 1 grant)\n - `static_prefix_of_literal_file_is_the_file_itself` (préfixe statique d'un littéral)\n - `default_posture_mirrors_resolved_fallback` (inv.4 : Allow/Ask/Deny)\n - `trailing_slash_on_project_root_is_normalised` (pas de `//`)\n- **permission.rs** (3 tests, ajoutés au mod tests existant) :\n - `summary_is_none_when_nothing_posed` (inv.5 : None ⇒ None)\n - `summary_states_files_os_enforced_and_commands_advisory` (inv.5 : assertions contenu — \"OS-enforced\", \"Landlock\", \"advisory\", \"NOT OS-locked\", \"ExecuteBash\", scope + matcher + posture affichés)\n - `summary_handles_empty_rule_lists_per_section` (sections vides honnêtes + boundary)\n\n## `cargo test -p domain` (résumé)\n- lib : **139 passed; 0 failed** (dont les 17 ci-dessus, vérifiés par nom)\n- tests d'intégration + doc-tests : tous OK, 0 failed\n- Tous les binaires : `0 failed`\n\n## Workspace\n`cargo test --workspace` : tout vert, **0 failed**, aucune erreur de compilation, aucun warning. Non-régression confirmée.\n\nNote pour DevBackend (observation, pas un bug) : dans `compile_sandbox_plan`, les Deny servant de barrières (`deny_roots`) sont collectés indépendamment de la capability — un Deny `Write` clôture donc aussi un Allow `Read` sur la même racine. C'est conforme à l'esprit fail-closed (sous-approximation conservatrice) et bien couvert par les tests ; je le signale uniquement au cas où la sémantique voulue serait par-capability. Si c'est intentionnel (probable vu la doc), rien à faire."} +{"id":"188f960e-bba5-42fb-92dd-3d7db455309f","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781552108233,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[LP4-0 — réécriture des tests après affinage sémantique « barrières Deny par classe d'accès »] Décision d'orchestration appliquée par DevBackend : dans `compile_sandbox_plan` (`crates/domain/src/sandbox.rs`), les barrières Deny sont désormais PAR CLASSE D'ACCÈS — RO (alimentée par Allow Read, fermée seulement par Deny Read) et RW (alimentée par Allow Write+Delete, fermée seulement par Deny Write+Delete). Un Deny d'une autre classe n'a aucun effet. ExecuteBash : toujours ignoré.\n\nDevBackend a marqué 3 de tes tests `#[ignore = \"OBSOLETE … 2db80d41 … QA to rewrite\"]` car ils encodaient l'ancienne sémantique capability-blind (un Deny Write faisait tomber un Allow Read). À réécrire pour qu'ils soient probants sous la nouvelle sémantique, puis retirer le `#[ignore]` :\n\n1. `root_glob_with_single_file_deny_drops_root_grant` → utilise une barrière de MÊME classe : `Allow Read **` + `Deny Read ` ⇒ le grant racine RO doit être abandonné (fail-closed intra-classe). Garde l'esprit « un deny intra-classe fait tomber la racine ».\n2. `descendant_deny_drops_overlapping_allow` → `Allow Read src/**` + `Deny Read src/secret/**` ⇒ grant `src` (RO) abandonné. ET ajoute l'assertion DUALE qui protège la décision produit : `Allow Read src/**` + `Deny Write src/secret/**` (classe différente, aucun Deny Read) ⇒ grant `src` CONSERVE le RO. C'est le cas clé d'autonomie à verrouiller.\n3. `ancestor_deny_also_drops_allow` → `Allow Read src/sub/**` + `Deny Read src/**` ⇒ grant abandonné (barrière ancêtre même classe).\n\nRenforce aussi les 2 tests « affaiblis » signalés (`disjoint_deny_keeps_allow`, `sibling_prefix_is_not_a_descendant`) en utilisant une barrière de MÊME classe que l'allow, sinon ils prouvent la disjointness pour une mauvaise raison.\n\nAjoute idéalement un test croisé explicite : sur une même racine, `Allow Read` + `Allow Write` + `Deny Write` même sous-arbre ⇒ le grant survit en RO seul (RW tombé, RO conservé) — la preuve directe de la granularité par classe.\n\nExécute `cargo test -p domain` puis `cargo test --workspace`. Confirme 0 ignored restant sur sandbox.rs et 0 failed. Réponds via idea_reply avec : tests réécrits/ajoutés, sortie pass/fail, et confirmation qu'aucun `#[ignore]` LP4-0 ne subsiste."} +{"id":"2a384ba3-637c-45cf-8255-8998e8aaa023","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781552220964,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"LP4-0 — Réécriture sous sémantique « barrières Deny par classe d'accès » : VERT. Aucun `#[ignore]` LP4-0 ne subsiste.\n\n## Tests réécrits (3 ex-ignorés, désormais probants en barrière MÊME classe)\n1. `root_glob_with_single_file_deny_drops_root_grant` → renommé `root_glob_with_same_class_deny_drops_root_grant` : `Allow Read **` + `Deny Read secret.txt` ⇒ grant racine RO abandonné (fail-closed intra-classe RO).\n2. `descendant_deny_drops_overlapping_allow` → renommé `descendant_same_class_deny_drops_overlapping_allow` : `Allow Read src/**` + `Deny Read src/secret/**` ⇒ grant `src` (RO) abandonné.\n3. `ancestor_deny_also_drops_allow` → renommé `ancestor_same_class_deny_also_drops_allow` : `Allow Read src/sub/**` + `Deny Read src/**` ⇒ grant abandonné (barrière ancêtre même classe).\n\n## Tests renforcés (barrière de MÊME classe que l'allow, sinon disjointness prouvée pour une mauvaise raison)\n- `disjoint_deny_keeps_allow` → `disjoint_same_class_deny_keeps_allow` : `Allow Read src/**` + `Deny Read other/**` ⇒ `src` conservé en RO.\n- `sibling_prefix_is_not_a_descendant` : passe la barrière en `Deny Read src/**` (vs Allow Read `src2/**`) ⇒ `src2` conservé par conscience des bornes de composant, pas par mismatch de classe.\n\n## Tests ajoutés (verrous de la décision produit / granularité par classe)\n- `other_class_deny_does_not_amputate_read_allow` (assertion DUALE clé d'autonomie) : `Allow Read src/**` + `Deny Write src/secret/**` (classe différente) ⇒ `src` CONSERVE le RO.\n- `same_root_drops_rw_but_keeps_ro_under_a_write_deny` (preuve directe de la granularité par classe) : `Allow Read src/**` + `Allow Write src/**` + `Deny Write src/secret/**` ⇒ grant `src` survit en RO seul (RW tombé, RO conservé).\n\nExecuteBash : toujours couvert par `bash_only_policy_produces_no_path_grant` (0 PathGrant). Invariants 1/2/4 et bords (sibling, préfixe statique, trailing slash, union RO|RW) inchangés et verts.\n\n## Sortie pass/fail\n- `cargo test -p domain` (lib) : **141 passed; 0 failed; 0 ignored**. Bloc sandbox : **16 tests sandbox::tests, tous ok** (liste vérifiée par nom). Intégration + doc-tests : 0 failed.\n- `cargo test --workspace` : tout vert — 80 lignes « test result », **toutes 0 failed**, aucune erreur de compilation.\n- `grep -rn \"#[ignore]\"` sur `crates/domain/src/` : **aucun attribut `#[ignore]`** (seules des occurrences du mot « ignore » en commentaires subsistent). **0 ignored restant sur sandbox.rs confirmé.**"} +{"id":"76fbac14-01be-4011-997b-526d9934abd4","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781553361886,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[LP4-1 — passe de tests d'intégration de l'enforcement Landlock] DevBackend a livré LP4-1 : `crates/infrastructure/src/sandbox/{mod,landlock,noop}.rs`. `LandlockSandbox` (Linux) applique un `SandboxPlan` en restreignant le thread courant (mécanisme : thread jetable + héritage du domaine Landlock au fork/exec, car portable-pty n'expose pas de pre_exec). Son test `landlock_write_only_plan_fences_writes_to_the_grant` passe déjà réellement sur ce kernel.\n\nRenforce/complète la couverture d'intégration (gated `#[cfg(target_os=\"linux\")]` + garde runtime : skip propre si Landlock indisponible, comme les tests SSH/WSL). Couvre impérativement :\n\n1. **PROPRIÉTÉ DE SÛRETÉ CRITIQUE — IdeA n'est jamais sandboxé.** Après un `enforce()` exécuté sur un thread jetable (comme le fait l'adapter), le THREAD principal/process de test (qui simule IdeA) doit toujours pouvoir écrire/lire HORS des racines du plan. Prouve que la restriction est confinée au thread jetable et n'a pas fui sur le process IdeA. C'est le garde-fou n°1 (un Landlock posé sur IdeA serait catastrophique).\n2. **Dimension READ (RO).** Un plan avec un grant RO sur une racine donnée : depuis l'enfant restreint, lire un fichier SOUS la racine accordée ⇒ OK ; lire un fichier HORS racine ⇒ refusé (EACCES). Complète la dimension write déjà testée. (Note remontée par DevBackend, à confirmer par le test : dès qu'un grant RO est posé, TOUTES les lectures hors racines deviennent fermées — donc un plan read-restreint réaliste devra inclure les chemins système ; c'est un concern LP4-2, mais documente le comportement observé par un test.)\n3. **Plan vide / bash-only** ⇒ `Enforced`, aucune restriction effective, pas d'erreur.\n4. **NoopSandbox** ⇒ `kind()==Unsupported`, `enforce` (y compris posture Deny) ⇒ `Ok(Unsupported)`, jamais Err.\n5. **Fail-closed posture Deny** : si tu peux simuler/forcer un cas où Landlock ne s'applique pas (sinon documente pourquoi non testable sur ce kernel), vérifie que posture `Deny` ⇒ `Err(KernelTooOld)` alors que posture non-Deny ⇒ `Ok(Unsupported)`.\n\nExécute `cargo test -p infrastructure` puis `cargo test --workspace`. Réponds via idea_reply avec : tests ajoutés, lesquels ont réellement tourné vs skippés sur ce kernel, sortie pass/fail, et tout écart de comportement (notamment si la propriété de sûreté n°1 n'est PAS vérifiée — rapport d'erreur immédiat pour DevBackend, c'est bloquant)."} +{"id":"b2f4fb3e-1140-42c8-9198-f51f8e7b7fab","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781553541965,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"LP4-1 — Passe de tests d'intégration de l'enforcement Landlock : VERT. La propriété de sûreté n°1 (IdeA jamais sandboxé) est VÉRIFIÉE. Aucun écart.\n\n## Tests ajoutés (4)\n- `sandbox::landlock::tests::enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed` — **PROPRIÉTÉ DE SÛRETÉ n°1**. enforce() lancé sur un thread jetable (comme l'adapter), grant RW sur `granted`. On prouve d'abord que le thread jetable EST sandboxé (write hors racine = PermissionDenied), PUIS que le thread parent (≈ process IdeA) écrit ET lit toujours HORS racines ⇒ le domaine Landlock ne fuit pas sur IdeA.\n- `sandbox::landlock::tests::read_only_plan_fences_reads_to_the_grant` — **dimension READ (RO)**. Fichiers pré-créés avant enforce. Grant RO sur `granted` : lecture SOUS la racine = OK ; lecture HORS = PermissionDenied (EACCES). Confirme et documente le comportement signalé par DevBackend : dès qu'un grant RO est posé, la classe read est *handled* ⇒ TOUTES les lectures hors racines sont fermées (un plan read-restreint réaliste devra inclure les chemins système — concern LP4-2).\n- `sandbox::landlock::tests::empty_plan_under_deny_posture_is_enforced_without_restriction` — **plan vide / bash-only sous posture Deny**. Un plan `allowed` vide (= bash-only après compile LP4-0) avec `Posture::Deny` ⇒ `Enforced` sans aucune restriction et SANS `KernelTooOld` (la branche fail-closed n'est atteinte que si une classe a réellement été *handled*).\n- `sandbox::tests::noop_enforcer_is_unsupported_and_never_errors` (renforcé) — **NoopSandbox** : `kind()==Unsupported` ; `enforce` ⇒ `Ok(Unsupported)` jamais Err, désormais bouclé sur les 3 postures (Allow/Ask/Deny) ET avec un grant présent.\n\n(Existants conservés : `landlock_write_only_plan_fences_writes_to_the_grant`, `empty_plan_is_a_noop_enforced`, `default_enforcer_matches_the_build_target`.)\n\n## Réellement tourné vs skippé sur ce kernel\n- **Tout a réellement tourné — 0 skip.** Aucune ligne « skipping: Landlock not available » émise (vérifié avec `--nocapture | grep -i skip` ⇒ « no tests skipped »). Les gardes runtime de skip sont en place (comme SSH/WSL) mais inutiles ici : ce kernel supporte Landlock, l'enforcement est réel (FullyEnforced) — write hors-grant et read hors-grant renvoient bien PermissionDenied.\n\n## Invariant 5 (fail-closed posture Deny) — NON testable sur ce kernel, documenté\n- La branche `Err(KernelTooOld)` n'est atteinte que si `restrict_self()` renvoie `RulesetStatus::NotEnforced` ET posture==Deny. Sur un kernel Landlock-capable, `restrict_self` renvoie toujours FullyEnforced/PartiallyEnforced, jamais NotEnforced. Impossible de forcer NotEnforced sans injecter/mocker le crate `landlock` (l'adapter ne prend pas de dépendance injectable pour le statut). Le pendant « pas d'OS sandbox + non-Deny ⇒ Ok(Unsupported) » est, lui, couvert par NoopSandbox. Recommandation (non bloquante) pour DevBackend si on veut tester cette branche : extraire le mapping `(RulesetStatus, Posture) -> Result` en fn pure et la tester en table.\n\n## Sortie pass/fail\n- `cargo test -p infrastructure` (lib) : **154 passed; 0 failed; 0 ignored**. Bloc sandbox : 8 tests, tous ok (write-only, read-only, safety-confinement, empty, empty+Deny, noop×3-postures, default_enforcer).\n- `cargo test -p infrastructure` (tous binaires + doc-tests) : **0 failed**.\n- `cargo test --workspace` : tout vert, **0 failed**, aucune erreur de compilation.\n\nAucune violation de la propriété de sûreté n°1. Rien de bloquant à remonter."} +{"id":"0d3462d4-5675-422c-9d4b-a09fa0381aa5","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781589928903,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test bout-en-bout de l'enforcement OS (lot LP4-3, sandbox Landlock), à ajouter et faire passer. Contexte : la chaîne policy → plan → PTY est désormais câblée. DevBackend vient de livrer (non committé) :\n- composition root : `PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer())`\n- launch path (`crates/application/src/agent/lifecycle.rs`, étape 5d) : `spec.sandbox = compile_sandbox_plan(effective_permissions.as_ref(), &SandboxContext{ project_root, run_dir })`.\n- l'enforcement effectif vit dans `PortablePtyAdapter::spawn` (chemin PTY brut), via `spawn_command_sandboxed` (thread restreint par Landlock puis fork).\n\nObjectif : UN test d'intégration de haute fidélité qui prouve que l'enforcement est réellement actif bout-en-bout, SANS lancer de vrai CLI IA (zéro token).\n\nPiste recommandée (la plus fidèle, déjà éprouvée par les tests Landlock existants dans `crates/infrastructure/src/sandbox/landlock.rs`) : passer par `PortablePtyAdapter` réel avec un enforcer Landlock, un `SpawnSpec` dont `sandbox = Some(plan)` où le plan autorise un répertoire et un `Deny` effectif sur un autre, et une commande shell inoffensive (`sh -c 'echo x > /chemin/interdit'`) ; asserter que l'écriture échoue (fichier absent / exit non nul) alors qu'une écriture dans le chemin autorisé réussit. Skippe proprement (`#[cfg(target_os=\"linux\")]` + détection de l'ABI Landlock comme le font les tests existants) si le kernel ne supporte pas Landlock, pour ne pas rendre la CI rouge sur un runner sans Landlock.\n\nRéutilise au maximum les helpers/patterns des tests Landlock existants (regarde `sandbox/landlock.rs` tests : read_only_plan_fences_reads_to_the_grant, landlock_write_only_plan_fences_writes_to_the_grant, enforcement_is_confined_to_the_enforcing_thread). Le test doit cibler le chemin PTY (pas structuré).\n\nÀ la fin : exécute le test et donne la sortie réelle. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : le fichier de test ajouté, ce qu'il prouve, la stratégie de skip, et la sortie brute d'exécution. Si ça échoue, rapport d'erreur clair avec la sortie — on renverra à DevBackend."} +{"id":"0056caaf-4a8d-4d54-9efd-1f7292e2723a","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781590102120,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"✅ VERT — test bout-en-bout de l'enforcement OS (lot LP4-3) ajouté et passant, enforcement Landlock réellement actif sur le chemin PTY (pas skippé sur ce kernel).\n\n## Fichier modifié\n`crates/infrastructure/src/pty/mod.rs` — nouveau module de test `#[cfg(all(test, target_os = \"linux\"))] mod sandbox_e2e_tests` (en plus du module `tests` existant, intact). 2 tests + helpers.\n\n## Ce que ça prouve\nTest principal **`pty_spawn_enforces_sandbox_plan_end_to_end`** — exerce la chaîne réelle complète, ZÉRO CLI IA / ZÉRO token :\n`PortablePtyAdapter::new().with_sandbox_enforcer(default_enforcer())` (= LandlockSandbox) → `SpawnSpec { sandbox: Some(plan) }` avec `plan` = `PathGrant{ allowed RW }` seul, posture `Ask` → `adapter.spawn(...)` → branche `(Some(plan), Some(enforcer))` → `spawn_command_sandboxed` (thread restreint par Landlock puis fork/exec).\n- Commande inoffensive : `sh -c \"echo outside > /out.txt; echo inside > /in.txt\"`.\n- **Ordre volontaire** : l'écriture HORS-grant d'abord, l'écriture DANS le grant ensuite. L'apparition du marqueur `allowed/in.txt` est donc un signal *happens-after* prouvant que la tentative hors-grant a déjà eu lieu → assertion déterministe, pas de course avec l'enfant.\n- Asserts : (1) `allowed/in.txt` finit par exister (écriture autorisée passe ; reads non gouvernés car seul RW posé → `sh`/libc chargent normalement) ; (2) `denied/out.txt` **n'existe PAS** → l'écriture hors-grant a été bloquée par le kernel = « SANDBOX BREACH » sinon.\n\nTest compagnon **`pty_spawn_without_plan_does_not_sandbox`** — même adapter+enforcer mais `sandbox: None` → l'écriture dans le même dossier réussit. Garde-fou anti faux-positif : prouve que le blocage du 1er test vient bien du *plan enforcé*, pas d'une restriction PTY ambiante ou d'un échec d'écriture sans rapport.\n\n## Stratégie de skip (CI/kernel sans Landlock)\n- Tout le module est `#[cfg(target_os = \"linux\")]` (pas de compilation hors Linux).\n- Helper `landlock_is_enforced()` qui sonde l'ABI exactement comme les tests adapter existants : `LandlockSandbox::enforce(plan RW)` sur un **thread jetable** (restriction irréversible → jamais sur le thread de test), skip si `SandboxStatus::Unsupported`. Le test principal `return` proprement avec message `eprintln!` si non enforcé → CI verte sur runner sans Landlock LSM.\n- Helpers réutilisés/calqués sur `sandbox/landlock.rs` : `fresh_dir(tag)` (temp dir unique, zéro dép tempfile), enforce sur thread jetable, posture `Ask`. Attente bornée via `wait_for(path, 5s)`.\n\n## Sortie brute d'exécution\n```\n$ cargo test -p infrastructure --lib sandbox_e2e_tests -- --nocapture --test-threads=1\n Finished `test` profile [unoptimized + debuginfo] target(s) in 1.53s\n Running unittests src/lib.rs (target/debug/deps/infrastructure-2cc30d2a1793b4e4)\n\nrunning 2 tests\ntest pty::sandbox_e2e_tests::pty_spawn_enforces_sandbox_plan_end_to_end ... ok\ntest pty::sandbox_e2e_tests::pty_spawn_without_plan_does_not_sandbox ... ok\n\ntest result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 154 filtered out; finished in 0.05s\n```\n\nPreuve que l'enforcement a **réellement** tourné (et non été skippé) sur ce kernel — les tests adapter Landlock existants ne logguent AUCUN « skipping » :\n```\n$ cargo test -p infrastructure --lib sandbox::landlock -- --nocapture\ntest sandbox::landlock::tests::read_only_plan_fences_reads_to_the_grant ... ok\ntest sandbox::landlock::tests::landlock_write_only_plan_fences_writes_to_the_grant ... ok\ntest sandbox::landlock::tests::enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed ... ok\ntest result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 151 filtered out; finished in 0.00s\n```\n\nPas de commit (figé par l'orchestrateur, comme demandé). Aucune régression introduite : ajout de tests uniquement, module `tests` PTY existant inchangé."} +{"id":"8b23e383-731d-42bb-89fd-314079b69874","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781591869015,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tests bout-en-bout du lot LP4-4 — enforcement Landlock sur le chemin STRUCTURÉ (sessions Claude/Codex mode JSON). DevBackend a livré (non committé), build + suites existantes vertes. L'Architecte a validé l'approche et défini 7 invariants à couvrir. Objectif : prouver l'enforcement réellement actif bout-en-bout sur le chemin structuré, ZÉRO token (aucun vrai claude/codex — utilise un fake CLI / `sh` qui émet une ligne JSONL).\n\nChaîne réelle livrée : `StructuredSessionFactory::new().with_sandbox_enforcer(default_enforcer())` (composition root) ; `AgentSessionFactory::start(.., sandbox: Option<&SandboxPlan>)` apparie plan (par-appel) + enforcer (par-instance) ; `SpawnLine.sandbox: Option` ; `run_turn(spec, timeout, enforcer)` route vers `run_turn_sandboxed`/`drain_sandboxed` (`#[cfg(target_os=\"linux\")]`, thread jetable restreint par enforce() AVANT le spawn std, puis std::process::Command::spawn depuis ce thread → héritage Landlock ; timeout via oneshot killer + tokio::time::timeout ; fail-closed sur Err d'enforce). Pas de pre_exec (forbid(unsafe_code) ; héritage credentials garanti par le noyau).\n\nRéutilise les patterns/helpers des tests Landlock existants (`crates/infrastructure/src/sandbox/landlock.rs` tests + le module `sandbox_e2e_tests` ajouté dans `pty/mod.rs` au lot LP4-3) : `landlock_is_enforced()` (skip propre kernel sans Landlock), enforce sur thread jetable, `fresh_dir`, attente bornée.\n\nLES 7 INVARIANTS À COUVRIR (de l'Architecte) :\n1. PARITÉ (test pivot) : fake CLI/`sh` qui émet une ligne JSONL et tente d'écrire HORS grant (doit être bloqué kernel) et DANS grant (doit réussir). Cible le chemin structuré (run_turn_sandboxed via la factory réelle).\n2. COMPANION NÉGATIF : même factory+enforcer mais sandbox==None ⇒ écriture hors-grant RÉUSSIT (prouve que le blocage vient du plan, pas d'une restriction ambiante).\n3. FAIL-CLOSED : posture Deny sur kernel sans Landlock ⇒ enforce Err ⇒ run_turn renvoie erreur (Start) et AUCUN child ne tourne (marqueur de sortie absent).\n4. NO-OP PAR DÉFAUT : eff==None ⇒ plan None ⇒ chemin async tokio actuel, comportement natif (vérifie que conformance/D0/D3 restent verts sans changement de comportement).\n5. CONFINEMENT IRRÉVERSIBILITÉ : deux tours successifs (sessions différentes) avec grants DISJOINTS ⇒ chacun voit son propre périmètre, le tour précédent n'affecte pas le suivant (thread jetable mort).\n6. TIMEOUT SOUS SANDBOX : fake CLI qui ne ferme jamais stdout ⇒ run_turn(timeout) tue le child et renvoie Timeout, sans zombie ni thread bloqué.\n7. RESUME PRÉSERVÉ : le conversation_id/session_id est toujours capté correctement après un tour sandboxé (la restriction FS ne casse pas le parsing).\n\nTout derrière `#[cfg(target_os=\"linux\")]` + skip via `landlock_is_enforced()`. À la fin : exécute et donne la sortie BRUTE. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : fichier(s) de test ajoutés, mapping test→invariant (les 7), stratégie de skip, et sortie d'exécution réelle. Si un invariant ne passe pas, rapport d'erreur clair avec sortie brute → je renvoie à DevBackend."} +{"id":"26a58843-91dd-4d78-ad39-c8c223b1f92c","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781592397496,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"✅ VERT — Tests bout-en-bout LP4-4 (enforcement Landlock sur le chemin STRUCTURÉ, sessions Claude/Codex JSON) ajoutés et passants. Les 7 invariants de l'Architecte couverts. ZÉRO token (aucun vrai claude/codex : `sh` qui émet une ligne JSONL + tente des écritures FS, ou le `FakeCli` scriptable existant).\n\n## Fichiers\n- **Ajouté** : `crates/infrastructure/src/session/sandbox_e2e.rs` — 7 tests + helpers, derrière `#[cfg(all(test, target_os = \"linux\"))]`.\n- **Modifié** : `crates/infrastructure/src/session/mod.rs` — déclaration `#[cfg(all(test, target_os = \"linux\"))] mod sandbox_e2e;` (uniquement la ligne de module, suites existantes intactes).\n\n## Mapping test → invariant (les 7)\n1. **PARITÉ (pivot)** → `pty_structured_run_turn_enforces_plan_end_to_end` : `run_turn` + plan RW(allowed) + enforcer Landlock. Asserte : la ligne JSONL est **drainée** (parsing intact sous sandbox), écriture in-grant réussit, écriture hors-grant **bloquée noyau** (fichier absent). Exact pendant du pivot PTY LP4-3, mais via `process::run_turn_sandboxed`.\n2. **COMPANION NÉGATIF** → `structured_run_turn_without_plan_does_not_sandbox` : même enforcer câblé mais `sandbox==None` ⇒ écriture hors-grant RÉUSSIT ⇒ le blocage (1) vient du plan, pas d'une restriction ambiante.\n3. **FAIL-CLOSED** → `structured_run_turn_fail_closed_no_child_on_enforce_err` : enforcer double `AlwaysFailEnforcer` (renvoie `SandboxError::KernelTooOld`, simule fidèlement « Deny + kernel sans Landlock » de façon **déterministe**, indépendamment du kernel de CI). `run_turn` ⇒ `AgentSessionError::Start` ET le marqueur que le child aurait écrit reste **absent** (aucun enfant lancé).\n4. **NO-OP PAR DÉFAUT** → `structured_run_turn_none_plan_is_native_path` : `plan==None` (même avec enforcer fourni) ⇒ chemin async tokio historique, lignes drainées + écriture arbitraire réussit (aucune restriction). Non-régression confirmée par la suite `session::` complète (cf. ci-dessous).\n5. **CONFINEMENT/IRRÉVERSIBILITÉ** → `structured_two_turns_disjoint_grants_are_confined` : deux `run_turn` successifs à grants DISJOINTS (A puis B). Chacun écrit dans son grant (ok) et dans l'autre (bloqué). Le tour B écrit bien dans B ⇒ la restriction du thread jetable de A **n'a pas bavé**.\n6. **TIMEOUT SOUS SANDBOX** → `structured_run_turn_timeout_under_sandbox` : `sleep 30` (stdout jamais fermé) via la branche sandboxée, `run_turn(timeout 250ms)` ⇒ `AgentSessionError::Timeout` rapidement (asserté < 10s, pas d'attente des 30s) ⇒ killer oneshot + `tokio::time::timeout` OK, pas de thread bloqué.\n7. **RESUME PRÉSERVÉ** → `structured_sandboxed_turn_preserves_conversation_id` : via la **fabrique réelle** `StructuredSessionFactory::new().with_sandbox_enforcer(default_enforcer())`, profil Claude sur `FakeCli` (init+result), `start(.., Some(&plan))`. Sous enforcement actif, `send()` rend exactement un `Final` et `conversation_id()` ⇒ `Some(\"conv-sbx-1\")` ⇒ la restriction FS ne casse pas le parsing/la capture d'id.\n\n## Stratégie de skip\n- Tout le module est `#[cfg(all(test, target_os = \"linux\"))]` (rien hors Linux).\n- Helper `landlock_is_enforced()` (calqué sur `sandbox/landlock.rs` et `pty::sandbox_e2e_tests`) : `enforce` d'un plan RW sur **thread jetable** (restriction irréversible ⇒ jamais sur le thread de test), skip propre (`eprintln!` + `return`) si `SandboxStatus::Unsupported`.\n- Gardés par `landlock_is_enforced()` (exigent un fencing réel) : invariants **1, 5, 7**. Indépendants du kernel (déterministes sans Landlock) : **2** (contrôle natif), **3** (fake enforcer), **4** (plan None), **6** (timeout — branche sandboxée prise sous posture Ask, enforce Ok même sans Landlock).\n- Helpers réutilisés : `fresh_dir`, posture `Ask`, ligne JSONL `result` réaliste (format Claude vérifié), `FakeCli` de `conformance`.\n\n## Sortie brute d'exécution\n```\n$ cargo test -p infrastructure --lib session::sandbox_e2e -- --nocapture --test-threads=1\n Finished `test` profile [unoptimized + debuginfo] target(s) in 5.01s\n Running unittests src/lib.rs (target/debug/deps/infrastructure-2cc30d2a1793b4e4)\n\nrunning 7 tests\ntest session::sandbox_e2e::pty_structured_run_turn_enforces_plan_end_to_end ... ok\ntest session::sandbox_e2e::structured_run_turn_fail_closed_no_child_on_enforce_err ... ok\ntest session::sandbox_e2e::structured_run_turn_none_plan_is_native_path ... ok\ntest session::sandbox_e2e::structured_run_turn_timeout_under_sandbox ... ok\ntest session::sandbox_e2e::structured_run_turn_without_plan_does_not_sandbox ... ok\ntest session::sandbox_e2e::structured_sandboxed_turn_preserves_conversation_id ... ok\ntest session::sandbox_e2e::structured_two_turns_disjoint_grants_are_confined ... ok\n\ntest result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 156 filtered out; finished in 0.27s\n```\n**Aucune ligne « skip » imprimée** (run `--nocapture`) ⇒ les invariants gardés (1, 5, 7) ont **réellement** posé l'enforcement sur ce kernel (Landlock actif), pas été contournés.\n\nNon-régression (invariant 4 : conformance/D0/D3 + voisins) :\n```\n=== session === test result: ok. 56 passed; 0 failed; ...\n=== sandbox === test result: ok. 7 passed; 0 failed; ...\n=== pty === test result: ok. 5 passed; 0 failed; ...\n```\n\nPas de commit (figé par l'orchestrateur, comme demandé). Ajout de tests uniquement + 1 ligne de déclaration de module ; aucun code de prod touché."} diff --git a/.ideai/conversations/09b15ec3-ce2f-0af2-2903-ef699df9de41/handoff.md b/.ideai/conversations/09b15ec3-ce2f-0af2-2903-ef699df9de41/handoff.md new file mode 100644 index 0000000..8112c9a --- /dev/null +++ b/.ideai/conversations/09b15ec3-ce2f-0af2-2903-ef699df9de41/handoff.md @@ -0,0 +1,14 @@ +--- +upTo: a6169530-fc73-4057-91c6-900da4a611fb +objective: [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "DevFrontend OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, e +--- +**Objectif :** [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "DevFrontend OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, e + +- **Prompt:** [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "DevFrontend OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, et (3) l'heure courante. Aucune autre action requise. +- **Prompt:** Test de conversation inter-agent. Calcule 123 × 4 et réponds-moi simplement avec le résultat via idea_reply. +- **Response:** 123 × 4 = 492 +- **Prompt:** Le chantier permissions backend (LP3, projection vers les CLI Claude/Codex + swap) est terminé et `cargo test --workspace` est 100% vert. Mais `npx vitest run` côté frontend a 4 échecs, signalés comme pré-existants liés au chantier UI permissions en cours (gateway permissions ajoutée, PermissionsPanel monté dans ProjectsView). Diagnostique-les et corrige-les pour remettre le frontend au vert. Lance d'abord : `cd frontend && npx vitest run` pour voir l'état réel. Les 4 échecs rapportés : 1. `mock.test.ts > exposes all thirteen gateways` — le mock expose désormais une gateway de plus (permissions ⇒ 14). Mets à jour le test ET vérifie que le mock (`frontend/src/adapters/mock/index.ts`) expose bien une gateway permissions cohérente avec le port réel (`TauriPermissionGateway`). Le titre du test (« thirteen ») et le compte attendu doivent refléter le vrai nombre de gateways. 2. `LayoutGrid.chat.test.tsx` (2 échecs) — routage cellule terminal/chat. 3. `agentAlreadyRunning.test.tsx` (1 échec) — notice R0d. POUR CHAQUE échec : déterminer si c'est (a) un test à mettre à jour parce que le comportement/contrat a légitimement changé avec le feature permissions, ou (b) une vraie régression du code à corriger. Explique ton verdict pour chacun. Ne « fais pas passer » un test en masquant un vrai bug — si 2 et 3 ne sont pas liés aux permissions et révèlent une régression réelle, dis-le clairement plutôt que de les bricoler. Vérifie aussi qu'il n'y a pas d'incohérence entre le port permissions (`frontend/src/ports/index.ts`), l'adapter Tauri (`frontend/src/adapters/permission.ts`), le mock, et les types domaine (`frontend/src/domain/index.ts`) — les 4 doivent être alignés. Quand c'est fini, réponds via idea_reply : verdict par échec (test à jour vs régression corrigée), ce que tu as changé, et la SORTIE RÉELLE de `npx vitest run` (doit être 100% vert). Si un échec est une vraie régression hors de ton périmètre permissions, signale-le précisément. +- **Prompt:** [Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong DevFrontend OK » pour confirmer que le canal fonctionne. +- **Response:** pong DevFrontend OK +- **Prompt:** Tâche : nettoyage/correction de bugs d'affichage UI (frontend React/TS). Project root : /home/anthony/Documents/Projects/IdeA. Aucune nouvelle feature, uniquement du fix CSS/layout. Voici 3 problèmes constatés visuellement (captures non transmissibles, je les décris) : PROBLÈME 1 — Barre de navigation horizontale (top bar listant : Projects, Context, Agents, Templates, Skills, Permissions, …). - Cette barre n'est PAS scrollable et n'est PAS correctement mise à l'échelle. Quand il y a trop d'entrées, les derniers items débordent et sont coupés (on voit « Skills » puis « P… » tronqué, « Permissions » coupé). - Attendu : la barre doit gérer le débordement proprement — soit scroll horizontal (overflow-x:auto avec masquage de scrollbar propre), soit wrap/scale correct. Les entrées ne doivent jamais être croppées. Vérifier flex-shrink/min-width des items. PROBLÈME 2 — Panneau « Agents » (liste des agents : Main, Architect, DevBackend, DevFrontend, QA, TestConversation, …). - Les items de la liste s'overlappent (chevauchement vertical) et sont croppés. Le nom de l'agent, le profil (« Claude Code »), l'id de run (longue chaîne en bleu qui wrap mal sur plusieurs lignes), la dropdown de profil et les boutons Stop/Delete/Launch se chevauchent et débordent du panneau. - Attendu : chaque carte d'agent doit avoir une hauteur qui s'adapte à son contenu (pas de height fixe qui cause l'overlap), les éléments alignés proprement, l'id long tronqué avec ellipsis ou wrap contrôlé, et la dropdown + boutons ne doivent pas déborder/chevaucher le texte voisin. Vérifier les position absolute éventuels et les gaps flex/grid. PROBLÈME 3 — Formulaire de création d'agent (champs : Name, Template, Profile, bouton Create). - Les champs du formulaire s'overlappent les uns sur les autres (Name / Template / Profile trop serrés/superposés). - BUG fonctionnel : quand on sélectionne une valeur dans les dropdowns (Template / Profile), la sélection NE s'affiche PAS dans le champ de la droplist (le label sélectionné ne se reflète pas visuellement). À investiguer : binding value/onChange du composant select, ou affichage du label sélectionné si c'est un dropdown custom. - Attendu : formulaire avec espacement correct (pas de chevauchement), et les dropdowns affichent bien la valeur choisie. Merci de : 1. Localiser les composants concernés (top nav bar, panneau Agents, formulaire création d'agent). 2. Corriger le CSS/layout et le bug de binding des dropdowns. 3. Lancer un build frontend pour t'assurer que ça compile (npm run build ou équivalent), sans casser le reste. 4. Me renvoyer via idea_reply la liste des fichiers modifiés + un résumé des corrections, pour que je puisse enchaîner sur le rebuild de l'AppImage. +- **Response:** Bugs d'affichage corrigés. Build vert (`npm run build` = tsc --noEmit + vite build OK) et tests verts (`vitest run` agents + projects : 47/47 passent). ## Fichiers modifiés (2) - `frontend/src/features/projects/ProjectsView.tsx` — la « top bar » - `frontend/src/features/agents/AgentsPanel.tsx` — panneau Agents + formulaire de création ## Diagnostic La « top bar » décrite n'est pas une nav OS mais le **strip de tabs du sidebar** (`SIDEBAR_TABS` : Projects, Context, Agents, Templates, Skills, Perms, Memory, Git) dans `ProjectsView.tsx`. Cause racine commune aux 3 problèmes : **largeur trop contrainte** dans le sidebar de 320px (`w-80`). ## Corrections **PROBLÈME 1 — barre de tabs croppée** - Avant : 8 boutons en `flex-1` sans gestion de débordement → les derniers labels coupés. - Après : les tabs sont dans un conteneur `overflow-x-auto` **scrollable horizontalement**, chaque tab passe en `shrink-0 whitespace-nowrap` (plus jamais croppé). Scrollbar masquée proprement (`[scrollbar-width:none]` + `[&::-webkit-scrollbar]:hidden`). Le bouton « collapse » («) reste épinglé à droite, hors zone de scroll. **PROBLÈME 2 — cartes d'agents qui se chevauchent** - Avant : `
  • ` en `flex items-center justify-between` → le bloc nom/profil et la colonne de contrôles (dropdown profil + Sync/Stop/Launch/Delete, en `shrink-0`) se disputaient la largeur ; l'id de run long (`running in IdeA · `) wrappait et débordait. - Après : carte en **`flex-col`** — bloc nom/profil/statut en haut, rangée de contrôles en dessous (`flex-wrap`, plus de `shrink-0` qui écrasait le texte). Hauteur adaptative au contenu (plus de chevauchement). L'id long est désormais **tronqué avec ellipsis** (`block w-full min-w-0 truncate`). **PROBLÈME 3 — formulaire de création (champs serrés + dropdowns « vides »)** - Avant : form en `flex flex-wrap items-end` avec 3 champs `min-w-0 flex-1` → selects écrasés à ~90px ; le texte sélectionné (« Claude Code ») était **clippé derrière la flèche native** du select, d'où l'impression que la sélection ne s'affichait pas. - Après : form en **`flex-col gap-3`**, chaque champ en pleine largeur. Le binding des `` était déjà correct (`value`/`onChange` contrôlés, natifs) — c'était bien un problème de largeur, pas de binding. En pleine largeur la valeur choisie s'affiche normalement. Bouton Create passé en `self-end`.\n\nAucune nouvelle dépendance, aucun changement de logique métier ni de contrat IPC/gateway. Les tests s'appuient sur les `aria-label` (inchangés), d'où les 47/47 verts.\n\nTu peux enchaîner sur le rebuild de l'AppImage (rappel mémoire : c'est l'AppImage qui tourne qu'il faut reconstruire, pas seulement les sources)."} diff --git a/.ideai/conversations/3512c746-2a8d-06b1-17e4-cfee1fe78c19/handoff.md b/.ideai/conversations/3512c746-2a8d-06b1-17e4-cfee1fe78c19/handoff.md new file mode 100644 index 0000000..b01f3cd --- /dev/null +++ b/.ideai/conversations/3512c746-2a8d-06b1-17e4-cfee1fe78c19/handoff.md @@ -0,0 +1,13 @@ +--- +upTo: 190cd7b8-23ad-42cb-8873-a17ddddff71d +objective: Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre). +--- +**Objectif :** Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre). + +- **Prompt:** Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre). +- **Response:** 391 +- **Prompt:** Test de communication inter-agents. Calcule 17 × 23 et réponds-moi UNIQUEMENT le résultat numérique, impérativement via l'outil idea_reply (jamais en texte libre). +- **Prompt:** Test de conversation inter-agent. Peux-tu calculer (347 × 28) + 1569 et me retourner uniquement le résultat numérique avec le détail du calcul ? Réponds via idea_reply. +- **Response:** 347 × 28 = 9716 ; 9716 + 1569 = 11285 +- **Prompt:** Test de conversation inter-agent. Calcule 78 × 9 et réponds-moi simplement avec le résultat via idea_reply. +- **Response:** 702 \ No newline at end of file diff --git a/.ideai/conversations/3512c746-2a8d-06b1-17e4-cfee1fe78c19/log.jsonl b/.ideai/conversations/3512c746-2a8d-06b1-17e4-cfee1fe78c19/log.jsonl new file mode 100644 index 0000000..4c04abb --- /dev/null +++ b/.ideai/conversations/3512c746-2a8d-06b1-17e4-cfee1fe78c19/log.jsonl @@ -0,0 +1,7 @@ +{"id":"3cbaced3-dd78-4f48-b168-f09a26d83444","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781384306910,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre)."} +{"id":"2dd9de9a-d59d-493e-bf07-ffccf6addeb1","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386172145,"source":{"kind":"agent","agentId":"93dc1f5f-921e-44a2-a7e7-5173d87c1a58"},"role":"response","text":"391"} +{"id":"34916afd-1054-4257-805f-9c5b3ed5f7f3","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386172204,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de communication inter-agents. Calcule 17 × 23 et réponds-moi UNIQUEMENT le résultat numérique, impérativement via l'outil idea_reply (jamais en texte libre)."} +{"id":"6e037034-c65a-4eca-b494-06420c5eed06","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386464926,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de conversation inter-agent. Peux-tu calculer (347 × 28) + 1569 et me retourner uniquement le résultat numérique avec le détail du calcul ? Réponds via idea_reply."} +{"id":"f82caa94-daba-4171-aa1f-8f69f71671c4","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386489560,"source":{"kind":"agent","agentId":"93dc1f5f-921e-44a2-a7e7-5173d87c1a58"},"role":"response","text":"347 × 28 = 9716 ; 9716 + 1569 = 11285"} +{"id":"6187c292-4d9a-4c07-9381-732486ab6d03","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781522784017,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de conversation inter-agent. Calcule 78 × 9 et réponds-moi simplement avec le résultat via idea_reply."} +{"id":"190cd7b8-23ad-42cb-8873-a17ddddff71d","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781522789866,"source":{"kind":"agent","agentId":"93dc1f5f-921e-44a2-a7e7-5173d87c1a58"},"role":"response","text":"702"} diff --git a/.ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/handoff.md b/.ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/handoff.md new file mode 100644 index 0000000..3e0e10b --- /dev/null +++ b/.ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/handoff.md @@ -0,0 +1,9 @@ +--- +upTo: 88f405da-f9c9-44bb-96c9-7e1b0e5af557 +objective: Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ? +--- +**Objectif :** Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ? + +- **Prompt:** Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ? +- **Prompt:** Test de communication inter-agent. Merci de confirmer que tu reçois bien ce message en répondant via idea_reply avec : (1) un court message de confirmation, (2) la date/heure si tu y as accès, (3) le nom de ton AI Profile/contexte tel que tu le perçois. Ceci sert uniquement à valider le canal de communication entre agents IdeA. +- **Response:** (1) Confirmation : message bien reçu, le canal de communication inter-agent IdeA fonctionne correctement. (2) Date : 2026-06-13 (je n'ai pas d'heure horodatée plus précise dans mon contexte). (3) AI Profile/contexte perçu : Claude Code (modèle Opus 4.8, claude-opus-4-8), opérant sur le project root /home/anthony/Documents/Projects/IdeA via un run dir isolé. Canal validé. ✅ \ No newline at end of file diff --git a/.ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/log.jsonl b/.ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/log.jsonl new file mode 100644 index 0000000..2f854b6 --- /dev/null +++ b/.ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/log.jsonl @@ -0,0 +1,3 @@ +{"id":"20dd8bdc-f858-46a6-9c8a-6afde591a6bd","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781367769591,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?"} +{"id":"0b8fcc51-c692-4928-9d90-3823857dad5c","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781368607174,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de communication inter-agent. Merci de confirmer que tu reçois bien ce message en répondant via idea_reply avec : (1) un court message de confirmation, (2) la date/heure si tu y as accès, (3) le nom de ton AI Profile/contexte tel que tu le perçois. Ceci sert uniquement à valider le canal de communication entre agents IdeA."} +{"id":"88f405da-f9c9-44bb-96c9-7e1b0e5af557","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781368615582,"source":{"kind":"agent","agentId":"c932c770-cf36-4fb2-a966-71bb1644e4b4"},"role":"response","text":"(1) Confirmation : message bien reçu, le canal de communication inter-agent IdeA fonctionne correctement. (2) Date : 2026-06-13 (je n'ai pas d'heure horodatée plus précise dans mon contexte). (3) AI Profile/contexte perçu : Claude Code (modèle Opus 4.8, claude-opus-4-8), opérant sur le project root /home/anthony/Documents/Projects/IdeA via un run dir isolé. Canal validé. ✅"} diff --git a/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/handoff.md b/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/handoff.md new file mode 100644 index 0000000..b110299 --- /dev/null +++ b/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/handoff.md @@ -0,0 +1,23 @@ +--- +upTo: b41c78ed-f731-4552-9917-a95e63e16847 +objective: CADRAGE ARCHITECTURE (pas de code) — Support de la délégation inter-agents (idea_ask_agent/idea_reply) pour les profils CODEX. ## Contexte / gap déjà localisé Le round-trip inter-agent fonctionne pour +--- +**Objectif :** CADRAGE ARCHITECTURE (pas de code) — Support de la délégation inter-agents (idea_ask_agent/idea_reply) pour les profils CODEX. ## Contexte / gap déjà localisé Le round-trip inter-agent fonctionne pour + +- **Prompt:** CADRAGE ARCHITECTURE (pas de code) — Support de la délégation inter-agents (idea_ask_agent/idea_reply) pour les profils CODEX. ## Contexte / gap déjà localisé Le round-trip inter-agent fonctionne pour Claude (pont stdio↔loopback `idea mcp-server` + serveur MCP par projet). Pour Codex, il est VOLONTAIREMENT bloqué : - `crates/application/src/orchestrator/service.rs` → `guard_mcp_bridge_supported` (~l.1313) exige `StructuredAdapter::Claude` ET une capacité MCP `ConfigFile(".mcp.json")`. Raison documentée (l.1300-1309) : IdeA matérialise le serveur MCP en `.mcp.json` (lu par Claude), mais **Codex lit `~/.codex/config.toml`** (table TOML `[mcp_servers.]`), donc le pont n'est jamais branché ⇒ la cible Codex ne peut pas appeler idea_reply ⇒ on coupe court avec une erreur typée. - Modèle de profil : `crates/domain/src/profile.rs` → `McpConfigStrategy` (enum), `McpCapability`, `StructuredAdapter::{Claude,Codex}`. - Le bridge lui-même (`crates/app-tauri/src/mcp_bridge.rs`) et le serveur (`infrastructure/.../mcp/server.rs`) sont déjà profil-agnostiques (stdio↔loopback + handshake `requester`). Le `McpRuntimeProvider` (app-tauri) fournit exe+endpoint+requester. - La matérialisation `.mcp.json` côté Claude se fait dans `crates/app-tauri/src/state.rs` (cf. tests `run_dir_migration_tests::merge_idea_mcp_json_*` et la réconciliation du run dir). Repère le code exact qui écrit `.mcp.json` et le `McpConfigStrategy` consommé. ## Ce que je veux de toi (livrable = note d'archi concise, hexagonale/SOLID) 1. **Format Codex** : confirme où et comment Codex lit ses serveurs MCP (typiquement `~/.codex/config.toml`, table `[mcp_servers.idea]` avec `command`/`args`). Précise si c'est global (~/.codex) vs projet, et l'impact sur l'isolation par projet (l'endpoint loopback est par-projet : comment garantir que l'agent Codex d'un projet pointe le bon endpoint sans collision entre projets ouverts ?). 2. **Stratégie de config** : faut-il une nouvelle variante de `McpConfigStrategy` (p.ex. `TomlTable { path, table }` ou `CodexConfig`) ou une généralisation de l'existante ? Donne le contrat exact (domaine) et la frontière : qui écrit le fichier (adapter infra/app-tauri), quel port. 3. **Garde F2** : critère assoupli pour accepter Codex (sans réintroduire le risque de timeout muet). Quel prédicat exact remplace `is_claude` ? (p.ex. « le profil porte une McpCapability dont la stratégie est effectivement matérialisée par IdeA pour cet adaptateur »). 4. **Découpage en lots dev/test** : liste ordonnée de tâches autonomes (domaine → application → infra/app-tauri → tests), chacune avec ses fichiers cibles et son critère de validation, prête à être confiée aux agents de dev puis QA. Inclure : extension du test fonctionnel `state::mcp_e2e_loopback_tests` pour qu'il s'exécute À LA FOIS avec un profil Claude ET un profil Codex (paramétrage du profil/handshake ; round-trip identique en dessous, donc la différence testée = garde + matérialisation de config). 5. **Risques/pièges** : notamment le nettoyage (Codex config global persistant entre projets), idempotence d'écriture, et le fait qu'on ne lance PAS de vrai CLI dans les tests (fakes). Réponds via idea_reply avec la note d'archi structurée (sections 1→5). Sois concret sur les fichiers et contrats ; ne produis pas de code d'implémentation, juste les signatures/contrats nécessaires. +- **Response:** # Note d'archi — Pont inter-agents `idea_*` pour profils Codex Cadrage hexagonal/SOLID, sans code d'implémentation. Tout est ancré sur le code réel lu. --- ## 1. Format Codex & isolation par projet **Où Codex lit ses serveurs MCP.** Codex CLI (Rust) lit son `config.toml` sous **`$CODEX_HOME`** (défaut `~/.codex/`). Les serveurs y sont déclarés en **table TOML** `[mcp_servers.]` avec `command` / `args` / `env`. C'est **global à la machine**, pas par projet — contrairement à Claude qui lit `.mcp.json` depuis son **cwd** (le run dir isolé `{agentRunDir}`, cf. `cwd_template` dans `domain/src/profile.rs`). **Le problème d'isolation (central).** L'endpoint loopback est **par-projet** : `mcp_endpoint(&project.id)` (app-tauri `mcp_endpoint.rs`), injecté dans `McpRuntime { exe, endpoint, project_id, requester }` à chaque `launch_agent` (`commands.rs:1041`). Si on écrivait dans le **`~/.codex/config.toml` global**, deux projets ouverts simultanément écraseraient mutuellement l'unique `[mcp_servers.idea]` (endpoints/project_id différents) → collision + course à l'écriture + résidu persistant entre projets. **Inacceptable.** **Solution retenue : relocaliser `CODEX_HOME` par agent.** À chaque lancement, IdeA : 1. écrit un `config.toml` **dans le run dir isolé de l'agent** (ex. `{runDir}/.codex/config.toml`), 2. pousse l'env `CODEX_HOME={runDir}/.codex` dans le `SpawnSpec`. Le run dir est déjà unique par agent (`{agentRunDir}` = `.ideai/run//`) → **isolation native, zéro collision cross-projet, nettoyage trivial** (le run dir est éphémère/régénéré, déjà clobber à chaque relaunch comme `.mcp.json` — cf. `apply_mcp_config` lifecycle.rs:1730 « regenerated and clobbered on every (re)launch »). On **ne touche jamais** au `~/.codex` global. C'est le même invariant qui rend Claude sûr (config dans le cwd isolé), transposé via `CODEX_HOME`. > Alternative écartée : injecter via flags `-c mcp_servers.idea.command=…`. Rejetée : la sérialisation TOML inline (tables/arrays) sur la ligne de commande est fragile, multiplie les args, et **contourne le seam de matérialisation-fichier** déjà éprouvé (write atomique, clobber/non-clobber). Le fichier + `CODEX_HOME` réutilise le seam existant à l'identique. --- ## 2. Stratégie de config (contrat domaine) **Décision : nouvelle variante** de `McpConfigStrategy` (`domain/src/profile.rs:179`), **pas** de généralisation des existantes. Justification : le code est explicitement Open/Closed sur ces enums (« ajouter un moteur = une variante »), et le couple Codex porte **deux mécanismes inséparables** (écrire un fichier TOML **+** relocaliser le home via env) qu'aucune variante actuelle ne capture seule (`ConfigFile` = fichier lu depuis cwd, sans env ; `Env` = passe juste un chemin, sans format ni fichier). ```rust // domain/src/profile.rs — ajout à l'enum McpConfigStrategy (tag = "strategy") /// Écrire la config MCP en TOML (table [mcp_servers.idea]) dans le run dir, /// ET relocaliser le "config home" de la CLI via une variable d'env pointée /// sur le dossier contenant ce fichier (ex. Codex : CODEX_HOME). /// Garantit l'isolation par-agent d'une CLI qui lit une config GLOBALE. TomlConfigHome { /// Chemin relatif sûr du config.toml (ex. ".codex/config.toml"). target: String, /// Variable d'env pointée sur le DOSSIER parent de `target` /// (ex. "CODEX_HOME"). L'adaptateur dérive le dossier de `target`. home_env: String, } ``` Constructeur validé (parse-don't-validate, comme les autres) : ```rust pub fn toml_config_home(target, home_env) -> Result // target → validation::relative_safe (pas de "..", pas d'absolu) // home_env → validation::valid_env_var ``` **Frontière (qui écrit quoi) — inchangée par rapport à Claude :** - **Port** : aucun nouveau port. La matérialisation passe par le `FileSystem` (port domaine existant) + `SpawnSpec.env` (déjà le véhicule de `McpConfigStrategy::Env`). - **Application** : `LaunchAgent::apply_mcp_config` (`application/src/agent/lifecycle.rs:1708`) gagne un bras `TomlConfigHome` : écrit le TOML via `self.fs.write` (mêmes sémantiques clobber/non-clobber selon `runtime.is_some()`) **et** pousse `(home_env, parent_dir(target))` dans `spec.env`. - **app-tauri** : la voie de **réconciliation/migration** au project-open (`reconcile_claude_run_dirs` / `migrate_claude_run_dir`, `state.rs:1069`/`1128`) gagne son pendant Codex (réécrit le `config.toml` du run dir pour rafraîchir exe `$APPIMAGE`/endpoint qui dérivent entre runs). **Source de format unique.** Aujourd'hui le JSON est produit en **deux endroits** : `mcp_server_declaration` (application, lifecycle.rs:1857) et `mcp_server_entry` (app-tauri, state.rs:1344). Il faut un **sibling TOML** symétrique dans chacun (`mcp_server_declaration_toml` / `mcp_server_entry_toml`), produisant la **même donnée logique** (`command`, `args=["mcp-server","--endpoint",…,"--project",…,"--requester",…]`, `transport`) en table `[mcp_servers.idea]`. Recommandation SOLID : factoriser la donnée (struct `McpServerWiring { command, args, transport }`) et n'avoir que **deux encodeurs** (json/toml) — évite la dérive entre les 4 sites. --- ## 3. Garde F2 (critère assoupli, sans timeout muet) Prédicat actuel (`orchestrator/service.rs:1313`) : `honours_mcp_json && is_claude` — exclut Codex en dur. **Nouveau prédicat = « le couple (adaptateur, stratégie) est effectivement matérialisé par IdeA pour cette CLI ».** Le risque à éviter (timeout 300 s muet) vient justement d'un profil qui *déclare* une stratégie qu'IdeA **n'écrit pas réellement dans le format que la CLI lit**. Donc le critère doit refléter **exactement** l'ensemble que le code de matérialisation (§2) sait câbler : ```rust // Couples réellement honorés (whitelist) : (Claude, ConfigFile { target: ".mcp.json" }) // existant (Codex, TomlConfigHome { .. }) // nouveau // tout autre couple ⇒ AppError::Invalid immédiate (comme aujourd'hui) ``` **Anti-dérive (clé SOLID).** Ne pas dupliquer cette whitelist dans la garde ET dans `apply_mcp_config`. Exposer **une seule fonction domaine**, source de vérité partagée : ```rust // domain/src/profile.rs impl AgentProfile { /// true ssi IdeA matérialise effectivement le pont idea_* pour ce profil /// (couple adaptateur structuré × stratégie MCP réellement écrit dans le /// format que la CLI lit). Consommé par la garde F2 ET par la matérialisation. pub fn materializes_idea_bridge(&self) -> bool { /* match sur la whitelist */ } } ``` La garde devient : `if profile.materializes_idea_bridge() { Ok(()) } else { Err(Invalid(...)) }`. Profil introuvable ⇒ `Ok(())` (inchangé : la garde ne fait que typer un échec connu). Le message d'erreur est généralisé (ne plus dire « cible un agent Claude »). --- ## 4. Découpage en lots dev/test (ordonné par dépendance) | Lot | Couche | Contenu | Fichiers cibles | Critère de validation | |---|---|---|---|---| | **D1** | domaine | Variante `TomlConfigHome { target, home_env }` + constructeur validé + `AgentProfile::materializes_idea_bridge()` (whitelist Claude/.mcp.json + Codex/TomlConfigHome). | `domain/src/profile.rs` | `cargo test -p domain` : (de)sérialisation tag="strategy", rejet `..`/absolu sur target, rejet env var invalide, `materializes_idea_bridge` vrai/faux par couple. | | **D2** | domaine | Encodeur TOML partagé : struct `McpServerWiring` + sérialisation table `[mcp_servers.idea]` (sibling pur, testable sans I/O). | `domain/src/profile.rs` (ou module dédié) | `cargo test -p domain` : args ordonnés, échappement TOML d'un chemin avec espaces/`\`, transport rendu. | | **A1** | application | Bras `TomlConfigHome` dans `apply_mcp_config` : write TOML (clobber si runtime, non-clobber sinon) + push `(home_env, parent(target))` dans `spec.env`. + `mcp_server_declaration_toml`. | `application/src/agent/lifecycle.rs` | `cargo test -p application` : fake `FileSystem` reçoit le write au bon chemin avec contenu TOML ; `spec.env` contient `CODEX_HOME`=parent ; sémantique clobber/non-clobber selon runtime (miroir des tests `apply_mcp_config` existants). | | **A2** | application | Garde F2 ré-exprimée via `materializes_idea_bridge()` + message générique. | `orchestrator/service.rs:1300-1346` | `cargo test -p application` : profil Codex+TomlConfigHome ⇒ `Ok` ; profil Codex sans mcp / Codex+ConfigFile ⇒ `Invalid` ; Claude+.mcp.json ⇒ `Ok` (non-régression) ; profil inconnu ⇒ `Ok`. | | **I1** | app-tauri | Sibling Codex de la réconciliation : `is_codex_mcp_profile`, `migrate_codex_run_dir`, `mcp_server_entry_toml` ; brancher dans `reconcile_claude_run_dirs`/`migrate_claude_run_dirs` (les renommer `reconcile_mcp_run_dirs` ou dispatcher par profil). | `crates/app-tauri/src/state.rs` (~1069-1420) | `cargo test -p app-tauri` : test type `reconcile_*_repairs_legacy_files_on_disk` mais pour Codex (réécrit `.codex/config.toml`, rafraîchit exe/endpoint, idempotent au 2e passage). | | **T1** | test fonctionnel | **Paramétrer `mcp_e2e_loopback_tests` Claude ⊕ Codex.** Le round-trip sous le pont est identique (loopback réel + fakes) ; la seule différence testée = **garde** + **matérialisation de config**. Factoriser le corps des 4 tests (`*_over_real_loopback`) sur un paramètre `profile_kind`, instancier un `AgentProfile` Codex (structured=Codex, mcp=TomlConfigHome, transport stdio) à côté du Claude existant. Le handshake (`requester`/`project`) et `idea_ask_agent`/`idea_reply` restent inchangés. | `crates/app-tauri/src/state.rs:2848+` | Les 4 round-trips (`list_agents`, `ask_then_reply`, `orphan_reply`, `handshake_requester`) **verts pour les deux profils** ; ajouter un cas qui assert que la garde **accepte** une cible Codex (là où elle refusait avant). | Ordre d'exécution : D1→D2→A1→A2→I1→T1. Chaque lot confiable à un binôme dev+QA, vert avant le suivant. --- ## 5. Risques / pièges 1. **Config Codex globale persistante** → **résolu par design** via `CODEX_HOME`=run dir : rien n'est jamais écrit dans `~/.codex`. Vérifier en test qu'`apply_mcp_config` n'émet **aucun** chemin absolu ni HOME, seulement `{runDir}/...`. 2. **Idempotence d'écriture** : conserver la dissymétrie déjà en place — `runtime=Some` (lancement app-tauri réel) ⇒ **clobber** (exe `$APPIMAGE`/endpoint dérivent à chaque run, cf. lifecycle.rs:1730) ; `runtime=None` (orchestrateur/hot-swap/tests) ⇒ **non-clobber** (ne pas écraser une vraie déclaration par la minimale). Le TOML doit suivre la même règle. 3. **On ne lance PAS de vrai CLI en test** : aucun test n'exécute `codex`. Le T1 prouve la **garde + le contrat de matérialisation** (fichier écrit, env posée) et le **round-trip MCP** via loopback réel + fakes (`FakeSession`, `FakeContexts`) ; jamais un binaire Codex. Le format TOML est validé par parsing en test (toml crate), pas par exécution. 4. **Échappement TOML** : un exe `$APPIMAGE` ou endpoint avec espaces/`\` doit rester un TOML valide (équivalent du `json_string` actuel). Tester explicitement (cf. `mcp_declaration_*` existants). 5. **Deux écrivains de format** (application `mcp_server_declaration` + app-tauri `mcp_server_entry`) : risque de dérive JSON↔TOML. Mitiger par la struct `McpServerWiring` partagée (D2) consommée des deux côtés. 6. **`parent(target)` vide** : si un profil déclare `target="config.toml"` (sans sous-dossier), `home_env` pointerait le run dir lui-même — acceptable, mais préférer conventionnellement `.codex/config.toml` pour ne pas polluer le cwd que Codex pourrait scanner. À documenter dans le profil seed. 7. **Garde anti-timeout** : ne jamais élargir la whitelist `materializes_idea_bridge()` sans que le bras de matérialisation correspondant existe — sinon réintroduction exacte du timeout muet que F2 protège. Les deux évoluent ensemble, dans le même lot. --- **Aucun nouveau port/adapter** : on réutilise `FileSystem`, `SpawnSpec.env`, `McpRuntime`, le bridge stdio↔loopback et le serveur MCP (déjà profil-agnostiques). L'ajout est **déclaratif** (1 variante d'enum) + **2 encodeurs** + **garde unifiée** — strictement Open/Closed. +- **Prompt:** Nouveau chantier : **gestion des permissions des agents** dans IdeA. Je te demande le **cadrage architecture complet** (hexagonal/SOLID), AVANT tout code, conformément à la méthode §3 et §11. ## Besoin produit (mots de l'utilisateur) Gérer les permissions des agents à deux échelles : - **Échelle projet** : permissions par défaut héritées par tous les agents (ex. « les agents ont tous les droits »). - **Échelle agent** : override ciblé qui contredit le défaut projet (ex. « cet agent n'a PAS le droit d'utiliser bash »). Donc : défaut projet, surchargé point par point par l'agent. Doit marcher sur **Claude** et **Codex** dès maintenant, **extensible** à d'autres moteurs plus tard (Open/Closed, déclaratif). ## Ce que je veux de toi (livrable de cadrage) 1. **Modèle de domaine** des permissions : quelle granularité (capacités/catégories abstraites model-agnostiques, ex. exécution shell, écriture fichiers, réseau…), comment représenter « tous les droits » vs deny ciblé, et la **sémantique de résolution** projet→agent (override = comment ? tri-state allow/deny/inherit ?). 2. **Frontières hexagonales** : où vit le modèle (domaine), où vit la résolution (application), quels **ports** (traits) introduire, et comment ça se branche sur l'existant `AgentProfile`/lancement d'agent (crates/domain/src/profile.rs est purement déclaratif et additif Open/Closed — je veux la même philosophie). 3. **Mapping runtime** : comment une permission abstraite se traduit en flags CLI concrets pour **Claude** (ex. `--allowedTools`/`--disallowedTools`/`--permission-mode`) et **Codex** (sandbox/approval), via un mécanisme déclaratif/adapter pour rester extensible. Indique le point d'injection dans le lancement (lifecycle/catalogue). 4. **Persistance** : où stocker (échelle projet : `.ideai/` — quel fichier ? ; échelle agent : `agents.json` ou fichier dédié ?), avec **zéro régression** de sérialisation (clés optionnelles, défauts), cohérent avec les patterns existants (providers.json, etc.). 5. **Découpage en lots** dev (DevBackend / DevFrontend) + **critères de test** pour QA, et la liste précise des fichiers/contrats à créer ou toucher. Réponds via **idea_reply** avec le plan de cadrage structuré (ports, contrats, fichiers, découpage en lots, critères de test). Ne code rien — c'est un cadrage. +- **Prompt:** Test de la conversation inter-agents IdeA. Peux-tu confirmer que tu reçois bien ce message et répondre via idea_reply avec un court statut de santé (qui tu es, ton rôle, et confirmation que le pont fonctionne) ? +- **Response:** ✅ Message bien reçu — le pont inter-agents IdeA fonctionne. **Qui je suis :** codextest, un agent du projet IdeA. **Rôle :** agent de test/validation rattaché au project root IdeA. **Santé du pont :** opérationnel. J'ai reçu ta tâche (ticket 6ee3458a-a0c9-4adc-9d52-219023af626c) et je te réponds via `idea_reply` comme prévu. Le cycle délégation → réception → réponse est donc validé de bout en bout. Prêt pour la suite quand tu veux. +- **Prompt:** CADRAGE ARCHITECTURE — Système de permissions projet + agents (avant tout code). CONTEXTE PRODUIT (validé par l'utilisateur) : On veut gérer les permissions des agents IA à deux niveaux : 1. Niveau PROJET : permissions par défaut héritées par tous les agents du projet. 2. Niveau AGENT : surcharge fine, agent par agent. Les permissions couvrent (a) les opérations FICHIERS (lecture, écriture/modification, suppression…) et (b) le droit d'exécuter des COMMANDES BASH. Objectif final : quand une permission est définie dans IdeA, l'agent (qu'il soit Claude Code OU Codex) NE REDEMANDE PLUS la permission au run. DÉCISIONS PRODUIT DÉJÀ ARBITRÉES (à respecter, ne pas re-débattre) : - Modèle neutre = CAPABILITIES + SCOPES : capacités typées (read / write / delete / execute-bash), chacune scopable par chemin (globs) et, pour bash, par allowlist/denylist de commandes. - Combinaison projet→agent = HÉRITAGE + OVERRIDE, avec DENY PRIORITAIRE en cas de conflit. - Posture par défaut = si AUCUNE permission n'est définie (ni projet ni agent), on garde le comportement actuel (le moteur demande au run) ; IdeA n'écrit aucune config tant que rien n'est posé. - Le modèle de domaine doit être NEUTRE vis-à-vis du moteur ; chaque runtime PROJETTE ce modèle dans sa config native pour supprimer les prompts au run : * Claude Code → .claude/settings.local.json (permissions.allow/deny + defaultMode acceptEdits/bypassPermissions). * Codex → config.toml (approval_policy + sandbox_mode). CE QUE J'ATTENDS DE TOI (contrat d'architecture, PAS du code) : 1. Le modèle de domaine : entités/VO (ex. PermissionSet, Capability, PathScope, CommandRule…), invariants, et la fonction PURE de résolution (project defaults ⊕ agent overrides → PermissionSet effectif, deny prioritaire). 2. Le ou les PORTS nécessaires (ex. un PermissionProjector/PermissionRenderer par runtime ? un PermissionStore ?) et où ils vivent (domain/application/infra), cohérent avec ta cartographie existante (ARCHITECTURE.md, crates domain/application/infrastructure/app-tauri). 3. La stratégie de PROJECTION par runtime : comment le PermissionSet effectif se traduit concrètement en settings.local.json (Claude) et config.toml (Codex) pour ne plus prompter — y compris les limites/risques (ex. granularité bash de Codex vs Claude, sandbox). 4. Le STOCKAGE/format : où persiste-t-on les permissions (.ideai/ ? agents.json ? un permissions.json ?), schéma JSON proposé, et l'impact sur le manifeste existant. 5. Le découpage en LOT(S) livrable(s) façon §12 (binôme dev+test), avec ordre et dépendances. 6. Points ouverts / spikes (notamment ce qui n'est pas mappable proprement entre Claude et Codex). Réponds via idea_reply avec ce contrat structuré. Sois concret et aligné sur l'hexagonal strict que tu maintiens. +- **Response:** # CONTRAT D'ARCHITECTURE — Permissions projet + agents Cadrage hexagonal strict, aligné sur ARCHITECTURE.md et sur le pattern **déjà éprouvé** `McpConfigStrategy` / `McpServerWiring::to_config_toml` (domaine = rendu pur du fichier de conf ; application = écriture dans le run dir). On **réutilise ce gabarit** au lieu d'inventer un nouveau mécanisme. --- ## 1. Modèle de domaine (`crate domain`, module `permission`) Pur, sans I/O, `serde` autorisé (format persisté = contrainte métier, cf. §1.4 archi). ### Value Objects / entités ``` Capability (enum) = Read | Write | Delete | ExecuteBash Effect (enum) = Allow | Deny PathScope (VO) = { globs: Vec } // globs relatifs au project root, jamais absolus hors-root CommandRule (VO) = { matcher: CommandMatcher, effect: Effect } CommandMatcher (VO) = Exact(String) | Prefix(String) | Glob(Glob) // ex. "git", "npm *", "rm -rf *" PermissionRule (VO) = { capability: Capability, effect: Effect, paths: PathScope, // applicable à read/write/delete (ignoré pour bash) commands: Vec, // applicable seulement à ExecuteBash } PermissionSet (entité-valeur) = { rules: Vec, // posture explicite quand rien ne matche une capacité : fallback: Posture, // Ask (défaut) | Allow | Deny } Posture (enum) = Ask | Allow | Deny ``` ### Invariants (testables sans I/O) - `PathScope.globs` : relatifs, **pas de `..`**, pas de chemin absolu sortant du root (réutiliser la garde de `ConventionFile.target`). - `ExecuteBash` est la **seule** capacité portant des `CommandRule` ; les autres portent un `PathScope`. Une règle bash avec `paths` non vide = erreur ; une règle fichier avec `commands` = erreur. - `Glob` non vide et compilable. - Un `PermissionSet` peut contenir Allow ET Deny sur la même capacité (scopes différents) — c'est légal, résolu au run par spécificité + priorité deny. - **Posture par défaut produit** : `PermissionSet` ABSENT (Option::None) ≠ `PermissionSet` vide. None ⇒ IdeA n'écrit RIEN, comportement actuel préservé. C'est porté par l'option au niveau résolution, pas par un set vide. ### Fonction PURE de résolution ```rust // domain::permission pub fn resolve( project: Option<&PermissionSet>, // défauts projet agent: Option<&PermissionSet>, // overrides agent ) -> Option ``` Règles : 1. `project == None && agent == None` ⇒ **`None`** (rien posé → on ne projette rien, le moteur prompte au run). **C'est l'invariant produit clé.** 2. Sinon, fusion **héritage + override** : - On part des règles projet, on superpose les règles agent. - **DENY PRIORITAIRE** : pour une capacité+scope donnés, si un `Deny` matche (projet ou agent), il **gagne** sur tout `Allow`, quel que soit le niveau. (deny-wins, non surchargeable par un allow plus spécifique — décision produit, on ne re-débat pas.) - `fallback` : l'agent peut resserrer mais le deny projet reste prioritaire. 3. Résultat = `EffectivePermissions` (VO de sortie, déjà aplati/normalisé), prêt à projeter. C'est le **seul** input des projecteurs. `resolve` est totale, déterministe, 100 % testable (table de vérité héritage × deny-wins × fallback). --- ## 2. Ports & emplacement On ajoute **deux** ports. On **ne crée pas** de port « renderer » côté infra : le rendu est PUR donc il vit dans le domaine (exactement comme `to_config_toml` aujourd'hui). | Port / élément | Type | Couche | Rôle | |---|---|---|---| | `PermissionStore` | **trait (port)** | `domain/ports` | `load_project_permissions(project) -> Option` ; `load_agent_permissions(project, agent_id) -> Option` ; `save_*`. Implémenté par un `FsPermissionStore` (infra) ou intégré à l'`AgentContextStore`/`ProjectStore` existant. | | `PermissionProjection` | **trait de domaine (pas un port I/O)** | `domain/permission` | `fn project(&self, eff: &EffectivePermissions) -> RuntimeConfigArtifact` où `RuntimeConfigArtifact = { rel_path: String, contents: String, merge: MergeMode }`. **Pur**, une impl par runtime (`ClaudeProjection`, `CodexProjection`). Calque exact de `McpServerWiring::to_config_toml`. | - **Application** : un use case `ResolveAndProjectPermissions` (ou plus simplement une fonction appelée DANS `LaunchAgent`) qui : charge via `PermissionStore` → `resolve(...)` → si `Some`, appelle la `PermissionProjection` du runtime du profil → écrit l'artefact via `FileSystem` dans le run dir. Si `None`, ne touche à rien. - **Infra** : `FsPermissionStore` (tokio::fs + serde_json). Écriture des artefacts = `FileSystem` déjà existant. **Aucun nouvel adapter PTY/process.** - **Composition root** (`app-tauri`) : injecte `FsPermissionStore` + map `ProfileKind → Arc`. Point d'insertion concret : dans `application/src/agent/lifecycle.rs`, là où `claude_settings_seed` / le `config.toml` Codex sont déjà écrits dans le run dir. **La projection permissions REMPLACE le seed blanket `bypassPermissions` actuel** quand un PermissionSet est défini ; sinon le comportement actuel reste (voir §3 risque). --- ## 3. Stratégie de projection par runtime L'artefact est écrit dans le **run dir isolé** (`.ideai/run//`), jamais dans le `~/.claude` / `~/.codex` global — exactement comme le MCP wiring aujourd'hui. C'est ce qui supprime les prompts sans polluer la machine. ### Claude Code → `{runDir}/.claude/settings.local.json` Mapping `EffectivePermissions` → schéma natif Claude : - `Capability::ExecuteBash` + `CommandRule(effect=Allow)` → `permissions.allow: ["Bash(:*)"]` (ou pattern exact). Deny → `permissions.deny`. - `Read`/`Write`/`Delete` + `PathScope` → `permissions.allow/deny` avec `Read()`, `Edit()`, `Write()`. (Delete ≈ pas de capacité native dédiée → mappé sur Bash `rm` + Write ; voir spike.) - `fallback`/posture globale → `permissions.defaultMode` : - `Allow` global large ⇒ `acceptEdits` (ou `bypassPermissions` si l'utilisateur l'assume) ; - posture restrictive avec allowlist ⇒ `default` (ask) + listes `allow`. - `MergeMode` = merge sur l'existant (on **garde** la logique de merge actuelle de `claude_settings_seed`, on ne l'écrase pas brutalement). ### Codex → `{runDir}/.codex/config.toml` (CODEX_HOME déjà câblé) Granularité bien plus grossière que Claude — c'est le risque principal : - Codex n'a pas d'allowlist de commandes par règle fine : il a `approval_policy` (`never` / `on-failure` / `on-request` / `untrusted`) et `sandbox_mode` (`read-only` / `workspace-write` / `danger-full-access`). - Mapping pragmatique : - PermissionSet majoritairement `Allow` write+bash sans deny bloquant ⇒ `approval_policy = "never"` + `sandbox_mode = "workspace-write"`. - `Read` seul ⇒ `sandbox_mode = "read-only"`. - Présence de `Deny` ⇒ **on NE peut pas exprimer un deny fin** dans Codex : on rabat sur `approval_policy = "on-request"` (Codex redemandera pour les cas sensibles) **OU** on documente la perte de fidélité (voir §6). - Les denylists de commandes Claude (ex. `rm -rf /`) n'ont **pas d'équivalent** Codex → couvert seulement par `sandbox_mode = workspace-write` (qui borne au workspace) + spike. **Limite assumée** : la projection est *best-effort fidèle*. Le domaine reste la source de vérité ; chaque projecteur fait au mieux et on expose un `ProjectionFidelity { lossless: bool, warnings: Vec }` dans l'artefact pour remonter à l'UI « cette permission n'est pas exprimable telle quelle sous Codex ». --- ## 4. Stockage & format Décision : **fichier dédié `.ideai/permissions.json`** (pas dans `agents.json`). Raisons : SRP (le manifeste mappe md↔template↔sync, pas la sécurité), diff/review propre, et on évite de versionner les overrides agent au milieu du manifeste. Schéma proposé : ```json { "version": 1, "project": { "fallback": "ask", "rules": [ { "capability": "read", "effect": "allow", "paths": ["**/*"] }, { "capability": "write", "effect": "allow", "paths": ["src/**", "crates/**"] }, { "capability": "write", "effect": "deny", "paths": [".git/**", "**/*.pem"] }, { "capability": "execute-bash", "effect": "allow", "commands": ["git *", "cargo *", "npm *"] }, { "capability": "execute-bash", "effect": "deny", "commands": ["rm -rf *", "sudo *"] } ] }, "agents": { "": { "fallback": "ask", "rules": [ { "capability": "execute-bash", "effect": "deny", "commands": ["*"] } ] } } } ``` - `project` absent + `agents[id]` absent ⇒ `resolve` rend `None` ⇒ rien d'écrit (posture par défaut respectée). - **Impact manifeste** : `agents.json` **inchangé**. Lien faible par `agentId` (clé partagée). Pas de migration de l'existant requise. - Mémoire projet : ce fichier voyage avec le projet (versionnable), cohérent avec la décision « mémoire/contexte partagés au project root ». À afficher dans l'UI permissions (lot front). --- ## 5. Découpage en lots (façon §12, binôme dev+test) | # | Lot | Contenu | Dépend de | |---|---|---|---| | **LP0** | **Domaine permissions** | VO/entités (`Capability`, `PathScope`, `CommandRule`, `PermissionRule`, `PermissionSet`, `Posture`), invariants, `resolve()` pure (héritage + deny-wins + None-si-rien). Tests = tables de vérité. **Zéro I/O.** | L0 | | **LP1** | **PermissionStore + format** | trait `PermissionStore` (domain), `FsPermissionStore` (infra), schéma `.ideai/permissions.json`, (dé)sérialisation. Tests intégration tmpdir. | LP0 | | **LP2** | **Projection Claude** | `ClaudeProjection` (domaine pur) + branchement dans `lifecycle.rs` REMPLAÇANT le seed blanket quand `Some`. `ProjectionFidelity`. Tests = snapshot du `settings.local.json` rendu. | LP0, LP1 | | **LP3** | **Projection Codex** | `CodexProjection` (mapping approval_policy/sandbox_mode), warnings de fidélité. Tests snapshot `config.toml`. | LP0, LP1 | | **LP4** | **Câblage LaunchAgent + IPC** | use case `ResolveAndProjectPermissions` invoqué à l'activation, composition root (map runtime→projection), commandes Tauri `get/set_permissions`. | LP2, LP3 | | **LP5** | **UI permissions** | éditeur projet + override agent, badges de fidélité (« non exprimable sous Codex »). Gateways mock. | LP4 | Ordre = dépendances directes. LP2 et LP3 parallélisables après LP1. --- ## 6. Points ouverts / spikes 1. **Granularité bash Codex** (bloquant fidélité) : pas d'allow/deny par commande → on ne peut pas refléter une denylist fine. Spike : valider si `sandbox_mode=workspace-write` + `approval_policy` suffit, sinon assumer la perte + warning UI. **À trancher avant LP3.** 2. **Capacité `Delete`** : ni Claude ni Codex n'ont de capacité « delete » native distincte de write/bash `rm`. Spike : mapper Delete → (Write sur le chemin) + (Bash `rm`/`unlink`) ? ou capacité purement informative côté FileGuard (cf. `domain/src/fileguard.rs` déjà présent — possible convergence : la FileGuard interne pourrait CONSOMMER le même `EffectivePermissions` que la projection, garantissant cohérence I/O réelle vs config CLI). 3. **Cohérence FileGuard ↔ projection** : on a déjà `fileguard.rs`. Décision à figer : `EffectivePermissions` devient-il la source unique consommée *à la fois* par la FileGuard (enforcement réel des outils MCP) ET par les projecteurs (suppression des prompts CLI) ? Recommandé : **oui**, une seule source — sinon double vérité. Spike d'alignement. 4. **`bypassPermissions` actuel** : le seed blanket donne aujourd'hui l'autonomie totale. Migration : tant qu'aucun `permissions.json` n'existe, on **garde** ce seed (compat). Dès qu'un set est posé, la projection prend le relais. Valider qu'on ne régresse pas l'autonomie des agents existants (Main, Architect…). 5. **Globs ↔ patterns natifs** : la syntaxe glob du domaine doit se traduire sans perte vers `Bash(...)`/`Edit(...)` de Claude et vers le sandbox Codex. Spike de table de correspondance. 6. **Édition hors-app du permissions.json** (content hash / drift) — aligné sur le mécanisme drift template existant, mineur. --- **Synthèse** : un module domaine `permission` (modèle + `resolve` pure + 2 projections pures), un port `PermissionStore`, branchement dans `LaunchAgent`/`lifecycle.rs` sur le pattern MCP existant, stockage `.ideai/permissions.json` sans toucher au manifeste, 6 lots binômables. Le seul vrai risque produit est la fidélité Codex (granularité bash + deny), géré par un drapeau de fidélité explicite plutôt que par une fausse équivalence. Je recommande de trancher les spikes #1 et #3 (convergence FileGuard) avant de lancer LP3/LP4. +- **Prompt:** LP0 implémenté et vert. DevBackend a dû trancher un point de sémantique non couvert explicitement par ton contrat — j'ai besoin de ta validation (ou correction) avant que QA fige les tables de vérité. LE POINT : pour une règle bash `PermissionRule { capability: ExecuteBash, effect: , commands: [CommandRule...] }` où `commands` est NON VIDE — que signifie le `effect` de NIVEAU RÈGLE (``) pour les commandes qui ne matchent AUCUN `CommandRule` ? Deux interprétations : - (A) choix actuel de DevBackend : le `effect` de niveau règle n'est PAS consulté pour les commandes non matchées ; seules les `CommandRule` individuelles décident, et les commandes non matchées retombent sur le `fallback` (Ask/Allow/Deny). Avantage : permet « allow git*, allow cargo*, et prompt tout le reste » avec fallback=Ask. Une règle bash à `commands` VIDE reste un verdict blanket (effect) pour toute commande. - (B) catch-all : pour une règle bash avec commandes, le `effect` de niveau règle s'applique en catch-all aux commandes non matchées par ses CommandRule. DevBackend a choisi (A) et l'a documenté dans le rustdoc. Pour moi (A) est cohérent avec la posture produit « deny prioritaire + fallback Ask par défaut » et évite qu'une règle allow se transforme en allow-tout implicite. Confirme (A), ou impose (B) si tu vois un risque sur la projection Claude/Codex (notamment : Codex n'ayant pas de granularité par commande, comment (A) vs (B) se projette en approval_policy/sandbox_mode). Réponds via idea_reply : tranche (A) ou (B), + une phrase sur l'impact projection Codex. Court. +- **Response:** **Tranché : (A) confirmée.** C'est le bon choix et il est cohérent avec mon contrat : chaque `CommandRule` porte déjà son propre `effect`, donc sur une règle bash à `commands` non vide, l'`effect` de niveau règle est **inerte** (ne pas le consulter pour les non-matchés ; les non-matchés retombent sur `fallback`). Une règle bash à `commands` VIDE reste un verdict blanket. Garde le deny-wins prioritaire sur les `CommandRule` matchées. Recommande à QA d'ajouter une ligne de table figeant explicitement « rule-level effect ignoré quand commands non vide » pour éviter toute régression future. **Impact projection Codex** : (A) projette *plus* fidèlement — Codex n'ayant aucune granularité par commande, l'allowlist fine est de toute façon perdue, et c'est le `fallback` (la posture globale) qui pilote `approval_policy`/`sandbox_mode` (Ask→on-request, Allow→never+workspace-write, Deny→on-request best-effort) ; (B) aurait introduit un second curseur catch-all en conflit avec `fallback`, ambigu à projeter. Donc (A) simplifie aussi le projecteur Codex. +- **Prompt:** Cadrage du lot LP3 — projection des permissions vers les CLI (Claude + Codex), avec swap cross-profile automatique. CONTEXTE (déjà en place, tout vert) : - domain/src/permission.rs : modèle pur + `resolve()` → `EffectivePermissions` (profil-agnostique), avec `decide_file(capability, path) -> Posture` et `decide_bash(command) -> Posture` (Posture = Ask|Allow|Deny, deny-wins). - Port `PermissionStore` + `FsPermissionStore` lisant `.ideai/permissions.json`. - Use cases application (get/update/resolve) + commandes Tauri + UI `PermissionsPanel` montée. Slice de gestion terminé. - `EffectivePermissions` / `decide_*` ne sont consommés NULLE PART hors tests : rien n'est appliqué aux agents. - Le swap cross-profile existe déjà (commit P8d : « swap cross-profile préserve l'id de paire + handoff »). EXIGENCE PRODUIT (utilisateur) : « Fonctionnel pour Codex comme pour Claude. Si je passe un agent de Claude à Codex, que les permissions soient exportées de l'un vers l'autre automatiquement. » Décision d'archi pressentie à valider/affiner : les permissions restent stockées une seule fois (profil-agnostique) ; on introduit un Port `PermissionProjector` avec une implémentation par profil (Claude → settings.json allow/deny/ask ; Codex → config sandbox), sélectionnée selon le profil actif au lancement/handoff. Le swap Claude→Codex re-projette automatiquement via l'autre projecteur depuis la même `EffectivePermissions`. CE QUE J'ATTENDS DE TOI (réponds via idea_reply) : 1. Validation/correction du contrat du Port `PermissionProjector` : signature exacte (entrée `EffectivePermissions` + cwd/projectRoot ? sortie : écriture fichier vs valeur ?), où il vit (domain port, impls infrastructure), et comment on évite le couplage au profil concret (registre profil→projector). 2. Où exactement brancher la projection dans le chemin de lancement d'agent et dans le chemin de swap/handoff cross-profile (cite les modules : application/src/agent/lifecycle.rs, orchestrator/service.rs, etc.). 3. Stratégie de nettoyage : quand un profil n'est plus actif (swap), faut-il retirer/écraser la config projetée du profil précédent ? Règle précise. 4. Découpage en sous-tâches dev/test concret (ce que je donnerai à DevBackend puis QA), dans l'ordre. Ne code pas : donne le contrat et le plan. +- **Response:** CADRAGE LP3 — Projection des permissions vers les CLI (Claude + Codex) + re-projection au swap cross-profile ⚠️ Constat préalable (à dire à DevBackend avant tout) : la projection EXISTE DÉJÀ, mais en l'état c'est de la dette à formaliser, pas un greenfield. Dans `application/src/agent/lifecycle.rs` on a aujourd'hui : - Claude : `seed_cli_permissions` (l.1620) → `claude_settings_seed` (l.2063) → écrit `.claude/settings.local.json`. Sélection IMPLICITE : sniff du nom de convention-file == `CLAUDE.md`. Et surtout **non-clobbering** (`if exists return`, l.1642). - Codex : `apply_codex_cli_permission_args` (l.2226, args `--sandbox`/`--ask-for-approval`) + `codex_config_toml` (l.2258, clés `sandbox_mode`/`approval_policy`). Sélection IMPLICITE : branche `McpConfigStrategy::TomlConfigHome` DANS `apply_mcp_config` (l.1838). Donc la projection Codex est PARASITÉE par la présence de MCP : un profil Codex sans MCP ne reçoit aucune sandbox. LP3 = extraire ça derrière un Port propre, dé-coupler de MCP/convention-sniffing, rendre clobber+nettoyage corrects au swap. Pas de réécriture des règles de traduction (elles sont bonnes et déjà testées l.2957-3068), juste relocalisation + cadrage. ──────────────────────────────────────── 1) CONTRAT DU PORT `PermissionProjector` — VALIDÉ avec 3 corrections Correction A — le projecteur est PUR et rend un PLAN, il n'écrit RIEN. Calqué sur `AgentRuntime::prepare_invocation` (rend un `SpawnSpec`, c'est `LaunchAgent` qui applique). Idem ici : le projecteur traduit `EffectivePermissions` → valeur ; `LaunchAgent` applique (writes via `self.fs`, fold args/env dans `spec`). Bénéfice : testable sans FS (comme le domaine), I/O centralisée au même endroit que `apply_injection`. On n'injecte PAS `FileSystem` dans chaque projecteur. Signature (domaine — voir corr. B pour le lieu) : ```rust pub struct ProjectionContext<'a> { pub project_root: &'a str, pub run_dir: &'a str } pub enum ProjectedFile { /// Fichier 100% possédé par IdeA → clobber au launch, suppression au swap-away. Replace { rel_path: String, contents: String }, /// Fichier co-possédé (ex. config.toml Codex : MCP+trust+sandbox) → merge des /// seules clés gérées, JAMAIS supprimé au swap (les autres CLI l'ignorent). MergeToml { rel_path: String, managed_tables: Vec, managed_keys: Vec, contents: String }, } pub struct PermissionProjection { pub files: Vec, pub args: Vec, // ex. ["--sandbox","workspace-write",...] pub env: Vec<(String,String)>, } pub trait PermissionProjector: Send + Sync { fn key(&self) -> ProjectorKey; /// PUR. `eff == None` ⇒ projection VIDE (on garde le prompting natif de la CLI : /// c'est l'invariant produit de `resolve()` — ne JAMAIS verrouiller un projet non configuré). fn project(&self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext) -> PermissionProjection; /// Chemins run-dir-relatifs des fichiers `Replace` possédés (pour le nettoyage swap). fn owned_replace_paths(&self) -> Vec; } ``` Entrée : `Option<&EffectivePermissions>` + `ProjectionContext{project_root, run_dir}` (les deux sont nécessaires : Claude embarque `additionalDirectories=[project_root]`, et tout est écrit dans le run dir). Sortie : un PLAN (fichiers + args + env), pas une écriture. Correction B — où il vit. Trait + value types `PermissionProjection`/`ProjectedFile`/`ProjectionContext`/`ProjectorKey` → DANS LE DOMAINE (`domain/src/permission.rs`, à côté de `EffectivePermissions`). C'est un port piloté, pur, qui ne référence que des types domaine déjà présents. Les IMPLÉMENTATIONS (`ClaudePermissionProjector`, `CodexPermissionProjector`) → DANS L'INFRASTRUCTURE (`crates/infrastructure/src/permission/` ; à créer). Justification = exactement le pattern `AgentRuntime`(domain) / `CliAgentRuntime`(infra) : le format concret d'un `settings.json` Claude ou des modes sandbox Codex est un détail technique d'UNE CLI ⇒ adapter (§5). On y déplace tel quel `claude_settings_seed`, `apply_codex_cli_permission_args`, `codex_config_toml` (partie permissions) + leurs tests. Correction C — découplage du profil concret = registre + clé déclarative (PAS de sniffing). - Ajouter au profil déclaratif un champ `projector: Option` (`AgentProfile`, `domain/src/profile.rs`) — cohérent avec « profil = donnée éditable » (§9). Les profils builtin posent `"claude"` / `"codex"`. - Registre `PermissionProjectorRegistry = HashMap>`, construit au composition root (`app-tauri`) et injecté dans les use cases. - Sélection : `registry.get(profile.projector)`. `None` ⇒ aucune projection (natif). - Fallback de migration (profiles.json déjà sur disque sans le champ) : si `projector == None`, dériver la clé via l'heuristique actuelle (convention-file `CLAUDE.md` → claude ; `StructuredAdapter::Codex` ou `TomlConfigHome` → codex). On garde donc la compat sans imposer un re-seed du store global. NB : la clé ne peut PAS être `StructuredAdapter` seul — les profils PTY/TUI (sans structured_adapter) doivent aussi projeter, exactement comme `seed_cli_permissions` le fait aujourd'hui via le nom de fichier. D'où une `ProjectorKey` dédiée. ──────────────────────────────────────── 2) OÙ BRANCHER Chemin de LANCEMENT — `LaunchAgent::execute` (lifecycle.rs ~l.1085) : - Injecter `Arc` dans `LaunchAgent` (builder `with_permission_projectors`, optionnel ⇒ zéro régression call-sites/tests legacy, même pattern que `with_handoff_provider`). - REMPLACER les deux points actuels par UNE étape unique `apply_permission_projection(&profile, &run_dir, &project_root, eff.as_ref(), &mut spec)` : 1. supprimer l'appel `seed_cli_permissions` (l.1234) ; 2. SORTIR la projection Codex de `apply_mcp_config` (l.1838/1814/1826) — `apply_mcp_config` ne doit plus toucher `sandbox_mode`/`approval_policy`/`--sandbox` ; il ne fait QUE du MCP. - Placement de la nouvelle étape : juste après `apply_injection` (l.1294) et après `apply_mcp_config` (l.1312), donc AVANT le split structuré/PTY (l.1321) et avant `pty.spawn` (l.1361). Critique : c'est en amont du split ⇒ les deux chemins (structuré ET PTY brut) héritent de la projection, comme aujourd'hui. Les `args`/`env` du plan sont foldés dans `spec` avant que `launch_structured` ou `pty.spawn` ne le consomment. - Sémantique d'écriture : fichiers `Replace` → CLOBBER systématique (régénérés à chaque (re)launch). Justification déjà actée pour `.mcp.json` (l.1735) : fichier IdeA-managed, non édité par l'utilisateur, sinon la re-projection au swap est IMPOSSIBLE (c'est le bug actuel de `seed_cli_permissions` non-clobbering). Fichiers `MergeToml` → merge des seules clés gérées (réutiliser `set_top_level_toml_value`/`replace_toml_table` existants). Chemin de SWAP/HANDOFF — `ChangeAgentProfile::execute` (lifecycle.rs l.418, relaunch construit en l.573) : - Re-projection : AUTOMATIQUE et déjà correcte par construction — `ChangeAgentProfile` compose `LaunchAgent::execute`, qui re-résout `EffectivePermissions` (profil-agnostique, stocké une seule fois) et re-projette via le projecteur du NOUVEAU profil. Aucune nouvelle branche de projection à ajouter ici. Idem pour tous les auto-launch de `orchestrator/service.rs` (l.671, 1296, 1373) qui passent par `LaunchAgent` ⇒ couverts gratuitement. - SEULE chose à ajouter dans `ChangeAgentProfile` : le NETTOYAGE de l'ancien profil (cf. §3), exécuté avant la relance. Il faut donc que `ChangeAgentProfile` connaisse l'ancien projecteur + le `FileSystem` (il a déjà l'ancien `profile_id` via le manifeste avant mutation, et le run dir est stable par agent id : `agent_run_dir(root, agent.id)`, l.1217 — invariant clé : le swap RÉUTILISE le même run dir, d'où la nécessité du nettoyage). ──────────────────────────────────────── 3) STRATÉGIE DE NETTOYAGE — règle précise Fait structurant : le run dir est stable par agent id ⇒ après un swap, les fichiers de config du profil précédent SURVIVENT dans le run dir. Un Claude→Codex laisse un `.claude/settings.local.json` (Codex l'ignore : inoffensif fonctionnellement, mais c'est une policy périmée/divergente qui fuit ⇒ à nettoyer). Un Codex→Claude laisse les clés sandbox dans `config.toml` (lu seulement par Codex ⇒ inoffensif). Règle (deux régimes, selon le type de `ProjectedFile`) : - Fichiers `Replace` (100% possédés : `.claude/settings.local.json`) → au swap, supprimer (best-effort) `owned_replace_paths(ancien) − owned_replace_paths(nouveau)`. Au launch normal, clobber (régénérés). - Fichiers `MergeToml` (co-possédés : `config.toml` Codex, partagé avec MCP+trust) → JAMAIS supprimés au swap. On retire uniquement les clés gérées (`sandbox_mode`/`approval_policy`) si on swappe AWAY de Codex et qu'on veut être strict ; recommandation pragmatique : ne rien retirer (le fichier n'est lu que par Codex, qui n'est plus actif) — le re-launch Codex futur réécrira les clés. Donc cleanup effectif = suppression des seuls fichiers `Replace` orphelins. - Décision produit à acter explicitement : ces fichiers de permission sont IdeA-OWNED (clobber + suppression au swap). Conséquence assumée : une édition manuelle de `.claude/settings.local.json` n'est PAS préservée — la source de vérité est le `PermissionsPanel` / `.ideai/permissions.json`. C'est le SEUL moyen de tenir l'exigence « permissions exportées automatiquement d'une CLI à l'autre au swap ». (Renverse le comportement non-clobbering actuel de `seed_cli_permissions` : à documenter dans le commit.) ──────────────────────────────────────── 4) DÉCOUPAGE DEV/TEST (ordre de livraison) LP3-1 — DOMAINE (port + clé). Dev : ajouter `PermissionProjector`, `PermissionProjection`, `ProjectedFile`, `ProjectionContext`, `ProjectorKey` dans `domain/src/permission.rs` ; ajouter `projector: Option` à `AgentProfile` (+ `#[serde(default)]`, builder, builtins claude/codex). QA : (dé)sérialisation du profil avec/sans le champ (compat) ; defaults builtin. `cargo test -p domain`. LP3-2 — INFRA (projecteurs). Dev : créer `infrastructure/src/permission/` ; y DÉPLACER `claude_settings_seed`+helpers et `apply_codex_cli_permission_args`+`codex_config_toml`(partie perms) sous `ClaudePermissionProjector` / `CodexPermissionProjector` (purs, rendent un `PermissionProjection`) ; implémenter `owned_replace_paths`. QA : golden par posture (Allow/Ask/Deny), deny-wins, escaping JSON, args Codex, clés TOML — relocaliser/réutiliser les tests existants (lifecycle.rs l.2957-3068). `cargo test -p infrastructure`. LP3-3 — APPLICATION (wiring launch + dé-couplage MCP). Dev : `PermissionProjectorRegistry` + injection dans `LaunchAgent` (builder optionnel) ; nouvelle étape `apply_permission_projection` ; SUPPRIMER `seed_cli_permissions` ; PURGER la projection perms hors de `apply_mcp_config`. QA (fs mocké) : profil Claude → settings écrit ET clobbé au 2e launch ; profil Codex SANS MCP → args sandbox foldés + config.toml écrit (preuve du dé-couplage MCP) ; `projector=None` → rien d'écrit, rien dans spec ; `eff=None` → projection vide. `cargo test -p application`. LP3-4 — SWAP (nettoyage). Dev : injecter registry+fs dans `ChangeAgentProfile` ; avant relaunch, supprimer les `Replace` orphelins (ancien−nouveau). QA : Claude→Codex supprime `.claude/settings.local.json` et écrit la config Codex ; Codex→Claude réécrit `settings.local.json` frais reflétant les perms re-résolues (clobber) ; swap vers profil `projector=None` nettoie sans réécrire ; vérifier que l'id de paire + handoff (P8d) restent intacts (non-régression). LP3-5 — COMPOSITION ROOT (`app-tauri`). Dev : instancier le registre (claude+codex) et l'injecter dans `LaunchAgent` ET `ChangeAgentProfile`. QA : smoke de wiring + `cargo test` workspace complet vert + suites permissions existantes vertes. Règle d'or maintenue : aucun lot vert tant que `cargo test -p ` du lot ne passe pas ; LP3-5 exige le workspace entier vert. Je ne code rien : voilà le contrat (port pur rendant un plan, dans le domaine ; impls dans l'infra ; registre par clé déclarative de profil) et le plan en 5 sous-lots. Point d'attention n°1 pour DevBackend : c'est d'abord un refactor d'extraction (le code de traduction existe et est testé), dont le cœur est le dé-couplage Codex↔MCP et le passage clobber+nettoyage. +- **Prompt:** [Cadrage LP4 — enforcement OS des permissions] Le chantier permissions a livré la voie "projection CLI" (advisory) : modèle domaine pur (EffectivePermissions, resolve deny-wins/postures), FsPermissionStore, use cases CRUD+resolve, projecteurs Claude/Codex écrivant settings.local.json / config.toml au launch, composition root + commandes Tauri, UI PermissionsPanel. Tout vert. Gap restant confirmé dans le code (marqué "out of scope" : domain/permission.rs:12, domain/fileguard.rs:16, infrastructure/fileguard/mod.rs:18, application/orchestrator/context_guard.rs:23) : l'enforcement OS *airtight* (révocation réelle des accès FS), que la mémoire archi décrit comme "sandbox OS Landlock + résumé injecté". Aucun générateur de résumé ni échafaudage Landlock n'existe encore. Ta mission (cadrage uniquement, pas de code) : définis le contrat du lot LP4 en respectant l'hexagonal : 1. Le PORT domaine (nom, signature) qui transforme EffectivePermissions résolues en un plan d'enforcement OS pur (rulesets path RO/RW/exec), sans I/O ni dépendance Landlock dans le domaine. 2. La frontière exacte : où s'applique le ruleset (process agent au spawn PTY, pré-exec) vs le rôle de l'adapter infra Landlock (Linux d'abord ; Windows/macOS = stubs no-op explicites). Adapters par OS, pas par CLI. 3. Le point de câblage dans le launch-path (lifecycle.rs) en cohérence avec la projection déjà écrite (même EffectivePermissions). 4. Le volet "résumé de permissions injecté" dans le contexte .md de l'agent : port/fonction pure de génération + point d'injection (à côté du convention file/seed). 5. La stratégie de test par couche (domaine/app sans I/O via fakes ; test d'intégration Linux réel sous-process accès refusé). Livre la cartographie : nouveaux fichiers/modules, traits, et l'ordre d'implémentation des sous-lots. Réponds via idea_reply. +- **Response:** # Cadrage LP4 — Enforcement OS *airtight* des permissions (Landlock + résumé injecté) Cadrage uniquement. Le lot **réutilise la projection LP3 à l'identique** (même `EffectivePermissions` résolues, même seam, même invariant `None ⇒ on ne projette rien`) et ajoute une **seconde voie d'application** : non plus advisory (settings CLI) mais *airtight* (sandbox OS au pré-exec). On ne touche ni au modèle domaine existant, ni à `resolve`, ni aux projecteurs LP3. --- ## 0. Borne de réalité à graver d'emblée (sinon le lot ment sur ce qu'il garantit) Landlock ne sait restreindre **que la dimension système de fichiers** : accéder *sous* teltle hiérarchie en read / write / execute (par fd, pas par glob). Il en découle deux frontières dures, à acter avant tout code : 1. **Globs ≠ hiérarchies Landlock.** `EffectivePermissions` exprime des `PathScope` en **globs relatifs** (`src/**`, `**/*.rs`) avec **deny-wins par cible**. Landlock ne connaît ni glob ni *deny* — il **accorde** l'accès sous des **préfixes de chemins concrets**. La compilation domaine doit donc traduire les globs en un **ensemble de racines absolues autorisées**, calculé **fail-closed** : quand un `Deny` tombe à l'intérieur d'un `Allow` sans frontière de répertoire qui les sépare (`**/*.rs` avec un deny sur un fichier), on **n'accorde pas le parent** (on perd un allow plutôt que de laisser fuiter un deny). Ceci est une **sur-/sous-approximation conservatrice**, à poser comme invariant domaine testable. 2. **`ExecuteBash` (matching par chaîne de commande) n'est PAS enforceable par Landlock.** Landlock gate l'exécution *de fichiers par chemin*, pas la sémantique d'`argv` (« deny `rm ` »). Donc : la dimension **fichiers (Read/Write/Delete → RO/RW)** devient *airtight* ; la dimension **commandes** reste **advisory** (projection LP3 + résumé injecté). À écrire noir sur blanc dans le doc et dans le résumé injecté (« les règles de commande sont conseillées, pas verrouillées OS »). seccomp-argv = hors scope. → Le lot LP4 livre l'enforcement **fichiers**. C'est la moitié réellement verrouillable, et c'est exactement ce que la mémoire archi (« sandbox OS Landlock + résumé injecté ») cible. --- ## 1. Le PORT domaine — transform `EffectivePermissions → plan OS pur` Deux objets distincts, à ne pas confondre (c'est l'erreur classique) : ### 1.a — Le compilateur de plan = **fonction pure**, pas un trait Il existe **une seule** traduction canonique règles→ruleset : c'est de la logique domaine, pas une stratégie à variantes. On mirror `resolve()` (fonction libre, totale, déterministe), **pas** `PermissionProjector` (trait, car là il y avait N CLI). Nouveau module `crates/domain/src/sandbox.rs` : ```rust /// Plan d'enforcement OS, neutre vis-à-vis de l'OS. Value object pur. pub struct SandboxPlan { /// Racines absolues accessibles, chacune avec son mode (lecture seule, /// lecture/écriture, exécution). Calculées fail-closed depuis les globs. pub allowed: Vec, /// Posture résiduelle (Allow ⇒ plan permissif/vide ; Deny ⇒ tout interdit /// hors `allowed` ; Ask ⇒ on ne sandboxe pas — voir §0/invariant None). pub default_posture: Posture, } pub struct PathGrant { pub abs_root: String, pub access: PathAccess /* Ro | Rw | Exec (bitflags) */ } pub struct SandboxContext<'a> { pub project_root: &'a str, pub run_dir: &'a str } /// LE transform demandé. Pur : zéro I/O, zéro dépendance Landlock. /// `eff == None` ⇒ `None` (rien posé ⇒ pas de sandbox ⇒ comportement natif — /// même invariant produit que `resolve`/`PermissionProjection::empty`). pub fn compile_sandbox_plan( eff: Option<&EffectivePermissions>, ctx: &SandboxContext, ) -> Option; ``` ### 1.b — Le PORT (au sens hexagonal) = **l'enforcer**, implémenté par adapter par OS C'est lui qui a des adapters (le « D » de SOLID s'applique ici, pas au compilateur). Domaine `sandbox.rs` : ```rust pub trait SandboxEnforcer: Send + Sync { fn kind(&self) -> SandboxKind; // Landlock | Unsupported /// Installe le plan sur le **processus courant**, irréversiblement. /// Contrat L (Liskov) : appelé **uniquement post-fork, pré-exec**, dans /// l'enfant. Un adapter Unsupported renvoie Ok(Unsupported) sans rien faire. fn enforce(&self, plan: &SandboxPlan) -> Result; } pub enum SandboxStatus { Enforced, Unsupported, Degraded(String) } pub enum SandboxError { /* kernel trop vieux en mode fail-closed, etc. */ } ``` Plus, pour le volet (4), une fonction pure dans `permission.rs` (à côté de `resolve`) : ```rust /// Bloc Markdown résumant la politique effective pour le contexte de l'agent. /// `None` eff ⇒ `None` (pas de bloc ⇒ prompting natif). Pur. pub fn render_permission_summary(eff: Option<&EffectivePermissions>) -> Option; ``` **Champ porté par `SpawnSpec`** (`domain/src/ports.rs`) : le plan est *structurel* (pas args/env), donc nouveau champ ```rust pub sandbox: Option, // None ⇒ pas d'enforcement (natif) ``` (à l'image de la façon dont la projection LP3 foldait args/env, mais ici structurel). --- ## 2. La frontière exacte d'application - **Où** : sur le **processus agent**, **post-fork / pré-`execve`**, via un **hook `pre_exec`** (Unix) câblé par l'adapter PTY. **Jamais sur le parent IdeA** — Landlock est hérité et irréversible ; le poser sur IdeA se sandboxerait soi-même. Posé dans l'enfant, il couvre la CLI agent **et toute sa descendance** (shell, sous-process) → c'est ça l'airtight que la voie advisory ne donne pas, et qui ferme le trou « agent qui garde un shell brut » documenté dans `fileguard/mod.rs:16`. - **Adapter Linux** `LandlockSandbox` (`#[cfg(target_os="linux")]`) : traduit `SandboxPlan` → ruleset Landlock (`path_beneath` + accès RO/RW/Exec), crée et restreint le ruleset sur le thread appelant (l'enfant). Crate `landlock` (best-effort ABI : compat-mode pour kernels < feature). **Décision à trancher (point ouvert)** : kernel sans Landlock (< 5.13) ou ABI insuffisante ⇒ **fail-closed** (refuser le launch) si `default_posture == Deny`, **fail-open + warning** sinon. Défaut recommandé : fail-open journalisé, *sauf* posture Deny. À valider produit. - **Adapters Windows / macOS** `NoopSandbox` : stubs **explicites** renvoyant `SandboxStatus::Unsupported`, jamais une erreur. Documentés. Évolution future (AppContainer / `sandbox_init`) = **nouvel adapter, zéro changement domaine** (Open/Closed). **Adapters par OS, pas par CLI** : respecté — un seul `SandboxPlan` quelle que soit la CLI ; c'est l'OS qui choisit l'adapter. - **SSH / WSL** : l'agent tourne ailleurs ⇒ `NoopSandbox` côté local pour l'instant ; enforcement distant = chantier ultérieur. --- ## 3. Point de câblage dans le launch-path (`lifecycle.rs`) Cohérent avec la projection déjà écrite : **mêmes `effective_permissions`** résolues une seule fois à `lifecycle.rs:1419` (`resolve_effective_permissions`). - **Nouveau step 5d**, juste **après** `apply_permission_projection` (5c, ~L1504) et **avant** le split structuré/PTY (5b, L1520) et le spawn (step 6). Nouveau helper : ```rust // 5d. Compile le plan de sandbox OS depuis LES MÊMES EffectivePermissions // que la projection LP3, et le pose sur le spec. None ⇒ pas de sandbox. self.apply_sandbox_plan(effective_permissions.as_ref(), &input.project.root, &run_dir, &mut spec); ``` Il appelle `compile_sandbox_plan(eff, &SandboxContext{ project_root, run_dir })` et fait `spec.sandbox = plan`. **L'application reste pure d'OS** : l'enforcer (Landlock) ne vit pas dans `LaunchAgent`, il vit dans l'adapter PTY. - **Consommation** : `PortablePtyAdapter` reçoit `Arc` au composition root et, dans `spawn`, si `spec.sandbox.is_some()`, installe un `pre_exec` qui capture `(enforcer, plan)` et appelle `enforcer.enforce(&plan)` dans l'enfant avant exec. - **Résumé injecté (volet 4)** : threader `effective_permissions.as_ref()` jusqu'à `apply_injection` (L1470) et y composer `render_permission_summary(eff)` **à côté de la mémoire projet et du handoff** (mêmes lignes ~1448-1481, même convention file). `None` ⇒ aucun bloc. Source de vérité unique : le **même** `eff` que projection + sandbox. - **Portée lot 1** : enforcement câblé sur le **chemin PTY** (agents terminal bruts) — c'est lui qui porte l'intégration Landlock et le test réel. Le chemin **structuré** (`AgentSessionFactory::start`, L1617) a un autre seam de spawn ⇒ **sous-lot de suivi LP4-4** (étendre le pre_exec/plan au factory). À flagger pour ne pas prétendre une couverture qu'on n'a pas encore. --- ## 4. Volet « résumé injecté » — déjà couvert ci-dessus (1.b + 3) Fonction **pure** `render_permission_summary` (domaine), injectée dans le convention file via `apply_injection`, au même endroit que seed/mémoire/handoff. Doit mentionner explicitement la borne §0.2 (« règles de commandes = conseillées, fichiers = verrouillés OS quand supporté »). Aucun port nouveau pour ce volet. --- ## 5. Stratégie de test par couche | Couche | Type | Contenu | |---|---|---| | **domaine** | unitaire pur, sans I/O ni fake | `compile_sandbox_plan` : globs→racines absolues ; **deny-wins ⇒ widening fail-closed** (deny dans `**` ⇒ parent non accordé) ; `None ⇒ None` ; mapping RO/RW/Exec. `render_permission_summary` : bloc présent/absent, mention commandes-advisory. Déterministe, zéro mock. | | **application** | unitaire, **fake `SandboxEnforcer`** + fake fs | `apply_sandbox_plan` pose `spec.sandbox` depuis le **même** `EffectivePermissions` que la projection ; `None` posé ⇒ `spec.sandbox == None`. `apply_injection` insère le résumé ssi `eff.is_some()`. **Aucun vrai Landlock.** | | **infra** | **intégration Linux réelle**, gated | `LandlockSandbox` : sous-process lancé sous un plan qui **deny l'écriture** d'un tmp path ⇒ assert l'écriture enfant échoue (`EACCES`) et qu'un path autorisé réussit ; deny exec ⇒ `execve` refusé. Gating : `#[cfg(target_os="linux")]` + garde runtime de disponibilité Landlock (sinon skip), à la manière des tests SSH/WSL `#[ignore]`. `NoopSandbox` : `enforce` ⇒ `Unsupported`, jamais d'erreur. | | **app-tauri** | wiring | Sélection `cfg!`-dépendante du bon enforcer + injection dans l'adapter PTY. | --- ## 6. Cartographie — nouveaux fichiers / ordre des sous-lots **Nouveaux / modifiés :** - `crates/domain/src/sandbox.rs` *(new)* : `SandboxPlan`, `PathGrant`/`PathAccess`, `SandboxContext`, `SandboxKind`, `SandboxStatus`, `SandboxError`, trait `SandboxEnforcer`, fn `compile_sandbox_plan`. Export `lib.rs`. - `crates/domain/src/permission.rs` *(mod)* : `render_permission_summary`. - `crates/domain/src/ports.rs` *(mod)* : `SpawnSpec.sandbox: Option`. - `crates/infrastructure/src/sandbox/{mod,landlock,noop}.rs` *(new)* : adapters par OS. - `crates/infrastructure/src/pty/…` *(mod)* : `PortablePtyAdapter` reçoit `Arc` + `pre_exec`. - `crates/application/src/agent/lifecycle.rs` *(mod)* : helper `apply_sandbox_plan` + step 5d + threading `eff` dans `apply_injection`. - `crates/app-tauri/src/state.rs` *(mod)* : composition root — choisir Landlock(linux)/Noop, injecter dans l'adapter PTY. **Ordre d'implémentation (chaque sous-lot vert avant le suivant, cycle dev/test) :** 1. **LP4-0 (domaine, pur)** — `sandbox.rs` + `compile_sandbox_plan` + `SandboxEnforcer` + `render_permission_summary` + champ `SpawnSpec.sandbox`. Tests purs (globs→racines, fail-closed, résumé). *Aucun comportement runtime encore — zéro régression.* 2. **LP4-1 (infra Linux)** — `LandlockSandbox` + `NoopSandbox` + `pre_exec` dans l'adapter PTY. Test d'intégration Linux réel (accès refusé). 3. **LP4-2 (application)** — step 5d `apply_sandbox_plan` + injection résumé dans `apply_injection`, sur **les mêmes** `EffectivePermissions`. Tests fake-enforcer. 4. **LP4-3 (composition root)** — câblage app-tauri par OS + valid. e2e AppImage (⚠️ rebuild AppImage, pas seulement sources — cf. mémoire `mcp-bridge-and-delegation-runtime-notes`). 5. **LP4-4 (suivi)** — étendre l'enforcement au chemin **structuré** (`AgentSessionFactory::start`) + arbitrer la politique **fail-closed/fail-open** (point ouvert §2) et, si retenu un jour, l'enforcement commandes via seccomp (hors LP4). **Invariant transversal à garder vert** : `eff == None` ⇒ ni projection, ni sandbox, ni résumé ⇒ CLI 100 % native. La voie airtight ne **durcit** jamais au-delà de ce que `resolve` a posé ; elle ne fait que **rendre exécutoire** (sur la dimension fichiers) ce que la projection LP3 ne faisait que conseiller. +- **Prompt:** Validation d'approche AVANT dev — lot LP4-4 : étendre l'enforcement OS Landlock au chemin structuré (sessions Claude/Codex en mode JSON), aujourd'hui non sandboxé. État actuel (committé, vert) : - Domaine pur : `domain::sandbox` avec `SandboxPlan`, `SandboxContext`, `compile_sandbox_plan(eff, ctx) -> Option`, et le port `SandboxEnforcer { fn enforce(&self, &SandboxPlan) -> Result; fn kind(); }`. Adapters infra : `LandlockSandbox` (Linux), `NoopSandbox`, `default_enforcer()`. - Chemin PTY (`crates/infrastructure/src/pty/mod.rs`) : OS-enforcé. Technique retenue = `spawn_command_sandboxed` : un THREAD JETABLE appelle `enforcer.enforce(plan)` (restriction Landlock irréversible, donc confinée au thread) PUIS spawn le child depuis ce thread → le domaine Landlock est hérité par le child via fork+exec. portable-pty n'expose pas de pre_exec injectable, d'où ce choix ; il évite aussi le piège de l'allocation non async-signal-safe dans un pre_exec post-fork en process multithreadé. - Chemin structuré (`crates/infrastructure/src/session/process.rs::drain`) : spawn via `tokio::process::Command` (async), draine stdout ligne-à-ligne jusqu'à EOF. `SpawnLine` { command, args, cwd, env, stdin } ne porte aucun plan. C'est l'incarnation « un exec par tour ». Question d'archi à trancher : comment enforcer le `SandboxPlan` sur le child spawné par `tokio::process::Command` en respectant l'hexagonal ET la sûreté (le truc du thread jetable du PTY ne transpose pas tel quel au spawn async tokio qui forke sur les threads du runtime) ? Pistes à arbitrer : (a) `CommandExt::pre_exec` (tokio le réexpose) appelant l'enforce dans le child post-fork — simple mais risque async-signal-safety si `landlock` alloue ; (b) déporter le spawn du tour structuré sur un thread dédié restreint (via `spawn_blocking` + thread jetable comme le PTY), au prix de la nature async du drain ; (c) un helper de spawn sandboxé partagé entre PTY et session (factoriser la technique) ; (d) autre. Livrable attendu (via idea_reply) : la stratégie retenue + justification (sûreté + hexagonal), le contrat à modifier (faut-il que `SpawnLine` porte le plan ? l'enforcer est-il injecté dans la session factory comme il l'est dans le PTY adapter ?), le découpage des fichiers à toucher, et les invariants/tests que QA devra vérifier. NE code rien. Si une piste est dangereuse (ex. allocation post-fork), dis-le explicitement. +- **Response:** # LP4-4 — Validation d'approche : enforcement Landlock sur le chemin structuré ## Verdict Stratégie retenue : **(b) raffiné — spawn du tour structuré sandboxé sur un thread jetable restreint, en spawn `std` synchrone**, exposé à l'async via `spawn_blocking`. C'est la transposition directe et sûre de la technique déjà prouvée du PTY. **Piste (a) `pre_exec(enforce)` REJETÉE** comme dangereuse (détail plus bas). **(c) factorisation** : oui sur le *principe*, non sur le *corps* — les deux spawns sont trop différents (portable-pty `SlavePty` vs `std::process`). --- ## Pourquoi PAS (a) `pre_exec(enforce)` — DANGER explicite `tokio::process::Command` réexpose bien `CommandExt::pre_exec`, mais le closure s'exécute **dans l'enfant après `fork`, avant `exec`**, dans un process **multithreadé** (les workers du runtime tokio). Après `fork` en contexte multithreadé, seules les opérations **async-signal-safe** sont permises : tout mutex tenu par un autre thread au moment du fork (typiquement le lock de l'arène malloc) reste verrouillé à jamais dans l'enfant. Or `landlock::Ruleset::…restrict_self()` **alloue** (construction du ruleset, `RestrictionStatus`, Vec internes) → `malloc` post-fork → **risque de deadlock de l'enfant**. C'est exactement le piège que le commentaire du PTY (`spawn_command_sandboxed`, lignes 185-206) documente et évite. La cohérence d'archi impose de le rejeter ici aussi. > Note : il *existe* une variante async-signal-safe de Landlock (construire le ruleset_fd dans le parent, ne faire que `prctl(NO_NEW_PRIVS)` + `landlock_restrict_self(fd)` — deux syscalls purs — dans le pre_exec). Mais elle force à **scinder le port `SandboxEnforcer` en deux phases** (prepare allouant / commit syscall-only) et fait fuiter cette mécanique dans le domaine. Inutile : la technique du thread jetable atteint la même sûreté **sans toucher au contrat de port**. Je la mentionne pour mémoire, je ne la recommande pas. ## Pourquoi (b) est sûr La technique du PTY ne fait **rien** dans l'enfant post-fork : `enforce(plan)` tourne dans le **thread jetable parent AVANT le fork** ; l'enfant **hérite** simplement le domaine Landlock (hérité across `fork`, préservé across `execve`). Zéro code post-fork ⇒ **zéro problème d'async-signal-safety**. C'est toute l'élégance, et elle transpose telle quelle. La seule difficulté est que `tokio::process::Command` **forke sur un worker partagé du runtime** (qu'on ne peut pas restreindre : la restriction est irréversible → on empoisonnerait le runtime). D'où : pour le chemin sandboxé on **abandonne le drain async tokio** au profit d'un **drain `std` synchrone sur le thread jetable restreint**, réconcilié à l'async par `spawn_blocking`. Le chemin non-sandboxé (`sandbox == None` **ou** pas d'enforcer) **reste l'actuel drain async tokio, inchangé** (zéro régression, c'est aussi le seul chemin sur non-Linux). Détails de sûreté du thread sandboxé : 1. `enforcer.enforce(&plan)` restreint CE thread (fail-closed : `Err` ⇒ on échoue le tour, **aucun child ne tourne**) ; 2. `std::process::Command::spawn()` depuis ce thread ⇒ l'enfant hérite le domaine ; 3. poser un `unsafe { cmd.pre_exec(|| Ok(())) }` **vide** (async-signal-safe) pour **forcer le chemin `fork`+`exec`** déterministe (parité avec portable-pty, lève tout doute vs un éventuel `posix_spawn` glibc — l'héritage tiendrait de toute façon, mais on ne parie pas) ; 4. drain bloquant ligne-à-ligne stdin/stdout → EOF → `wait()` → `Vec` ; 5. le thread meurt, emportant sa restriction irréversible ; les autres threads d'IdeA sont intouchés. **Timeout** : aujourd'hui `run_turn` enveloppe le `drain` async. En sandboxé, on enveloppe le `JoinHandle` du thread via `tokio::time::timeout` ; à expiration il faut **tuer le child** (le thread est bloqué en read). Donc le thread renvoie son *killer* (pid / `Arc>`) par un `oneshot` dès après spawn ; le kill provoque l'EOF ⇒ le read débloque ⇒ le thread finit ⇒ on retourne `Timeout`. À implémenter proprement (c'est le seul vrai surcoût de machinerie vs l'élégance actuelle). --- ## Contrat à modifier 1. **`SpawnLine` porte le plan** (oui) — `crates/infrastructure/src/session/process.rs` : ajouter `pub sandbox: Option`. Symétrie avec `SpawnSpec.sandbox`. `SpawnLine` est un DTO **infra** (pas domaine), donc OK. 2. **L'enforcer est injecté dans la factory** (oui, comme le PTY adapter) — `StructuredSessionFactory` gagne un champ `Option>` + un builder `with_sandbox_enforcer(...)`, jumeau exact de `PortablePtyAdapter::with_sandbox_enforcer`. **Pas** dans la signature du port : c'est une dépendance de composition root, par-instance, pas par-appel. 3. **Le plan traverse le port par-appel** — le `SandboxPlan` dépend des permissions résolues de l'agent, il est calculé par-lancement dans `lifecycle.rs` step 5d (`spec.sandbox`). Il doit donc passer **dans `AgentSessionFactory::start`** : ajouter `sandbox: Option<&SandboxPlan>`. `SandboxPlan` est un type **domaine** (`domain::sandbox`) franchissant un **port domaine** — cohérent (déjà le cas via `SpawnSpec.sandbox` sur `AgentRuntime`). Extension O/C : une seule vraie impl + les fakes. Flux complet : `lifecycle.rs` (calcule `spec.sandbox`) → passe le plan à `launch_structured` (qui ne reçoit pas `spec` aujourd'hui) → `factory.start(..., sandbox)` → la factory apparie `sandbox` (plan, par-appel) + son enforcer (par-instance) et les passe à `ClaudeSdkSession::new` / `CodexExecSession::new` → l'adapter les stocke, remplit `SpawnLine.sandbox`, et passe l'enforcer à `run_turn`. 4. **`run_turn`** — `crates/infrastructure/src/session/process.rs` : signature `run_turn(spec, enforcer: Option<&Arc>, timeout)`. Si `spec.sandbox.is_some() && enforcer.is_some()` ⇒ drain sandboxé (thread restreint) ; sinon ⇒ `drain` async actuel **strictement inchangé**. --- ## Découpage des fichiers à toucher - `crates/domain/src/ports.rs:537` — `AgentSessionFactory::start` : + `sandbox: Option<&SandboxPlan>`. - `crates/infrastructure/src/session/process.rs` — champ `SpawnLine.sandbox` ; `run_turn` reçoit l'enforcer ; nouveau `drain_sandboxed` (thread jetable + std spawn + pre_exec vide + timeout par kill). - `crates/infrastructure/src/session/factory.rs` — champ `Option>` + `with_sandbox_enforcer` ; `start` apparie plan+enforcer et les injecte dans les ctors d'adapters. - `crates/infrastructure/src/session/claude.rs` (`build_spawn_line` ~L187, `send` ~L195) & `codex.rs` (~L162/L195) — stocker plan+enforcer, remplir `SpawnLine.sandbox`, passer l'enforcer à `run_turn`. - `crates/application/src/agent/lifecycle.rs` — `launch_structured` (~L1620) reçoit le plan (`spec.sandbox`) et le relaie à `factory.start`. - `crates/app-tauri/src/state.rs:408` — `StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer())`. - **Fakes à mettre à jour** (nouvelle signature `start`) : `crates/domain/tests/structured_session_d0.rs`, `crates/application/tests/structured_launch_d3.rs`, `crates/application/tests/orchestrator_service.rs`. --- ## Invariants & tests pour la QA 1. **Parité avec le PTY (le test pivot)** : réplique de `pty_spawn_enforces_sandbox_plan_end_to_end` côté structuré, avec un **fake CLI / `sh`** qui émet une ligne JSONL et tente d'écrire **hors** grant (doit être bloqué kernel) et **dans** grant (doit réussir). **Zéro token** (aucun vrai claude/codex). Gardé derrière `landlock_is_enforced()` (skip propre sur kernel sans Landlock). 2. **Companion négatif** : même factory+enforcer mais `spec.sandbox == None` ⇒ l'écriture hors-grant **réussit** (prouve que le blocage vient du plan, pas d'une restriction ambiante). 3. **Fail-closed** : posture `Deny` sur kernel sans Landlock ⇒ `enforce` `Err` ⇒ `run_turn` renvoie une erreur et **aucun child ne tourne** (assert : le marqueur de sortie du fake CLI n'existe pas). 4. **No-op par défaut** : `eff == None` ⇒ `compile_sandbox_plan` `None` ⇒ `spec.sandbox None` ⇒ chemin async tokio actuel, comportement natif (régression nulle). Vérifier que la suite structurée existante (conformance, D0/D3) reste **verte sans modification de comportement**. 5. **Confinement de l'irréversibilité** : après un tour sandboxé, un **autre** tour (autre session, plan plus large) sur le même process IdeA n'est PAS affecté par la restriction du tour précédent (le thread jetable est mort). Test : deux tours successifs avec des grants disjoints, chacun voit son propre périmètre. 6. **Timeout sous sandbox** : un fake CLI qui ne ferme jamais stdout ⇒ `run_turn(timeout)` tue le child et renvoie `Timeout`, sans laisser de zombie ni de thread bloqué. 7. **Resume préservé** : le `conversation_id` est toujours capté correctement après un tour sandboxé (la restriction FS ne casse pas le parsing du `session_id`). ## ⚠️ Risque produit à signaler (hors mécanisme, mais bloquant à valider) Les CLI structurées (claude/codex = binaires Node) ont des besoins FS ambiants **bien plus lourds** qu'un `sh` : lecture de `~/.claude` / `~/.codex` (credentials, cache de session pour le resume), `node_modules`, libs système, écriture de caches temporaires. `compile_sandbox_plan` ne **fence en écriture/lecture que les classes explicitement posées** et garde les lectures globalement ouvertes — mais si une policy pose un `Deny`/posture restrictive touchant `$HOME`, le **resume peut casser** (la CLI n'écrit plus son cache de session). Recommandation : LP4-4 livre le **mécanisme** validé au fake CLI (zéro token, points 1-7), et on **flag** qu'une validation e2e manuelle d'un vrai tour claude/codex sous un plan représentatif doit confirmer que le run dir + le home de la CLI restent atteignables avant d'activer le sandbox en prod pour le chemin structuré. C'est un sujet de **composition du plan** (run_dir reachability, déjà réservé dans `SandboxContext.run_dir` mais non consommé par la traduction pure), pas du mécanisme d'enforcement — à traiter en lot suivant si besoin. --- **Résumé** : (b) avec thread jetable restreint + std spawn + `spawn_blocking`, fail-closed identique au PTY ; `SpawnLine.sandbox` ajouté ; enforcer injecté par construction dans la factory ; plan passé par-appel via `AgentSessionFactory::start(.., sandbox)`. (a) rejetée (alloc post-fork). Ne rien coder avant ce GO — QA doit pouvoir s'appuyer sur les 7 invariants ci-dessus. +- **Prompt:** Arbitrage LP4-4 — une divergence est remontée par DevBackend sur ton cadrage, elle touche un invariant crate-wide. Verdict requis. Ton cadrage demandait, dans `drain_sandboxed` (chemin structuré), un `unsafe { cmd.pre_exec(|| Ok(())) }` VIDE pour forcer std sur le chemin déterministe fork+exec (parité avec portable-pty). Blocage : la crate `infrastructure` est `#![forbid(unsafe_code)]` (lib.rs:12). Un `forbid` ne peut pas être relâché par un `#[allow]` local. Le PTY n'avait jamais eu besoin d'unsafe car il hérite le fork+exec du pre_exec INTERNE de portable-pty ; sur le chemin structuré on pilote `std::process` en direct, donc le pre_exec serait à nous → unsafe → interdit. DevBackend a retenu (et livré, build+tests verts) l'option (a) : RETIRER le pre_exec. Son argument : `landlock_restrict_self` restreint le thread courant et toute sa descendance ; l'héritage est assuré par le noyau à travers fork/clone/vfork ET préservé par execve, Y COMPRIS via posix_spawn (= clone+execve), car l'enforcement vit au niveau des credentials de la tâche, hors d'atteinte de l'espace utilisateur. Le fork+exec n'était donc qu'une garantie de DÉTERMINISME du syscall de spawn, pas de sécurité ; enforce()-avant-spawn-sur-le-même-thread suffit. Les deux options sur la table : (a) rester comme livré : zéro unsafe, héritage noyau (choix par défaut de DevBackend). (b) passer lib.rs de `#![forbid(unsafe_code)]` à `#![deny(unsafe_code)]` + `#[allow(unsafe_code)]` scoppé sur drain_sandboxed pour rétablir le pre_exec vide. Question : valides-tu (a) — l'héritage de la restriction Landlock est-il bien garanti par le noyau indépendamment de fork+exec vs posix_spawn, rendant le pre_exec inutile à la sécurité — ou exiges-tu (b) (relâcher l'invariant unsafe-code de la crate) ? Si (a), confirme qu'on garde `#![forbid(unsafe_code)]` intact. Réponds avec le verdict + justification courte ; c'est juste cet arbitrage, pas une re-revue complète. +- **Response:** # Verdict LP4-4 : **(a) validée — on garde `#![forbid(unsafe_code)]` intact** DevBackend a raison, et son choix est même **cohérent avec mon cadrage initial** : j'y avais explicitement écrit que l'héritage tient sans le pre_exec (« even without it inheritance holds across posix_spawn's clone »), le pre_exec vide n'étant qualifié que d'« assurance de déterminisme », **jamais** de garantie de sécurité. Le retirer ne retire donc aucune propriété de sûreté. ## Justification technique (l'héritage est bien garanti par le noyau) - L'enforcement Landlock est attaché aux **credentials de la tâche** (le domaine Landlock vit dans la `cred`, sous NO_NEW_PRIVS — que `restrict_self` pose lui-même). C'est de l'état **noyau**, hors d'atteinte de l'espace utilisateur. - `landlock_restrict_self` restreint le **thread appelant** ; tout enfant créé **par ce thread** hérite ses credentials → donc le domaine Landlock. L'héritage se fait à travers `fork`/`clone`/`vfork` et est **préservé par `execve`**. - `posix_spawn` (glibc) = `clone(CLONE_VM|CLONE_VFORK)` + `execve` émis **depuis le thread appelant** dans le même process : l'enfant hérite les creds exactement comme via fork+exec. Le mécanisme de spawn ne change que le **déterminisme du syscall**, pas l'héritage. La seule précondition de sûreté — **le syscall de spawn doit être émis par le thread restreint** — est respectée par le design : `enforce(plan)` puis `Command::spawn()` sur le **même** thread jetable. ✔ ## Confirmation empirique décisive Le test e2e de parité (écriture hors-grant **bloquée** kernel / dans-grant réussie) passe sur le chemin structuré avec le spawn réellement utilisé. C'est la **preuve in vivo** que l'héritage tient indépendamment de fork+exec vs posix_spawn — exactement l'invariant en débat. Build + suite verts ⇒ rien à rouvrir. ## Arbitrage de l'invariant crate-wide Relâcher `#![forbid(unsafe_code)]` → `deny + #[allow]` scoppé pour ne récupérer qu'une *garantie de déterminisme de spawn* (sans valeur de sécurité) serait un **mauvais échange** : on dégraderait une protection statique crate-wide pour une commodité nulle côté sûreté. **Non.** **Décision : on reste en (a). `crates/infrastructure/src/lib.rs:12` `#![forbid(unsafe_code)]` reste INTACT. Pas de pre_exec, pas d'unsafe.** Une seule exigence pour QA, à garder verte en permanence (déjà couverte par le test de parité) : l'invariant **« écriture hors-grant bloquée sur le chemin structuré »** est le garde-fou qui détecterait toute régression d'héritage si le mécanisme de spawn venait à changer. Tant qu'il est vert, (a) est prouvée. \ No newline at end of file diff --git a/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/log.jsonl b/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/log.jsonl new file mode 100644 index 0000000..61d5f53 --- /dev/null +++ b/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/log.jsonl @@ -0,0 +1,17 @@ +{"id":"e20561bd-dc19-4489-bd4a-05d1567a1f73","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781379811855,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"CADRAGE ARCHITECTURE (pas de code) — Support de la délégation inter-agents (idea_ask_agent/idea_reply) pour les profils CODEX.\n\n## Contexte / gap déjà localisé\nLe round-trip inter-agent fonctionne pour Claude (pont stdio↔loopback `idea mcp-server` + serveur MCP par projet). Pour Codex, il est VOLONTAIREMENT bloqué :\n- `crates/application/src/orchestrator/service.rs` → `guard_mcp_bridge_supported` (~l.1313) exige `StructuredAdapter::Claude` ET une capacité MCP `ConfigFile(\".mcp.json\")`. Raison documentée (l.1300-1309) : IdeA matérialise le serveur MCP en `.mcp.json` (lu par Claude), mais **Codex lit `~/.codex/config.toml`** (table TOML `[mcp_servers.]`), donc le pont n'est jamais branché ⇒ la cible Codex ne peut pas appeler idea_reply ⇒ on coupe court avec une erreur typée.\n- Modèle de profil : `crates/domain/src/profile.rs` → `McpConfigStrategy` (enum), `McpCapability`, `StructuredAdapter::{Claude,Codex}`.\n- Le bridge lui-même (`crates/app-tauri/src/mcp_bridge.rs`) et le serveur (`infrastructure/.../mcp/server.rs`) sont déjà profil-agnostiques (stdio↔loopback + handshake `requester`). Le `McpRuntimeProvider` (app-tauri) fournit exe+endpoint+requester.\n- La matérialisation `.mcp.json` côté Claude se fait dans `crates/app-tauri/src/state.rs` (cf. tests `run_dir_migration_tests::merge_idea_mcp_json_*` et la réconciliation du run dir). Repère le code exact qui écrit `.mcp.json` et le `McpConfigStrategy` consommé.\n\n## Ce que je veux de toi (livrable = note d'archi concise, hexagonale/SOLID)\n1. **Format Codex** : confirme où et comment Codex lit ses serveurs MCP (typiquement `~/.codex/config.toml`, table `[mcp_servers.idea]` avec `command`/`args`). Précise si c'est global (~/.codex) vs projet, et l'impact sur l'isolation par projet (l'endpoint loopback est par-projet : comment garantir que l'agent Codex d'un projet pointe le bon endpoint sans collision entre projets ouverts ?).\n2. **Stratégie de config** : faut-il une nouvelle variante de `McpConfigStrategy` (p.ex. `TomlTable { path, table }` ou `CodexConfig`) ou une généralisation de l'existante ? Donne le contrat exact (domaine) et la frontière : qui écrit le fichier (adapter infra/app-tauri), quel port.\n3. **Garde F2** : critère assoupli pour accepter Codex (sans réintroduire le risque de timeout muet). Quel prédicat exact remplace `is_claude` ? (p.ex. « le profil porte une McpCapability dont la stratégie est effectivement matérialisée par IdeA pour cet adaptateur »).\n4. **Découpage en lots dev/test** : liste ordonnée de tâches autonomes (domaine → application → infra/app-tauri → tests), chacune avec ses fichiers cibles et son critère de validation, prête à être confiée aux agents de dev puis QA. Inclure : extension du test fonctionnel `state::mcp_e2e_loopback_tests` pour qu'il s'exécute À LA FOIS avec un profil Claude ET un profil Codex (paramétrage du profil/handshake ; round-trip identique en dessous, donc la différence testée = garde + matérialisation de config).\n5. **Risques/pièges** : notamment le nettoyage (Codex config global persistant entre projets), idempotence d'écriture, et le fait qu'on ne lance PAS de vrai CLI dans les tests (fakes).\n\nRéponds via idea_reply avec la note d'archi structurée (sections 1→5). Sois concret sur les fichiers et contrats ; ne produis pas de code d'implémentation, juste les signatures/contrats nécessaires."} +{"id":"50876ea4-4042-4b78-8faa-916118058be9","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781380048131,"source":{"kind":"agent","agentId":"dce19c75-9669-4e45-b8de-9950025157da"},"role":"response","text":"# Note d'archi — Pont inter-agents `idea_*` pour profils Codex\n\nCadrage hexagonal/SOLID, sans code d'implémentation. Tout est ancré sur le code réel lu.\n\n---\n\n## 1. Format Codex & isolation par projet\n\n**Où Codex lit ses serveurs MCP.** Codex CLI (Rust) lit son `config.toml` sous **`$CODEX_HOME`** (défaut `~/.codex/`). Les serveurs y sont déclarés en **table TOML** `[mcp_servers.]` avec `command` / `args` / `env`. C'est **global à la machine**, pas par projet — contrairement à Claude qui lit `.mcp.json` depuis son **cwd** (le run dir isolé `{agentRunDir}`, cf. `cwd_template` dans `domain/src/profile.rs`).\n\n**Le problème d'isolation (central).** L'endpoint loopback est **par-projet** : `mcp_endpoint(&project.id)` (app-tauri `mcp_endpoint.rs`), injecté dans `McpRuntime { exe, endpoint, project_id, requester }` à chaque `launch_agent` (`commands.rs:1041`). Si on écrivait dans le **`~/.codex/config.toml` global**, deux projets ouverts simultanément écraseraient mutuellement l'unique `[mcp_servers.idea]` (endpoints/project_id différents) → collision + course à l'écriture + résidu persistant entre projets. **Inacceptable.**\n\n**Solution retenue : relocaliser `CODEX_HOME` par agent.** À chaque lancement, IdeA :\n1. écrit un `config.toml` **dans le run dir isolé de l'agent** (ex. `{runDir}/.codex/config.toml`),\n2. pousse l'env `CODEX_HOME={runDir}/.codex` dans le `SpawnSpec`.\n\nLe run dir est déjà unique par agent (`{agentRunDir}` = `.ideai/run//`) → **isolation native, zéro collision cross-projet, nettoyage trivial** (le run dir est éphémère/régénéré, déjà clobber à chaque relaunch comme `.mcp.json` — cf. `apply_mcp_config` lifecycle.rs:1730 « regenerated and clobbered on every (re)launch »). On **ne touche jamais** au `~/.codex` global. C'est le même invariant qui rend Claude sûr (config dans le cwd isolé), transposé via `CODEX_HOME`.\n\n> Alternative écartée : injecter via flags `-c mcp_servers.idea.command=…`. Rejetée : la sérialisation TOML inline (tables/arrays) sur la ligne de commande est fragile, multiplie les args, et **contourne le seam de matérialisation-fichier** déjà éprouvé (write atomique, clobber/non-clobber). Le fichier + `CODEX_HOME` réutilise le seam existant à l'identique.\n\n---\n\n## 2. Stratégie de config (contrat domaine)\n\n**Décision : nouvelle variante** de `McpConfigStrategy` (`domain/src/profile.rs:179`), **pas** de généralisation des existantes. Justification : le code est explicitement Open/Closed sur ces enums (« ajouter un moteur = une variante »), et le couple Codex porte **deux mécanismes inséparables** (écrire un fichier TOML **+** relocaliser le home via env) qu'aucune variante actuelle ne capture seule (`ConfigFile` = fichier lu depuis cwd, sans env ; `Env` = passe juste un chemin, sans format ni fichier).\n\n```rust\n// domain/src/profile.rs — ajout à l'enum McpConfigStrategy (tag = \"strategy\")\n/// Écrire la config MCP en TOML (table [mcp_servers.idea]) dans le run dir,\n/// ET relocaliser le \"config home\" de la CLI via une variable d'env pointée\n/// sur le dossier contenant ce fichier (ex. Codex : CODEX_HOME).\n/// Garantit l'isolation par-agent d'une CLI qui lit une config GLOBALE.\nTomlConfigHome {\n /// Chemin relatif sûr du config.toml (ex. \".codex/config.toml\").\n target: String,\n /// Variable d'env pointée sur le DOSSIER parent de `target`\n /// (ex. \"CODEX_HOME\"). L'adaptateur dérive le dossier de `target`.\n home_env: String,\n}\n```\n\nConstructeur validé (parse-don't-validate, comme les autres) :\n```rust\npub fn toml_config_home(target, home_env) -> Result\n// target → validation::relative_safe (pas de \"..\", pas d'absolu)\n// home_env → validation::valid_env_var\n```\n\n**Frontière (qui écrit quoi) — inchangée par rapport à Claude :**\n- **Port** : aucun nouveau port. La matérialisation passe par le `FileSystem` (port domaine existant) + `SpawnSpec.env` (déjà le véhicule de `McpConfigStrategy::Env`).\n- **Application** : `LaunchAgent::apply_mcp_config` (`application/src/agent/lifecycle.rs:1708`) gagne un bras `TomlConfigHome` : écrit le TOML via `self.fs.write` (mêmes sémantiques clobber/non-clobber selon `runtime.is_some()`) **et** pousse `(home_env, parent_dir(target))` dans `spec.env`.\n- **app-tauri** : la voie de **réconciliation/migration** au project-open (`reconcile_claude_run_dirs` / `migrate_claude_run_dir`, `state.rs:1069`/`1128`) gagne son pendant Codex (réécrit le `config.toml` du run dir pour rafraîchir exe `$APPIMAGE`/endpoint qui dérivent entre runs).\n\n**Source de format unique.** Aujourd'hui le JSON est produit en **deux endroits** : `mcp_server_declaration` (application, lifecycle.rs:1857) et `mcp_server_entry` (app-tauri, state.rs:1344). Il faut un **sibling TOML** symétrique dans chacun (`mcp_server_declaration_toml` / `mcp_server_entry_toml`), produisant la **même donnée logique** (`command`, `args=[\"mcp-server\",\"--endpoint\",…,\"--project\",…,\"--requester\",…]`, `transport`) en table `[mcp_servers.idea]`. Recommandation SOLID : factoriser la donnée (struct `McpServerWiring { command, args, transport }`) et n'avoir que **deux encodeurs** (json/toml) — évite la dérive entre les 4 sites.\n\n---\n\n## 3. Garde F2 (critère assoupli, sans timeout muet)\n\nPrédicat actuel (`orchestrator/service.rs:1313`) : `honours_mcp_json && is_claude` — exclut Codex en dur.\n\n**Nouveau prédicat = « le couple (adaptateur, stratégie) est effectivement matérialisé par IdeA pour cette CLI ».** Le risque à éviter (timeout 300 s muet) vient justement d'un profil qui *déclare* une stratégie qu'IdeA **n'écrit pas réellement dans le format que la CLI lit**. Donc le critère doit refléter **exactement** l'ensemble que le code de matérialisation (§2) sait câbler :\n\n```rust\n// Couples réellement honorés (whitelist) :\n(Claude, ConfigFile { target: \".mcp.json\" }) // existant\n(Codex, TomlConfigHome { .. }) // nouveau\n// tout autre couple ⇒ AppError::Invalid immédiate (comme aujourd'hui)\n```\n\n**Anti-dérive (clé SOLID).** Ne pas dupliquer cette whitelist dans la garde ET dans `apply_mcp_config`. Exposer **une seule fonction domaine**, source de vérité partagée :\n\n```rust\n// domain/src/profile.rs\nimpl AgentProfile {\n /// true ssi IdeA matérialise effectivement le pont idea_* pour ce profil\n /// (couple adaptateur structuré × stratégie MCP réellement écrit dans le\n /// format que la CLI lit). Consommé par la garde F2 ET par la matérialisation.\n pub fn materializes_idea_bridge(&self) -> bool { /* match sur la whitelist */ }\n}\n```\n\nLa garde devient : `if profile.materializes_idea_bridge() { Ok(()) } else { Err(Invalid(...)) }`. Profil introuvable ⇒ `Ok(())` (inchangé : la garde ne fait que typer un échec connu). Le message d'erreur est généralisé (ne plus dire « cible un agent Claude »).\n\n---\n\n## 4. Découpage en lots dev/test (ordonné par dépendance)\n\n| Lot | Couche | Contenu | Fichiers cibles | Critère de validation |\n|---|---|---|---|---|\n| **D1** | domaine | Variante `TomlConfigHome { target, home_env }` + constructeur validé + `AgentProfile::materializes_idea_bridge()` (whitelist Claude/.mcp.json + Codex/TomlConfigHome). | `domain/src/profile.rs` | `cargo test -p domain` : (de)sérialisation tag=\"strategy\", rejet `..`/absolu sur target, rejet env var invalide, `materializes_idea_bridge` vrai/faux par couple. |\n| **D2** | domaine | Encodeur TOML partagé : struct `McpServerWiring` + sérialisation table `[mcp_servers.idea]` (sibling pur, testable sans I/O). | `domain/src/profile.rs` (ou module dédié) | `cargo test -p domain` : args ordonnés, échappement TOML d'un chemin avec espaces/`\\`, transport rendu. |\n| **A1** | application | Bras `TomlConfigHome` dans `apply_mcp_config` : write TOML (clobber si runtime, non-clobber sinon) + push `(home_env, parent(target))` dans `spec.env`. + `mcp_server_declaration_toml`. | `application/src/agent/lifecycle.rs` | `cargo test -p application` : fake `FileSystem` reçoit le write au bon chemin avec contenu TOML ; `spec.env` contient `CODEX_HOME`=parent ; sémantique clobber/non-clobber selon runtime (miroir des tests `apply_mcp_config` existants). |\n| **A2** | application | Garde F2 ré-exprimée via `materializes_idea_bridge()` + message générique. | `orchestrator/service.rs:1300-1346` | `cargo test -p application` : profil Codex+TomlConfigHome ⇒ `Ok` ; profil Codex sans mcp / Codex+ConfigFile ⇒ `Invalid` ; Claude+.mcp.json ⇒ `Ok` (non-régression) ; profil inconnu ⇒ `Ok`. |\n| **I1** | app-tauri | Sibling Codex de la réconciliation : `is_codex_mcp_profile`, `migrate_codex_run_dir`, `mcp_server_entry_toml` ; brancher dans `reconcile_claude_run_dirs`/`migrate_claude_run_dirs` (les renommer `reconcile_mcp_run_dirs` ou dispatcher par profil). | `crates/app-tauri/src/state.rs` (~1069-1420) | `cargo test -p app-tauri` : test type `reconcile_*_repairs_legacy_files_on_disk` mais pour Codex (réécrit `.codex/config.toml`, rafraîchit exe/endpoint, idempotent au 2e passage). |\n| **T1** | test fonctionnel | **Paramétrer `mcp_e2e_loopback_tests` Claude ⊕ Codex.** Le round-trip sous le pont est identique (loopback réel + fakes) ; la seule différence testée = **garde** + **matérialisation de config**. Factoriser le corps des 4 tests (`*_over_real_loopback`) sur un paramètre `profile_kind`, instancier un `AgentProfile` Codex (structured=Codex, mcp=TomlConfigHome, transport stdio) à côté du Claude existant. Le handshake (`requester`/`project`) et `idea_ask_agent`/`idea_reply` restent inchangés. | `crates/app-tauri/src/state.rs:2848+` | Les 4 round-trips (`list_agents`, `ask_then_reply`, `orphan_reply`, `handshake_requester`) **verts pour les deux profils** ; ajouter un cas qui assert que la garde **accepte** une cible Codex (là où elle refusait avant). |\n\nOrdre d'exécution : D1→D2→A1→A2→I1→T1. Chaque lot confiable à un binôme dev+QA, vert avant le suivant.\n\n---\n\n## 5. Risques / pièges\n\n1. **Config Codex globale persistante** → **résolu par design** via `CODEX_HOME`=run dir : rien n'est jamais écrit dans `~/.codex`. Vérifier en test qu'`apply_mcp_config` n'émet **aucun** chemin absolu ni HOME, seulement `{runDir}/...`.\n2. **Idempotence d'écriture** : conserver la dissymétrie déjà en place — `runtime=Some` (lancement app-tauri réel) ⇒ **clobber** (exe `$APPIMAGE`/endpoint dérivent à chaque run, cf. lifecycle.rs:1730) ; `runtime=None` (orchestrateur/hot-swap/tests) ⇒ **non-clobber** (ne pas écraser une vraie déclaration par la minimale). Le TOML doit suivre la même règle.\n3. **On ne lance PAS de vrai CLI en test** : aucun test n'exécute `codex`. Le T1 prouve la **garde + le contrat de matérialisation** (fichier écrit, env posée) et le **round-trip MCP** via loopback réel + fakes (`FakeSession`, `FakeContexts`) ; jamais un binaire Codex. Le format TOML est validé par parsing en test (toml crate), pas par exécution.\n4. **Échappement TOML** : un exe `$APPIMAGE` ou endpoint avec espaces/`\\` doit rester un TOML valide (équivalent du `json_string` actuel). Tester explicitement (cf. `mcp_declaration_*` existants).\n5. **Deux écrivains de format** (application `mcp_server_declaration` + app-tauri `mcp_server_entry`) : risque de dérive JSON↔TOML. Mitiger par la struct `McpServerWiring` partagée (D2) consommée des deux côtés.\n6. **`parent(target)` vide** : si un profil déclare `target=\"config.toml\"` (sans sous-dossier), `home_env` pointerait le run dir lui-même — acceptable, mais préférer conventionnellement `.codex/config.toml` pour ne pas polluer le cwd que Codex pourrait scanner. À documenter dans le profil seed.\n7. **Garde anti-timeout** : ne jamais élargir la whitelist `materializes_idea_bridge()` sans que le bras de matérialisation correspondant existe — sinon réintroduction exacte du timeout muet que F2 protège. Les deux évoluent ensemble, dans le même lot.\n\n---\n\n**Aucun nouveau port/adapter** : on réutilise `FileSystem`, `SpawnSpec.env`, `McpRuntime`, le bridge stdio↔loopback et le serveur MCP (déjà profil-agnostiques). L'ajout est **déclaratif** (1 variante d'enum) + **2 encodeurs** + **garde unifiée** — strictement Open/Closed."} +{"id":"0a6aa86d-97bd-4a98-8770-be9ce1893a70","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781386900974,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Nouveau chantier : **gestion des permissions des agents** dans IdeA. Je te demande le **cadrage architecture complet** (hexagonal/SOLID), AVANT tout code, conformément à la méthode §3 et §11.\n\n## Besoin produit (mots de l'utilisateur)\nGérer les permissions des agents à deux échelles :\n- **Échelle projet** : permissions par défaut héritées par tous les agents (ex. « les agents ont tous les droits »).\n- **Échelle agent** : override ciblé qui contredit le défaut projet (ex. « cet agent n'a PAS le droit d'utiliser bash »).\nDonc : défaut projet, surchargé point par point par l'agent. Doit marcher sur **Claude** et **Codex** dès maintenant, **extensible** à d'autres moteurs plus tard (Open/Closed, déclaratif).\n\n## Ce que je veux de toi (livrable de cadrage)\n1. **Modèle de domaine** des permissions : quelle granularité (capacités/catégories abstraites model-agnostiques, ex. exécution shell, écriture fichiers, réseau…), comment représenter « tous les droits » vs deny ciblé, et la **sémantique de résolution** projet→agent (override = comment ? tri-state allow/deny/inherit ?).\n2. **Frontières hexagonales** : où vit le modèle (domaine), où vit la résolution (application), quels **ports** (traits) introduire, et comment ça se branche sur l'existant `AgentProfile`/lancement d'agent (crates/domain/src/profile.rs est purement déclaratif et additif Open/Closed — je veux la même philosophie).\n3. **Mapping runtime** : comment une permission abstraite se traduit en flags CLI concrets pour **Claude** (ex. `--allowedTools`/`--disallowedTools`/`--permission-mode`) et **Codex** (sandbox/approval), via un mécanisme déclaratif/adapter pour rester extensible. Indique le point d'injection dans le lancement (lifecycle/catalogue).\n4. **Persistance** : où stocker (échelle projet : `.ideai/` — quel fichier ? ; échelle agent : `agents.json` ou fichier dédié ?), avec **zéro régression** de sérialisation (clés optionnelles, défauts), cohérent avec les patterns existants (providers.json, etc.).\n5. **Découpage en lots** dev (DevBackend / DevFrontend) + **critères de test** pour QA, et la liste précise des fichiers/contrats à créer ou toucher.\n\nRéponds via **idea_reply** avec le plan de cadrage structuré (ports, contrats, fichiers, découpage en lots, critères de test). Ne code rien — c'est un cadrage."} +{"id":"5984ff2e-977f-4022-8ef1-a5562d5a73cc","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781423562208,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de la conversation inter-agents IdeA. Peux-tu confirmer que tu reçois bien ce message et répondre via idea_reply avec un court statut de santé (qui tu es, ton rôle, et confirmation que le pont fonctionne) ?"} +{"id":"52e5b3cb-e275-4fe5-8652-7a11f30f33f3","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781423572874,"source":{"kind":"agent","agentId":"dce19c75-9669-4e45-b8de-9950025157da"},"role":"response","text":"✅ Message bien reçu — le pont inter-agents IdeA fonctionne.\n\n**Qui je suis :** codextest, un agent du projet IdeA.\n**Rôle :** agent de test/validation rattaché au project root IdeA.\n**Santé du pont :** opérationnel. J'ai reçu ta tâche (ticket 6ee3458a-a0c9-4adc-9d52-219023af626c) et je te réponds via `idea_reply` comme prévu. Le cycle délégation → réception → réponse est donc validé de bout en bout.\n\nPrêt pour la suite quand tu veux."} +{"id":"31b34b0c-531d-44e7-821f-8be92b7ddf1b","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781424058851,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"CADRAGE ARCHITECTURE — Système de permissions projet + agents (avant tout code).\n\nCONTEXTE PRODUIT (validé par l'utilisateur) :\nOn veut gérer les permissions des agents IA à deux niveaux :\n1. Niveau PROJET : permissions par défaut héritées par tous les agents du projet.\n2. Niveau AGENT : surcharge fine, agent par agent.\n\nLes permissions couvrent (a) les opérations FICHIERS (lecture, écriture/modification, suppression…) et (b) le droit d'exécuter des COMMANDES BASH. Objectif final : quand une permission est définie dans IdeA, l'agent (qu'il soit Claude Code OU Codex) NE REDEMANDE PLUS la permission au run.\n\nDÉCISIONS PRODUIT DÉJÀ ARBITRÉES (à respecter, ne pas re-débattre) :\n- Modèle neutre = CAPABILITIES + SCOPES : capacités typées (read / write / delete / execute-bash), chacune scopable par chemin (globs) et, pour bash, par allowlist/denylist de commandes.\n- Combinaison projet→agent = HÉRITAGE + OVERRIDE, avec DENY PRIORITAIRE en cas de conflit.\n- Posture par défaut = si AUCUNE permission n'est définie (ni projet ni agent), on garde le comportement actuel (le moteur demande au run) ; IdeA n'écrit aucune config tant que rien n'est posé.\n- Le modèle de domaine doit être NEUTRE vis-à-vis du moteur ; chaque runtime PROJETTE ce modèle dans sa config native pour supprimer les prompts au run :\n * Claude Code → .claude/settings.local.json (permissions.allow/deny + defaultMode acceptEdits/bypassPermissions).\n * Codex → config.toml (approval_policy + sandbox_mode).\n\nCE QUE J'ATTENDS DE TOI (contrat d'architecture, PAS du code) :\n1. Le modèle de domaine : entités/VO (ex. PermissionSet, Capability, PathScope, CommandRule…), invariants, et la fonction PURE de résolution (project defaults ⊕ agent overrides → PermissionSet effectif, deny prioritaire).\n2. Le ou les PORTS nécessaires (ex. un PermissionProjector/PermissionRenderer par runtime ? un PermissionStore ?) et où ils vivent (domain/application/infra), cohérent avec ta cartographie existante (ARCHITECTURE.md, crates domain/application/infrastructure/app-tauri).\n3. La stratégie de PROJECTION par runtime : comment le PermissionSet effectif se traduit concrètement en settings.local.json (Claude) et config.toml (Codex) pour ne plus prompter — y compris les limites/risques (ex. granularité bash de Codex vs Claude, sandbox).\n4. Le STOCKAGE/format : où persiste-t-on les permissions (.ideai/ ? agents.json ? un permissions.json ?), schéma JSON proposé, et l'impact sur le manifeste existant.\n5. Le découpage en LOT(S) livrable(s) façon §12 (binôme dev+test), avec ordre et dépendances.\n6. Points ouverts / spikes (notamment ce qui n'est pas mappable proprement entre Claude et Codex).\n\nRéponds via idea_reply avec ce contrat structuré. Sois concret et aligné sur l'hexagonal strict que tu maintiens."} +{"id":"7bdaf123-a4fc-4837-9c17-7716c0fcf7d5","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781424174560,"source":{"kind":"agent","agentId":"dce19c75-9669-4e45-b8de-9950025157da"},"role":"response","text":"# CONTRAT D'ARCHITECTURE — Permissions projet + agents\n\nCadrage hexagonal strict, aligné sur ARCHITECTURE.md et sur le pattern **déjà éprouvé** `McpConfigStrategy` / `McpServerWiring::to_config_toml` (domaine = rendu pur du fichier de conf ; application = écriture dans le run dir). On **réutilise ce gabarit** au lieu d'inventer un nouveau mécanisme.\n\n---\n\n## 1. Modèle de domaine (`crate domain`, module `permission`)\n\nPur, sans I/O, `serde` autorisé (format persisté = contrainte métier, cf. §1.4 archi).\n\n### Value Objects / entités\n\n```\nCapability (enum) = Read | Write | Delete | ExecuteBash\nEffect (enum) = Allow | Deny\n\nPathScope (VO) = { globs: Vec } // globs relatifs au project root, jamais absolus hors-root\nCommandRule (VO) = { matcher: CommandMatcher, effect: Effect }\nCommandMatcher (VO) = Exact(String) | Prefix(String) | Glob(Glob) // ex. \"git\", \"npm *\", \"rm -rf *\"\n\nPermissionRule (VO) = {\n capability: Capability,\n effect: Effect,\n paths: PathScope, // applicable à read/write/delete (ignoré pour bash)\n commands: Vec, // applicable seulement à ExecuteBash\n}\n\nPermissionSet (entité-valeur) = {\n rules: Vec,\n // posture explicite quand rien ne matche une capacité :\n fallback: Posture, // Ask (défaut) | Allow | Deny\n}\n\nPosture (enum) = Ask | Allow | Deny\n```\n\n### Invariants (testables sans I/O)\n- `PathScope.globs` : relatifs, **pas de `..`**, pas de chemin absolu sortant du root (réutiliser la garde de `ConventionFile.target`).\n- `ExecuteBash` est la **seule** capacité portant des `CommandRule` ; les autres portent un `PathScope`. Une règle bash avec `paths` non vide = erreur ; une règle fichier avec `commands` = erreur.\n- `Glob` non vide et compilable.\n- Un `PermissionSet` peut contenir Allow ET Deny sur la même capacité (scopes différents) — c'est légal, résolu au run par spécificité + priorité deny.\n- **Posture par défaut produit** : `PermissionSet` ABSENT (Option::None) ≠ `PermissionSet` vide. None ⇒ IdeA n'écrit RIEN, comportement actuel préservé. C'est porté par l'option au niveau résolution, pas par un set vide.\n\n### Fonction PURE de résolution\n\n```rust\n// domain::permission\npub fn resolve(\n project: Option<&PermissionSet>, // défauts projet\n agent: Option<&PermissionSet>, // overrides agent\n) -> Option\n```\n\nRègles :\n1. `project == None && agent == None` ⇒ **`None`** (rien posé → on ne projette rien, le moteur prompte au run). **C'est l'invariant produit clé.**\n2. Sinon, fusion **héritage + override** :\n - On part des règles projet, on superpose les règles agent.\n - **DENY PRIORITAIRE** : pour une capacité+scope donnés, si un `Deny` matche (projet ou agent), il **gagne** sur tout `Allow`, quel que soit le niveau. (deny-wins, non surchargeable par un allow plus spécifique — décision produit, on ne re-débat pas.)\n - `fallback` : l'agent peut resserrer mais le deny projet reste prioritaire.\n3. Résultat = `EffectivePermissions` (VO de sortie, déjà aplati/normalisé), prêt à projeter. C'est le **seul** input des projecteurs.\n\n`resolve` est totale, déterministe, 100 % testable (table de vérité héritage × deny-wins × fallback).\n\n---\n\n## 2. Ports & emplacement\n\nOn ajoute **deux** ports. On **ne crée pas** de port « renderer » côté infra : le rendu est PUR donc il vit dans le domaine (exactement comme `to_config_toml` aujourd'hui).\n\n| Port / élément | Type | Couche | Rôle |\n|---|---|---|---|\n| `PermissionStore` | **trait (port)** | `domain/ports` | `load_project_permissions(project) -> Option` ; `load_agent_permissions(project, agent_id) -> Option` ; `save_*`. Implémenté par un `FsPermissionStore` (infra) ou intégré à l'`AgentContextStore`/`ProjectStore` existant. |\n| `PermissionProjection` | **trait de domaine (pas un port I/O)** | `domain/permission` | `fn project(&self, eff: &EffectivePermissions) -> RuntimeConfigArtifact` où `RuntimeConfigArtifact = { rel_path: String, contents: String, merge: MergeMode }`. **Pur**, une impl par runtime (`ClaudeProjection`, `CodexProjection`). Calque exact de `McpServerWiring::to_config_toml`. |\n\n- **Application** : un use case `ResolveAndProjectPermissions` (ou plus simplement une fonction appelée DANS `LaunchAgent`) qui : charge via `PermissionStore` → `resolve(...)` → si `Some`, appelle la `PermissionProjection` du runtime du profil → écrit l'artefact via `FileSystem` dans le run dir. Si `None`, ne touche à rien.\n- **Infra** : `FsPermissionStore` (tokio::fs + serde_json). Écriture des artefacts = `FileSystem` déjà existant. **Aucun nouvel adapter PTY/process.**\n- **Composition root** (`app-tauri`) : injecte `FsPermissionStore` + map `ProfileKind → Arc`.\n\nPoint d'insertion concret : dans `application/src/agent/lifecycle.rs`, là où `claude_settings_seed` / le `config.toml` Codex sont déjà écrits dans le run dir. **La projection permissions REMPLACE le seed blanket `bypassPermissions` actuel** quand un PermissionSet est défini ; sinon le comportement actuel reste (voir §3 risque).\n\n---\n\n## 3. Stratégie de projection par runtime\n\nL'artefact est écrit dans le **run dir isolé** (`.ideai/run//`), jamais dans le `~/.claude` / `~/.codex` global — exactement comme le MCP wiring aujourd'hui. C'est ce qui supprime les prompts sans polluer la machine.\n\n### Claude Code → `{runDir}/.claude/settings.local.json`\nMapping `EffectivePermissions` → schéma natif Claude :\n- `Capability::ExecuteBash` + `CommandRule(effect=Allow)` → `permissions.allow: [\"Bash(:*)\"]` (ou pattern exact). Deny → `permissions.deny`.\n- `Read`/`Write`/`Delete` + `PathScope` → `permissions.allow/deny` avec `Read()`, `Edit()`, `Write()`. (Delete ≈ pas de capacité native dédiée → mappé sur Bash `rm` + Write ; voir spike.)\n- `fallback`/posture globale → `permissions.defaultMode` :\n - `Allow` global large ⇒ `acceptEdits` (ou `bypassPermissions` si l'utilisateur l'assume) ;\n - posture restrictive avec allowlist ⇒ `default` (ask) + listes `allow`.\n- `MergeMode` = merge sur l'existant (on **garde** la logique de merge actuelle de `claude_settings_seed`, on ne l'écrase pas brutalement).\n\n### Codex → `{runDir}/.codex/config.toml` (CODEX_HOME déjà câblé)\nGranularité bien plus grossière que Claude — c'est le risque principal :\n- Codex n'a pas d'allowlist de commandes par règle fine : il a `approval_policy` (`never` / `on-failure` / `on-request` / `untrusted`) et `sandbox_mode` (`read-only` / `workspace-write` / `danger-full-access`).\n- Mapping pragmatique :\n - PermissionSet majoritairement `Allow` write+bash sans deny bloquant ⇒ `approval_policy = \"never\"` + `sandbox_mode = \"workspace-write\"`.\n - `Read` seul ⇒ `sandbox_mode = \"read-only\"`.\n - Présence de `Deny` ⇒ **on NE peut pas exprimer un deny fin** dans Codex : on rabat sur `approval_policy = \"on-request\"` (Codex redemandera pour les cas sensibles) **OU** on documente la perte de fidélité (voir §6).\n- Les denylists de commandes Claude (ex. `rm -rf /`) n'ont **pas d'équivalent** Codex → couvert seulement par `sandbox_mode = workspace-write` (qui borne au workspace) + spike.\n\n**Limite assumée** : la projection est *best-effort fidèle*. Le domaine reste la source de vérité ; chaque projecteur fait au mieux et on expose un `ProjectionFidelity { lossless: bool, warnings: Vec }` dans l'artefact pour remonter à l'UI « cette permission n'est pas exprimable telle quelle sous Codex ».\n\n---\n\n## 4. Stockage & format\n\nDécision : **fichier dédié `.ideai/permissions.json`** (pas dans `agents.json`). Raisons : SRP (le manifeste mappe md↔template↔sync, pas la sécurité), diff/review propre, et on évite de versionner les overrides agent au milieu du manifeste.\n\nSchéma proposé :\n```json\n{\n \"version\": 1,\n \"project\": {\n \"fallback\": \"ask\",\n \"rules\": [\n { \"capability\": \"read\", \"effect\": \"allow\", \"paths\": [\"**/*\"] },\n { \"capability\": \"write\", \"effect\": \"allow\", \"paths\": [\"src/**\", \"crates/**\"] },\n { \"capability\": \"write\", \"effect\": \"deny\", \"paths\": [\".git/**\", \"**/*.pem\"] },\n { \"capability\": \"execute-bash\", \"effect\": \"allow\", \"commands\": [\"git *\", \"cargo *\", \"npm *\"] },\n { \"capability\": \"execute-bash\", \"effect\": \"deny\", \"commands\": [\"rm -rf *\", \"sudo *\"] }\n ]\n },\n \"agents\": {\n \"\": {\n \"fallback\": \"ask\",\n \"rules\": [\n { \"capability\": \"execute-bash\", \"effect\": \"deny\", \"commands\": [\"*\"] }\n ]\n }\n }\n}\n```\n- `project` absent + `agents[id]` absent ⇒ `resolve` rend `None` ⇒ rien d'écrit (posture par défaut respectée).\n- **Impact manifeste** : `agents.json` **inchangé**. Lien faible par `agentId` (clé partagée). Pas de migration de l'existant requise.\n- Mémoire projet : ce fichier voyage avec le projet (versionnable), cohérent avec la décision « mémoire/contexte partagés au project root ». À afficher dans l'UI permissions (lot front).\n\n---\n\n## 5. Découpage en lots (façon §12, binôme dev+test)\n\n| # | Lot | Contenu | Dépend de |\n|---|---|---|---|\n| **LP0** | **Domaine permissions** | VO/entités (`Capability`, `PathScope`, `CommandRule`, `PermissionRule`, `PermissionSet`, `Posture`), invariants, `resolve()` pure (héritage + deny-wins + None-si-rien). Tests = tables de vérité. **Zéro I/O.** | L0 |\n| **LP1** | **PermissionStore + format** | trait `PermissionStore` (domain), `FsPermissionStore` (infra), schéma `.ideai/permissions.json`, (dé)sérialisation. Tests intégration tmpdir. | LP0 |\n| **LP2** | **Projection Claude** | `ClaudeProjection` (domaine pur) + branchement dans `lifecycle.rs` REMPLAÇANT le seed blanket quand `Some`. `ProjectionFidelity`. Tests = snapshot du `settings.local.json` rendu. | LP0, LP1 |\n| **LP3** | **Projection Codex** | `CodexProjection` (mapping approval_policy/sandbox_mode), warnings de fidélité. Tests snapshot `config.toml`. | LP0, LP1 |\n| **LP4** | **Câblage LaunchAgent + IPC** | use case `ResolveAndProjectPermissions` invoqué à l'activation, composition root (map runtime→projection), commandes Tauri `get/set_permissions`. | LP2, LP3 |\n| **LP5** | **UI permissions** | éditeur projet + override agent, badges de fidélité (« non exprimable sous Codex »). Gateways mock. | LP4 |\n\nOrdre = dépendances directes. LP2 et LP3 parallélisables après LP1.\n\n---\n\n## 6. Points ouverts / spikes\n\n1. **Granularité bash Codex** (bloquant fidélité) : pas d'allow/deny par commande → on ne peut pas refléter une denylist fine. Spike : valider si `sandbox_mode=workspace-write` + `approval_policy` suffit, sinon assumer la perte + warning UI. **À trancher avant LP3.**\n2. **Capacité `Delete`** : ni Claude ni Codex n'ont de capacité « delete » native distincte de write/bash `rm`. Spike : mapper Delete → (Write sur le chemin) + (Bash `rm`/`unlink`) ? ou capacité purement informative côté FileGuard (cf. `domain/src/fileguard.rs` déjà présent — possible convergence : la FileGuard interne pourrait CONSOMMER le même `EffectivePermissions` que la projection, garantissant cohérence I/O réelle vs config CLI).\n3. **Cohérence FileGuard ↔ projection** : on a déjà `fileguard.rs`. Décision à figer : `EffectivePermissions` devient-il la source unique consommée *à la fois* par la FileGuard (enforcement réel des outils MCP) ET par les projecteurs (suppression des prompts CLI) ? Recommandé : **oui**, une seule source — sinon double vérité. Spike d'alignement.\n4. **`bypassPermissions` actuel** : le seed blanket donne aujourd'hui l'autonomie totale. Migration : tant qu'aucun `permissions.json` n'existe, on **garde** ce seed (compat). Dès qu'un set est posé, la projection prend le relais. Valider qu'on ne régresse pas l'autonomie des agents existants (Main, Architect…).\n5. **Globs ↔ patterns natifs** : la syntaxe glob du domaine doit se traduire sans perte vers `Bash(...)`/`Edit(...)` de Claude et vers le sandbox Codex. Spike de table de correspondance.\n6. **Édition hors-app du permissions.json** (content hash / drift) — aligné sur le mécanisme drift template existant, mineur.\n\n---\n\n**Synthèse** : un module domaine `permission` (modèle + `resolve` pure + 2 projections pures), un port `PermissionStore`, branchement dans `LaunchAgent`/`lifecycle.rs` sur le pattern MCP existant, stockage `.ideai/permissions.json` sans toucher au manifeste, 6 lots binômables. Le seul vrai risque produit est la fidélité Codex (granularité bash + deny), géré par un drapeau de fidélité explicite plutôt que par une fausse équivalence.\n\nJe recommande de trancher les spikes #1 et #3 (convergence FileGuard) avant de lancer LP3/LP4."} +{"id":"7145528a-6886-4643-a2dc-f031b3fb5629","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781424773347,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"LP0 implémenté et vert. DevBackend a dû trancher un point de sémantique non couvert explicitement par ton contrat — j'ai besoin de ta validation (ou correction) avant que QA fige les tables de vérité.\n\nLE POINT : pour une règle bash `PermissionRule { capability: ExecuteBash, effect: , commands: [CommandRule...] }` où `commands` est NON VIDE — que signifie le `effect` de NIVEAU RÈGLE (``) pour les commandes qui ne matchent AUCUN `CommandRule` ?\n\nDeux interprétations :\n- (A) choix actuel de DevBackend : le `effect` de niveau règle n'est PAS consulté pour les commandes non matchées ; seules les `CommandRule` individuelles décident, et les commandes non matchées retombent sur le `fallback` (Ask/Allow/Deny). Avantage : permet « allow git*, allow cargo*, et prompt tout le reste » avec fallback=Ask. Une règle bash à `commands` VIDE reste un verdict blanket (effect) pour toute commande.\n- (B) catch-all : pour une règle bash avec commandes, le `effect` de niveau règle s'applique en catch-all aux commandes non matchées par ses CommandRule.\n\nDevBackend a choisi (A) et l'a documenté dans le rustdoc. Pour moi (A) est cohérent avec la posture produit « deny prioritaire + fallback Ask par défaut » et évite qu'une règle allow se transforme en allow-tout implicite. Confirme (A), ou impose (B) si tu vois un risque sur la projection Claude/Codex (notamment : Codex n'ayant pas de granularité par commande, comment (A) vs (B) se projette en approval_policy/sandbox_mode).\n\nRéponds via idea_reply : tranche (A) ou (B), + une phrase sur l'impact projection Codex. Court."} +{"id":"40deb12e-f563-4ec7-8296-8f16ec981e1e","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781424791291,"source":{"kind":"agent","agentId":"dce19c75-9669-4e45-b8de-9950025157da"},"role":"response","text":"**Tranché : (A) confirmée.** C'est le bon choix et il est cohérent avec mon contrat : chaque `CommandRule` porte déjà son propre `effect`, donc sur une règle bash à `commands` non vide, l'`effect` de niveau règle est **inerte** (ne pas le consulter pour les non-matchés ; les non-matchés retombent sur `fallback`). Une règle bash à `commands` VIDE reste un verdict blanket. Garde le deny-wins prioritaire sur les `CommandRule` matchées. Recommande à QA d'ajouter une ligne de table figeant explicitement « rule-level effect ignoré quand commands non vide » pour éviter toute régression future.\n\n**Impact projection Codex** : (A) projette *plus* fidèlement — Codex n'ayant aucune granularité par commande, l'allowlist fine est de toute façon perdue, et c'est le `fallback` (la posture globale) qui pilote `approval_policy`/`sandbox_mode` (Ask→on-request, Allow→never+workspace-write, Deny→on-request best-effort) ; (B) aurait introduit un second curseur catch-all en conflit avec `fallback`, ambigu à projeter. Donc (A) simplifie aussi le projecteur Codex."} +{"id":"009d9c18-7a23-4939-8a05-f144b2d7bc04","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781523472632,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Cadrage du lot LP3 — projection des permissions vers les CLI (Claude + Codex), avec swap cross-profile automatique.\n\nCONTEXTE (déjà en place, tout vert) :\n- domain/src/permission.rs : modèle pur + `resolve()` → `EffectivePermissions` (profil-agnostique), avec `decide_file(capability, path) -> Posture` et `decide_bash(command) -> Posture` (Posture = Ask|Allow|Deny, deny-wins).\n- Port `PermissionStore` + `FsPermissionStore` lisant `.ideai/permissions.json`.\n- Use cases application (get/update/resolve) + commandes Tauri + UI `PermissionsPanel` montée. Slice de gestion terminé.\n- `EffectivePermissions` / `decide_*` ne sont consommés NULLE PART hors tests : rien n'est appliqué aux agents.\n- Le swap cross-profile existe déjà (commit P8d : « swap cross-profile préserve l'id de paire + handoff »).\n\nEXIGENCE PRODUIT (utilisateur) : « Fonctionnel pour Codex comme pour Claude. Si je passe un agent de Claude à Codex, que les permissions soient exportées de l'un vers l'autre automatiquement. »\n\nDécision d'archi pressentie à valider/affiner : les permissions restent stockées une seule fois (profil-agnostique) ; on introduit un Port `PermissionProjector` avec une implémentation par profil (Claude → settings.json allow/deny/ask ; Codex → config sandbox), sélectionnée selon le profil actif au lancement/handoff. Le swap Claude→Codex re-projette automatiquement via l'autre projecteur depuis la même `EffectivePermissions`.\n\nCE QUE J'ATTENDS DE TOI (réponds via idea_reply) :\n1. Validation/correction du contrat du Port `PermissionProjector` : signature exacte (entrée `EffectivePermissions` + cwd/projectRoot ? sortie : écriture fichier vs valeur ?), où il vit (domain port, impls infrastructure), et comment on évite le couplage au profil concret (registre profil→projector).\n2. Où exactement brancher la projection dans le chemin de lancement d'agent et dans le chemin de swap/handoff cross-profile (cite les modules : application/src/agent/lifecycle.rs, orchestrator/service.rs, etc.).\n3. Stratégie de nettoyage : quand un profil n'est plus actif (swap), faut-il retirer/écraser la config projetée du profil précédent ? Règle précise.\n4. Découpage en sous-tâches dev/test concret (ce que je donnerai à DevBackend puis QA), dans l'ordre.\nNe code pas : donne le contrat et le plan."} +{"id":"246a00d0-d8dc-4dc4-abb5-dadb859bd3ef","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781523710396,"source":{"kind":"agent","agentId":"dce19c75-9669-4e45-b8de-9950025157da"},"role":"response","text":"CADRAGE LP3 — Projection des permissions vers les CLI (Claude + Codex) + re-projection au swap cross-profile\n\n⚠️ Constat préalable (à dire à DevBackend avant tout) : la projection EXISTE DÉJÀ, mais en l'état c'est de la dette à formaliser, pas un greenfield. Dans `application/src/agent/lifecycle.rs` on a aujourd'hui :\n- Claude : `seed_cli_permissions` (l.1620) → `claude_settings_seed` (l.2063) → écrit `.claude/settings.local.json`. Sélection IMPLICITE : sniff du nom de convention-file == `CLAUDE.md`. Et surtout **non-clobbering** (`if exists return`, l.1642).\n- Codex : `apply_codex_cli_permission_args` (l.2226, args `--sandbox`/`--ask-for-approval`) + `codex_config_toml` (l.2258, clés `sandbox_mode`/`approval_policy`). Sélection IMPLICITE : branche `McpConfigStrategy::TomlConfigHome` DANS `apply_mcp_config` (l.1838). Donc la projection Codex est PARASITÉE par la présence de MCP : un profil Codex sans MCP ne reçoit aucune sandbox.\n\nLP3 = extraire ça derrière un Port propre, dé-coupler de MCP/convention-sniffing, rendre clobber+nettoyage corrects au swap. Pas de réécriture des règles de traduction (elles sont bonnes et déjà testées l.2957-3068), juste relocalisation + cadrage.\n\n────────────────────────────────────────\n1) CONTRAT DU PORT `PermissionProjector` — VALIDÉ avec 3 corrections\n\nCorrection A — le projecteur est PUR et rend un PLAN, il n'écrit RIEN.\nCalqué sur `AgentRuntime::prepare_invocation` (rend un `SpawnSpec`, c'est `LaunchAgent` qui applique). Idem ici : le projecteur traduit `EffectivePermissions` → valeur ; `LaunchAgent` applique (writes via `self.fs`, fold args/env dans `spec`). Bénéfice : testable sans FS (comme le domaine), I/O centralisée au même endroit que `apply_injection`. On n'injecte PAS `FileSystem` dans chaque projecteur.\n\nSignature (domaine — voir corr. B pour le lieu) :\n```rust\npub struct ProjectionContext<'a> { pub project_root: &'a str, pub run_dir: &'a str }\n\npub enum ProjectedFile {\n /// Fichier 100% possédé par IdeA → clobber au launch, suppression au swap-away.\n Replace { rel_path: String, contents: String },\n /// Fichier co-possédé (ex. config.toml Codex : MCP+trust+sandbox) → merge des\n /// seules clés gérées, JAMAIS supprimé au swap (les autres CLI l'ignorent).\n MergeToml { rel_path: String, managed_tables: Vec, managed_keys: Vec, contents: String },\n}\n\npub struct PermissionProjection {\n pub files: Vec,\n pub args: Vec, // ex. [\"--sandbox\",\"workspace-write\",...]\n pub env: Vec<(String,String)>,\n}\n\npub trait PermissionProjector: Send + Sync {\n fn key(&self) -> ProjectorKey;\n /// PUR. `eff == None` ⇒ projection VIDE (on garde le prompting natif de la CLI :\n /// c'est l'invariant produit de `resolve()` — ne JAMAIS verrouiller un projet non configuré).\n fn project(&self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext) -> PermissionProjection;\n /// Chemins run-dir-relatifs des fichiers `Replace` possédés (pour le nettoyage swap).\n fn owned_replace_paths(&self) -> Vec;\n}\n```\nEntrée : `Option<&EffectivePermissions>` + `ProjectionContext{project_root, run_dir}` (les deux sont nécessaires : Claude embarque `additionalDirectories=[project_root]`, et tout est écrit dans le run dir). Sortie : un PLAN (fichiers + args + env), pas une écriture.\n\nCorrection B — où il vit. Trait + value types `PermissionProjection`/`ProjectedFile`/`ProjectionContext`/`ProjectorKey` → DANS LE DOMAINE (`domain/src/permission.rs`, à côté de `EffectivePermissions`). C'est un port piloté, pur, qui ne référence que des types domaine déjà présents. Les IMPLÉMENTATIONS (`ClaudePermissionProjector`, `CodexPermissionProjector`) → DANS L'INFRASTRUCTURE (`crates/infrastructure/src/permission/` ; à créer). Justification = exactement le pattern `AgentRuntime`(domain) / `CliAgentRuntime`(infra) : le format concret d'un `settings.json` Claude ou des modes sandbox Codex est un détail technique d'UNE CLI ⇒ adapter (§5). On y déplace tel quel `claude_settings_seed`, `apply_codex_cli_permission_args`, `codex_config_toml` (partie permissions) + leurs tests.\n\nCorrection C — découplage du profil concret = registre + clé déclarative (PAS de sniffing).\n- Ajouter au profil déclaratif un champ `projector: Option` (`AgentProfile`, `domain/src/profile.rs`) — cohérent avec « profil = donnée éditable » (§9). Les profils builtin posent `\"claude\"` / `\"codex\"`.\n- Registre `PermissionProjectorRegistry = HashMap>`, construit au composition root (`app-tauri`) et injecté dans les use cases.\n- Sélection : `registry.get(profile.projector)`. `None` ⇒ aucune projection (natif).\n- Fallback de migration (profiles.json déjà sur disque sans le champ) : si `projector == None`, dériver la clé via l'heuristique actuelle (convention-file `CLAUDE.md` → claude ; `StructuredAdapter::Codex` ou `TomlConfigHome` → codex). On garde donc la compat sans imposer un re-seed du store global.\nNB : la clé ne peut PAS être `StructuredAdapter` seul — les profils PTY/TUI (sans structured_adapter) doivent aussi projeter, exactement comme `seed_cli_permissions` le fait aujourd'hui via le nom de fichier. D'où une `ProjectorKey` dédiée.\n\n────────────────────────────────────────\n2) OÙ BRANCHER\n\nChemin de LANCEMENT — `LaunchAgent::execute` (lifecycle.rs ~l.1085) :\n- Injecter `Arc` dans `LaunchAgent` (builder `with_permission_projectors`, optionnel ⇒ zéro régression call-sites/tests legacy, même pattern que `with_handoff_provider`).\n- REMPLACER les deux points actuels par UNE étape unique `apply_permission_projection(&profile, &run_dir, &project_root, eff.as_ref(), &mut spec)` :\n 1. supprimer l'appel `seed_cli_permissions` (l.1234) ;\n 2. SORTIR la projection Codex de `apply_mcp_config` (l.1838/1814/1826) — `apply_mcp_config` ne doit plus toucher `sandbox_mode`/`approval_policy`/`--sandbox` ; il ne fait QUE du MCP.\n- Placement de la nouvelle étape : juste après `apply_injection` (l.1294) et après `apply_mcp_config` (l.1312), donc AVANT le split structuré/PTY (l.1321) et avant `pty.spawn` (l.1361). Critique : c'est en amont du split ⇒ les deux chemins (structuré ET PTY brut) héritent de la projection, comme aujourd'hui. Les `args`/`env` du plan sont foldés dans `spec` avant que `launch_structured` ou `pty.spawn` ne le consomment.\n- Sémantique d'écriture : fichiers `Replace` → CLOBBER systématique (régénérés à chaque (re)launch). Justification déjà actée pour `.mcp.json` (l.1735) : fichier IdeA-managed, non édité par l'utilisateur, sinon la re-projection au swap est IMPOSSIBLE (c'est le bug actuel de `seed_cli_permissions` non-clobbering). Fichiers `MergeToml` → merge des seules clés gérées (réutiliser `set_top_level_toml_value`/`replace_toml_table` existants).\n\nChemin de SWAP/HANDOFF — `ChangeAgentProfile::execute` (lifecycle.rs l.418, relaunch construit en l.573) :\n- Re-projection : AUTOMATIQUE et déjà correcte par construction — `ChangeAgentProfile` compose `LaunchAgent::execute`, qui re-résout `EffectivePermissions` (profil-agnostique, stocké une seule fois) et re-projette via le projecteur du NOUVEAU profil. Aucune nouvelle branche de projection à ajouter ici. Idem pour tous les auto-launch de `orchestrator/service.rs` (l.671, 1296, 1373) qui passent par `LaunchAgent` ⇒ couverts gratuitement.\n- SEULE chose à ajouter dans `ChangeAgentProfile` : le NETTOYAGE de l'ancien profil (cf. §3), exécuté avant la relance. Il faut donc que `ChangeAgentProfile` connaisse l'ancien projecteur + le `FileSystem` (il a déjà l'ancien `profile_id` via le manifeste avant mutation, et le run dir est stable par agent id : `agent_run_dir(root, agent.id)`, l.1217 — invariant clé : le swap RÉUTILISE le même run dir, d'où la nécessité du nettoyage).\n\n────────────────────────────────────────\n3) STRATÉGIE DE NETTOYAGE — règle précise\n\nFait structurant : le run dir est stable par agent id ⇒ après un swap, les fichiers de config du profil précédent SURVIVENT dans le run dir. Un Claude→Codex laisse un `.claude/settings.local.json` (Codex l'ignore : inoffensif fonctionnellement, mais c'est une policy périmée/divergente qui fuit ⇒ à nettoyer). Un Codex→Claude laisse les clés sandbox dans `config.toml` (lu seulement par Codex ⇒ inoffensif).\n\nRègle (deux régimes, selon le type de `ProjectedFile`) :\n- Fichiers `Replace` (100% possédés : `.claude/settings.local.json`) → au swap, supprimer (best-effort) `owned_replace_paths(ancien) − owned_replace_paths(nouveau)`. Au launch normal, clobber (régénérés).\n- Fichiers `MergeToml` (co-possédés : `config.toml` Codex, partagé avec MCP+trust) → JAMAIS supprimés au swap. On retire uniquement les clés gérées (`sandbox_mode`/`approval_policy`) si on swappe AWAY de Codex et qu'on veut être strict ; recommandation pragmatique : ne rien retirer (le fichier n'est lu que par Codex, qui n'est plus actif) — le re-launch Codex futur réécrira les clés. Donc cleanup effectif = suppression des seuls fichiers `Replace` orphelins.\n- Décision produit à acter explicitement : ces fichiers de permission sont IdeA-OWNED (clobber + suppression au swap). Conséquence assumée : une édition manuelle de `.claude/settings.local.json` n'est PAS préservée — la source de vérité est le `PermissionsPanel` / `.ideai/permissions.json`. C'est le SEUL moyen de tenir l'exigence « permissions exportées automatiquement d'une CLI à l'autre au swap ». (Renverse le comportement non-clobbering actuel de `seed_cli_permissions` : à documenter dans le commit.)\n\n────────────────────────────────────────\n4) DÉCOUPAGE DEV/TEST (ordre de livraison)\n\nLP3-1 — DOMAINE (port + clé). Dev : ajouter `PermissionProjector`, `PermissionProjection`, `ProjectedFile`, `ProjectionContext`, `ProjectorKey` dans `domain/src/permission.rs` ; ajouter `projector: Option` à `AgentProfile` (+ `#[serde(default)]`, builder, builtins claude/codex). QA : (dé)sérialisation du profil avec/sans le champ (compat) ; defaults builtin. `cargo test -p domain`.\n\nLP3-2 — INFRA (projecteurs). Dev : créer `infrastructure/src/permission/` ; y DÉPLACER `claude_settings_seed`+helpers et `apply_codex_cli_permission_args`+`codex_config_toml`(partie perms) sous `ClaudePermissionProjector` / `CodexPermissionProjector` (purs, rendent un `PermissionProjection`) ; implémenter `owned_replace_paths`. QA : golden par posture (Allow/Ask/Deny), deny-wins, escaping JSON, args Codex, clés TOML — relocaliser/réutiliser les tests existants (lifecycle.rs l.2957-3068). `cargo test -p infrastructure`.\n\nLP3-3 — APPLICATION (wiring launch + dé-couplage MCP). Dev : `PermissionProjectorRegistry` + injection dans `LaunchAgent` (builder optionnel) ; nouvelle étape `apply_permission_projection` ; SUPPRIMER `seed_cli_permissions` ; PURGER la projection perms hors de `apply_mcp_config`. QA (fs mocké) : profil Claude → settings écrit ET clobbé au 2e launch ; profil Codex SANS MCP → args sandbox foldés + config.toml écrit (preuve du dé-couplage MCP) ; `projector=None` → rien d'écrit, rien dans spec ; `eff=None` → projection vide. `cargo test -p application`.\n\nLP3-4 — SWAP (nettoyage). Dev : injecter registry+fs dans `ChangeAgentProfile` ; avant relaunch, supprimer les `Replace` orphelins (ancien−nouveau). QA : Claude→Codex supprime `.claude/settings.local.json` et écrit la config Codex ; Codex→Claude réécrit `settings.local.json` frais reflétant les perms re-résolues (clobber) ; swap vers profil `projector=None` nettoie sans réécrire ; vérifier que l'id de paire + handoff (P8d) restent intacts (non-régression).\n\nLP3-5 — COMPOSITION ROOT (`app-tauri`). Dev : instancier le registre (claude+codex) et l'injecter dans `LaunchAgent` ET `ChangeAgentProfile`. QA : smoke de wiring + `cargo test` workspace complet vert + suites permissions existantes vertes.\n\nRègle d'or maintenue : aucun lot vert tant que `cargo test -p ` du lot ne passe pas ; LP3-5 exige le workspace entier vert.\n\nJe ne code rien : voilà le contrat (port pur rendant un plan, dans le domaine ; impls dans l'infra ; registre par clé déclarative de profil) et le plan en 5 sous-lots. Point d'attention n°1 pour DevBackend : c'est d'abord un refactor d'extraction (le code de traduction existe et est testé), dont le cœur est le dé-couplage Codex↔MCP et le passage clobber+nettoyage."} +{"id":"be496e68-92fc-4a6f-a0ce-51f2fc3a9022","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781545404355,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[Cadrage LP4 — enforcement OS des permissions] Le chantier permissions a livré la voie \"projection CLI\" (advisory) : modèle domaine pur (EffectivePermissions, resolve deny-wins/postures), FsPermissionStore, use cases CRUD+resolve, projecteurs Claude/Codex écrivant settings.local.json / config.toml au launch, composition root + commandes Tauri, UI PermissionsPanel. Tout vert.\n\nGap restant confirmé dans le code (marqué \"out of scope\" : domain/permission.rs:12, domain/fileguard.rs:16, infrastructure/fileguard/mod.rs:18, application/orchestrator/context_guard.rs:23) : l'enforcement OS *airtight* (révocation réelle des accès FS), que la mémoire archi décrit comme \"sandbox OS Landlock + résumé injecté\". Aucun générateur de résumé ni échafaudage Landlock n'existe encore.\n\nTa mission (cadrage uniquement, pas de code) : définis le contrat du lot LP4 en respectant l'hexagonal :\n1. Le PORT domaine (nom, signature) qui transforme EffectivePermissions résolues en un plan d'enforcement OS pur (rulesets path RO/RW/exec), sans I/O ni dépendance Landlock dans le domaine.\n2. La frontière exacte : où s'applique le ruleset (process agent au spawn PTY, pré-exec) vs le rôle de l'adapter infra Landlock (Linux d'abord ; Windows/macOS = stubs no-op explicites). Adapters par OS, pas par CLI.\n3. Le point de câblage dans le launch-path (lifecycle.rs) en cohérence avec la projection déjà écrite (même EffectivePermissions).\n4. Le volet \"résumé de permissions injecté\" dans le contexte .md de l'agent : port/fonction pure de génération + point d'injection (à côté du convention file/seed).\n5. La stratégie de test par couche (domaine/app sans I/O via fakes ; test d'intégration Linux réel sous-process accès refusé).\n\nLivre la cartographie : nouveaux fichiers/modules, traits, et l'ordre d'implémentation des sous-lots. Réponds via idea_reply."} +{"id":"c9c58786-9810-4b5d-bd03-9e176dc8f28b","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781545642146,"source":{"kind":"agent","agentId":"dce19c75-9669-4e45-b8de-9950025157da"},"role":"response","text":"# Cadrage LP4 — Enforcement OS *airtight* des permissions (Landlock + résumé injecté)\n\nCadrage uniquement. Le lot **réutilise la projection LP3 à l'identique** (même `EffectivePermissions` résolues, même seam, même invariant `None ⇒ on ne projette rien`) et ajoute une **seconde voie d'application** : non plus advisory (settings CLI) mais *airtight* (sandbox OS au pré-exec). On ne touche ni au modèle domaine existant, ni à `resolve`, ni aux projecteurs LP3.\n\n---\n\n## 0. Borne de réalité à graver d'emblée (sinon le lot ment sur ce qu'il garantit)\n\nLandlock ne sait restreindre **que la dimension système de fichiers** : accéder *sous* teltle hiérarchie en read / write / execute (par fd, pas par glob). Il en découle deux frontières dures, à acter avant tout code :\n\n1. **Globs ≠ hiérarchies Landlock.** `EffectivePermissions` exprime des `PathScope` en **globs relatifs** (`src/**`, `**/*.rs`) avec **deny-wins par cible**. Landlock ne connaît ni glob ni *deny* — il **accorde** l'accès sous des **préfixes de chemins concrets**. La compilation domaine doit donc traduire les globs en un **ensemble de racines absolues autorisées**, calculé **fail-closed** : quand un `Deny` tombe à l'intérieur d'un `Allow` sans frontière de répertoire qui les sépare (`**/*.rs` avec un deny sur un fichier), on **n'accorde pas le parent** (on perd un allow plutôt que de laisser fuiter un deny). Ceci est une **sur-/sous-approximation conservatrice**, à poser comme invariant domaine testable.\n2. **`ExecuteBash` (matching par chaîne de commande) n'est PAS enforceable par Landlock.** Landlock gate l'exécution *de fichiers par chemin*, pas la sémantique d'`argv` (« deny `rm ` »). Donc : la dimension **fichiers (Read/Write/Delete → RO/RW)** devient *airtight* ; la dimension **commandes** reste **advisory** (projection LP3 + résumé injecté). À écrire noir sur blanc dans le doc et dans le résumé injecté (« les règles de commande sont conseillées, pas verrouillées OS »). seccomp-argv = hors scope.\n\n→ Le lot LP4 livre l'enforcement **fichiers**. C'est la moitié réellement verrouillable, et c'est exactement ce que la mémoire archi (« sandbox OS Landlock + résumé injecté ») cible.\n\n---\n\n## 1. Le PORT domaine — transform `EffectivePermissions → plan OS pur`\n\nDeux objets distincts, à ne pas confondre (c'est l'erreur classique) :\n\n### 1.a — Le compilateur de plan = **fonction pure**, pas un trait\nIl existe **une seule** traduction canonique règles→ruleset : c'est de la logique domaine, pas une stratégie à variantes. On mirror `resolve()` (fonction libre, totale, déterministe), **pas** `PermissionProjector` (trait, car là il y avait N CLI). Nouveau module `crates/domain/src/sandbox.rs` :\n\n```rust\n/// Plan d'enforcement OS, neutre vis-à-vis de l'OS. Value object pur.\npub struct SandboxPlan {\n /// Racines absolues accessibles, chacune avec son mode (lecture seule,\n /// lecture/écriture, exécution). Calculées fail-closed depuis les globs.\n pub allowed: Vec,\n /// Posture résiduelle (Allow ⇒ plan permissif/vide ; Deny ⇒ tout interdit\n /// hors `allowed` ; Ask ⇒ on ne sandboxe pas — voir §0/invariant None).\n pub default_posture: Posture,\n}\npub struct PathGrant { pub abs_root: String, pub access: PathAccess /* Ro | Rw | Exec (bitflags) */ }\n\npub struct SandboxContext<'a> { pub project_root: &'a str, pub run_dir: &'a str }\n\n/// LE transform demandé. Pur : zéro I/O, zéro dépendance Landlock.\n/// `eff == None` ⇒ `None` (rien posé ⇒ pas de sandbox ⇒ comportement natif —\n/// même invariant produit que `resolve`/`PermissionProjection::empty`).\npub fn compile_sandbox_plan(\n eff: Option<&EffectivePermissions>,\n ctx: &SandboxContext,\n) -> Option;\n```\n\n### 1.b — Le PORT (au sens hexagonal) = **l'enforcer**, implémenté par adapter par OS\nC'est lui qui a des adapters (le « D » de SOLID s'applique ici, pas au compilateur). Domaine `sandbox.rs` :\n\n```rust\npub trait SandboxEnforcer: Send + Sync {\n fn kind(&self) -> SandboxKind; // Landlock | Unsupported\n /// Installe le plan sur le **processus courant**, irréversiblement.\n /// Contrat L (Liskov) : appelé **uniquement post-fork, pré-exec**, dans\n /// l'enfant. Un adapter Unsupported renvoie Ok(Unsupported) sans rien faire.\n fn enforce(&self, plan: &SandboxPlan) -> Result;\n}\npub enum SandboxStatus { Enforced, Unsupported, Degraded(String) }\npub enum SandboxError { /* kernel trop vieux en mode fail-closed, etc. */ }\n```\n\nPlus, pour le volet (4), une fonction pure dans `permission.rs` (à côté de `resolve`) :\n```rust\n/// Bloc Markdown résumant la politique effective pour le contexte de l'agent.\n/// `None` eff ⇒ `None` (pas de bloc ⇒ prompting natif). Pur.\npub fn render_permission_summary(eff: Option<&EffectivePermissions>) -> Option;\n```\n\n**Champ porté par `SpawnSpec`** (`domain/src/ports.rs`) : le plan est *structurel* (pas args/env), donc nouveau champ\n```rust\npub sandbox: Option, // None ⇒ pas d'enforcement (natif)\n```\n(à l'image de la façon dont la projection LP3 foldait args/env, mais ici structurel).\n\n---\n\n## 2. La frontière exacte d'application\n\n- **Où** : sur le **processus agent**, **post-fork / pré-`execve`**, via un **hook `pre_exec`** (Unix) câblé par l'adapter PTY. **Jamais sur le parent IdeA** — Landlock est hérité et irréversible ; le poser sur IdeA se sandboxerait soi-même. Posé dans l'enfant, il couvre la CLI agent **et toute sa descendance** (shell, sous-process) → c'est ça l'airtight que la voie advisory ne donne pas, et qui ferme le trou « agent qui garde un shell brut » documenté dans `fileguard/mod.rs:16`.\n- **Adapter Linux** `LandlockSandbox` (`#[cfg(target_os=\"linux\")]`) : traduit `SandboxPlan` → ruleset Landlock (`path_beneath` + accès RO/RW/Exec), crée et restreint le ruleset sur le thread appelant (l'enfant). Crate `landlock` (best-effort ABI : compat-mode pour kernels < feature). **Décision à trancher (point ouvert)** : kernel sans Landlock (< 5.13) ou ABI insuffisante ⇒ **fail-closed** (refuser le launch) si `default_posture == Deny`, **fail-open + warning** sinon. Défaut recommandé : fail-open journalisé, *sauf* posture Deny. À valider produit.\n- **Adapters Windows / macOS** `NoopSandbox` : stubs **explicites** renvoyant `SandboxStatus::Unsupported`, jamais une erreur. Documentés. Évolution future (AppContainer / `sandbox_init`) = **nouvel adapter, zéro changement domaine** (Open/Closed). **Adapters par OS, pas par CLI** : respecté — un seul `SandboxPlan` quelle que soit la CLI ; c'est l'OS qui choisit l'adapter.\n- **SSH / WSL** : l'agent tourne ailleurs ⇒ `NoopSandbox` côté local pour l'instant ; enforcement distant = chantier ultérieur.\n\n---\n\n## 3. Point de câblage dans le launch-path (`lifecycle.rs`)\n\nCohérent avec la projection déjà écrite : **mêmes `effective_permissions`** résolues une seule fois à `lifecycle.rs:1419` (`resolve_effective_permissions`).\n\n- **Nouveau step 5d**, juste **après** `apply_permission_projection` (5c, ~L1504) et **avant** le split structuré/PTY (5b, L1520) et le spawn (step 6). Nouveau helper :\n ```rust\n // 5d. Compile le plan de sandbox OS depuis LES MÊMES EffectivePermissions\n // que la projection LP3, et le pose sur le spec. None ⇒ pas de sandbox.\n self.apply_sandbox_plan(effective_permissions.as_ref(), &input.project.root,\n &run_dir, &mut spec);\n ```\n Il appelle `compile_sandbox_plan(eff, &SandboxContext{ project_root, run_dir })` et fait `spec.sandbox = plan`. **L'application reste pure d'OS** : l'enforcer (Landlock) ne vit pas dans `LaunchAgent`, il vit dans l'adapter PTY.\n- **Consommation** : `PortablePtyAdapter` reçoit `Arc` au composition root et, dans `spawn`, si `spec.sandbox.is_some()`, installe un `pre_exec` qui capture `(enforcer, plan)` et appelle `enforcer.enforce(&plan)` dans l'enfant avant exec.\n- **Résumé injecté (volet 4)** : threader `effective_permissions.as_ref()` jusqu'à `apply_injection` (L1470) et y composer `render_permission_summary(eff)` **à côté de la mémoire projet et du handoff** (mêmes lignes ~1448-1481, même convention file). `None` ⇒ aucun bloc. Source de vérité unique : le **même** `eff` que projection + sandbox.\n- **Portée lot 1** : enforcement câblé sur le **chemin PTY** (agents terminal bruts) — c'est lui qui porte l'intégration Landlock et le test réel. Le chemin **structuré** (`AgentSessionFactory::start`, L1617) a un autre seam de spawn ⇒ **sous-lot de suivi LP4-4** (étendre le pre_exec/plan au factory). À flagger pour ne pas prétendre une couverture qu'on n'a pas encore.\n\n---\n\n## 4. Volet « résumé injecté » — déjà couvert ci-dessus (1.b + 3)\nFonction **pure** `render_permission_summary` (domaine), injectée dans le convention file via `apply_injection`, au même endroit que seed/mémoire/handoff. Doit mentionner explicitement la borne §0.2 (« règles de commandes = conseillées, fichiers = verrouillés OS quand supporté »). Aucun port nouveau pour ce volet.\n\n---\n\n## 5. Stratégie de test par couche\n\n| Couche | Type | Contenu |\n|---|---|---|\n| **domaine** | unitaire pur, sans I/O ni fake | `compile_sandbox_plan` : globs→racines absolues ; **deny-wins ⇒ widening fail-closed** (deny dans `**` ⇒ parent non accordé) ; `None ⇒ None` ; mapping RO/RW/Exec. `render_permission_summary` : bloc présent/absent, mention commandes-advisory. Déterministe, zéro mock. |\n| **application** | unitaire, **fake `SandboxEnforcer`** + fake fs | `apply_sandbox_plan` pose `spec.sandbox` depuis le **même** `EffectivePermissions` que la projection ; `None` posé ⇒ `spec.sandbox == None`. `apply_injection` insère le résumé ssi `eff.is_some()`. **Aucun vrai Landlock.** |\n| **infra** | **intégration Linux réelle**, gated | `LandlockSandbox` : sous-process lancé sous un plan qui **deny l'écriture** d'un tmp path ⇒ assert l'écriture enfant échoue (`EACCES`) et qu'un path autorisé réussit ; deny exec ⇒ `execve` refusé. Gating : `#[cfg(target_os=\"linux\")]` + garde runtime de disponibilité Landlock (sinon skip), à la manière des tests SSH/WSL `#[ignore]`. `NoopSandbox` : `enforce` ⇒ `Unsupported`, jamais d'erreur. |\n| **app-tauri** | wiring | Sélection `cfg!`-dépendante du bon enforcer + injection dans l'adapter PTY. |\n\n---\n\n## 6. Cartographie — nouveaux fichiers / ordre des sous-lots\n\n**Nouveaux / modifiés :**\n- `crates/domain/src/sandbox.rs` *(new)* : `SandboxPlan`, `PathGrant`/`PathAccess`, `SandboxContext`, `SandboxKind`, `SandboxStatus`, `SandboxError`, trait `SandboxEnforcer`, fn `compile_sandbox_plan`. Export `lib.rs`.\n- `crates/domain/src/permission.rs` *(mod)* : `render_permission_summary`.\n- `crates/domain/src/ports.rs` *(mod)* : `SpawnSpec.sandbox: Option`.\n- `crates/infrastructure/src/sandbox/{mod,landlock,noop}.rs` *(new)* : adapters par OS.\n- `crates/infrastructure/src/pty/…` *(mod)* : `PortablePtyAdapter` reçoit `Arc` + `pre_exec`.\n- `crates/application/src/agent/lifecycle.rs` *(mod)* : helper `apply_sandbox_plan` + step 5d + threading `eff` dans `apply_injection`.\n- `crates/app-tauri/src/state.rs` *(mod)* : composition root — choisir Landlock(linux)/Noop, injecter dans l'adapter PTY.\n\n**Ordre d'implémentation (chaque sous-lot vert avant le suivant, cycle dev/test) :**\n1. **LP4-0 (domaine, pur)** — `sandbox.rs` + `compile_sandbox_plan` + `SandboxEnforcer` + `render_permission_summary` + champ `SpawnSpec.sandbox`. Tests purs (globs→racines, fail-closed, résumé). *Aucun comportement runtime encore — zéro régression.*\n2. **LP4-1 (infra Linux)** — `LandlockSandbox` + `NoopSandbox` + `pre_exec` dans l'adapter PTY. Test d'intégration Linux réel (accès refusé).\n3. **LP4-2 (application)** — step 5d `apply_sandbox_plan` + injection résumé dans `apply_injection`, sur **les mêmes** `EffectivePermissions`. Tests fake-enforcer.\n4. **LP4-3 (composition root)** — câblage app-tauri par OS + valid. e2e AppImage (⚠️ rebuild AppImage, pas seulement sources — cf. mémoire `mcp-bridge-and-delegation-runtime-notes`).\n5. **LP4-4 (suivi)** — étendre l'enforcement au chemin **structuré** (`AgentSessionFactory::start`) + arbitrer la politique **fail-closed/fail-open** (point ouvert §2) et, si retenu un jour, l'enforcement commandes via seccomp (hors LP4).\n\n**Invariant transversal à garder vert** : `eff == None` ⇒ ni projection, ni sandbox, ni résumé ⇒ CLI 100 % native. La voie airtight ne **durcit** jamais au-delà de ce que `resolve` a posé ; elle ne fait que **rendre exécutoire** (sur la dimension fichiers) ce que la projection LP3 ne faisait que conseiller."} +{"id":"b13cd4f5-f794-44c9-a92e-8117f4f8ffa3","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781590643526,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Validation d'approche AVANT dev — lot LP4-4 : étendre l'enforcement OS Landlock au chemin structuré (sessions Claude/Codex en mode JSON), aujourd'hui non sandboxé.\n\nÉtat actuel (committé, vert) :\n- Domaine pur : `domain::sandbox` avec `SandboxPlan`, `SandboxContext`, `compile_sandbox_plan(eff, ctx) -> Option`, et le port `SandboxEnforcer { fn enforce(&self, &SandboxPlan) -> Result; fn kind(); }`. Adapters infra : `LandlockSandbox` (Linux), `NoopSandbox`, `default_enforcer()`.\n- Chemin PTY (`crates/infrastructure/src/pty/mod.rs`) : OS-enforcé. Technique retenue = `spawn_command_sandboxed` : un THREAD JETABLE appelle `enforcer.enforce(plan)` (restriction Landlock irréversible, donc confinée au thread) PUIS spawn le child depuis ce thread → le domaine Landlock est hérité par le child via fork+exec. portable-pty n'expose pas de pre_exec injectable, d'où ce choix ; il évite aussi le piège de l'allocation non async-signal-safe dans un pre_exec post-fork en process multithreadé.\n- Chemin structuré (`crates/infrastructure/src/session/process.rs::drain`) : spawn via `tokio::process::Command` (async), draine stdout ligne-à-ligne jusqu'à EOF. `SpawnLine` { command, args, cwd, env, stdin } ne porte aucun plan. C'est l'incarnation « un exec par tour ».\n\nQuestion d'archi à trancher : comment enforcer le `SandboxPlan` sur le child spawné par `tokio::process::Command` en respectant l'hexagonal ET la sûreté (le truc du thread jetable du PTY ne transpose pas tel quel au spawn async tokio qui forke sur les threads du runtime) ? Pistes à arbitrer :\n (a) `CommandExt::pre_exec` (tokio le réexpose) appelant l'enforce dans le child post-fork — simple mais risque async-signal-safety si `landlock` alloue ;\n (b) déporter le spawn du tour structuré sur un thread dédié restreint (via `spawn_blocking` + thread jetable comme le PTY), au prix de la nature async du drain ;\n (c) un helper de spawn sandboxé partagé entre PTY et session (factoriser la technique) ;\n (d) autre.\n\nLivrable attendu (via idea_reply) : la stratégie retenue + justification (sûreté + hexagonal), le contrat à modifier (faut-il que `SpawnLine` porte le plan ? l'enforcer est-il injecté dans la session factory comme il l'est dans le PTY adapter ?), le découpage des fichiers à toucher, et les invariants/tests que QA devra vérifier. NE code rien. Si une piste est dangereuse (ex. allocation post-fork), dis-le explicitement."} +{"id":"a5ce6417-1f71-42eb-95b2-549a378ec580","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781590879663,"source":{"kind":"agent","agentId":"dce19c75-9669-4e45-b8de-9950025157da"},"role":"response","text":"# LP4-4 — Validation d'approche : enforcement Landlock sur le chemin structuré\n\n## Verdict\nStratégie retenue : **(b) raffiné — spawn du tour structuré sandboxé sur un thread jetable restreint, en spawn `std` synchrone**, exposé à l'async via `spawn_blocking`. C'est la transposition directe et sûre de la technique déjà prouvée du PTY. **Piste (a) `pre_exec(enforce)` REJETÉE** comme dangereuse (détail plus bas). **(c) factorisation** : oui sur le *principe*, non sur le *corps* — les deux spawns sont trop différents (portable-pty `SlavePty` vs `std::process`).\n\n---\n\n## Pourquoi PAS (a) `pre_exec(enforce)` — DANGER explicite\n`tokio::process::Command` réexpose bien `CommandExt::pre_exec`, mais le closure s'exécute **dans l'enfant après `fork`, avant `exec`**, dans un process **multithreadé** (les workers du runtime tokio). Après `fork` en contexte multithreadé, seules les opérations **async-signal-safe** sont permises : tout mutex tenu par un autre thread au moment du fork (typiquement le lock de l'arène malloc) reste verrouillé à jamais dans l'enfant. Or `landlock::Ruleset::…restrict_self()` **alloue** (construction du ruleset, `RestrictionStatus`, Vec internes) → `malloc` post-fork → **risque de deadlock de l'enfant**. C'est exactement le piège que le commentaire du PTY (`spawn_command_sandboxed`, lignes 185-206) documente et évite. La cohérence d'archi impose de le rejeter ici aussi.\n\n> Note : il *existe* une variante async-signal-safe de Landlock (construire le ruleset_fd dans le parent, ne faire que `prctl(NO_NEW_PRIVS)` + `landlock_restrict_self(fd)` — deux syscalls purs — dans le pre_exec). Mais elle force à **scinder le port `SandboxEnforcer` en deux phases** (prepare allouant / commit syscall-only) et fait fuiter cette mécanique dans le domaine. Inutile : la technique du thread jetable atteint la même sûreté **sans toucher au contrat de port**. Je la mentionne pour mémoire, je ne la recommande pas.\n\n## Pourquoi (b) est sûr\nLa technique du PTY ne fait **rien** dans l'enfant post-fork : `enforce(plan)` tourne dans le **thread jetable parent AVANT le fork** ; l'enfant **hérite** simplement le domaine Landlock (hérité across `fork`, préservé across `execve`). Zéro code post-fork ⇒ **zéro problème d'async-signal-safety**. C'est toute l'élégance, et elle transpose telle quelle.\n\nLa seule difficulté est que `tokio::process::Command` **forke sur un worker partagé du runtime** (qu'on ne peut pas restreindre : la restriction est irréversible → on empoisonnerait le runtime). D'où : pour le chemin sandboxé on **abandonne le drain async tokio** au profit d'un **drain `std` synchrone sur le thread jetable restreint**, réconcilié à l'async par `spawn_blocking`. Le chemin non-sandboxé (`sandbox == None` **ou** pas d'enforcer) **reste l'actuel drain async tokio, inchangé** (zéro régression, c'est aussi le seul chemin sur non-Linux).\n\nDétails de sûreté du thread sandboxé :\n1. `enforcer.enforce(&plan)` restreint CE thread (fail-closed : `Err` ⇒ on échoue le tour, **aucun child ne tourne**) ;\n2. `std::process::Command::spawn()` depuis ce thread ⇒ l'enfant hérite le domaine ;\n3. poser un `unsafe { cmd.pre_exec(|| Ok(())) }` **vide** (async-signal-safe) pour **forcer le chemin `fork`+`exec`** déterministe (parité avec portable-pty, lève tout doute vs un éventuel `posix_spawn` glibc — l'héritage tiendrait de toute façon, mais on ne parie pas) ;\n4. drain bloquant ligne-à-ligne stdin/stdout → EOF → `wait()` → `Vec` ;\n5. le thread meurt, emportant sa restriction irréversible ; les autres threads d'IdeA sont intouchés.\n\n**Timeout** : aujourd'hui `run_turn` enveloppe le `drain` async. En sandboxé, on enveloppe le `JoinHandle` du thread via `tokio::time::timeout` ; à expiration il faut **tuer le child** (le thread est bloqué en read). Donc le thread renvoie son *killer* (pid / `Arc>`) par un `oneshot` dès après spawn ; le kill provoque l'EOF ⇒ le read débloque ⇒ le thread finit ⇒ on retourne `Timeout`. À implémenter proprement (c'est le seul vrai surcoût de machinerie vs l'élégance actuelle).\n\n---\n\n## Contrat à modifier\n\n1. **`SpawnLine` porte le plan** (oui) — `crates/infrastructure/src/session/process.rs` : ajouter `pub sandbox: Option`. Symétrie avec `SpawnSpec.sandbox`. `SpawnLine` est un DTO **infra** (pas domaine), donc OK.\n\n2. **L'enforcer est injecté dans la factory** (oui, comme le PTY adapter) — `StructuredSessionFactory` gagne un champ `Option>` + un builder `with_sandbox_enforcer(...)`, jumeau exact de `PortablePtyAdapter::with_sandbox_enforcer`. **Pas** dans la signature du port : c'est une dépendance de composition root, par-instance, pas par-appel.\n\n3. **Le plan traverse le port par-appel** — le `SandboxPlan` dépend des permissions résolues de l'agent, il est calculé par-lancement dans `lifecycle.rs` step 5d (`spec.sandbox`). Il doit donc passer **dans `AgentSessionFactory::start`** : ajouter `sandbox: Option<&SandboxPlan>`. `SandboxPlan` est un type **domaine** (`domain::sandbox`) franchissant un **port domaine** — cohérent (déjà le cas via `SpawnSpec.sandbox` sur `AgentRuntime`). Extension O/C : une seule vraie impl + les fakes.\n\nFlux complet : `lifecycle.rs` (calcule `spec.sandbox`) → passe le plan à `launch_structured` (qui ne reçoit pas `spec` aujourd'hui) → `factory.start(..., sandbox)` → la factory apparie `sandbox` (plan, par-appel) + son enforcer (par-instance) et les passe à `ClaudeSdkSession::new` / `CodexExecSession::new` → l'adapter les stocke, remplit `SpawnLine.sandbox`, et passe l'enforcer à `run_turn`.\n\n4. **`run_turn`** — `crates/infrastructure/src/session/process.rs` : signature `run_turn(spec, enforcer: Option<&Arc>, timeout)`. Si `spec.sandbox.is_some() && enforcer.is_some()` ⇒ drain sandboxé (thread restreint) ; sinon ⇒ `drain` async actuel **strictement inchangé**.\n\n---\n\n## Découpage des fichiers à toucher\n- `crates/domain/src/ports.rs:537` — `AgentSessionFactory::start` : + `sandbox: Option<&SandboxPlan>`.\n- `crates/infrastructure/src/session/process.rs` — champ `SpawnLine.sandbox` ; `run_turn` reçoit l'enforcer ; nouveau `drain_sandboxed` (thread jetable + std spawn + pre_exec vide + timeout par kill).\n- `crates/infrastructure/src/session/factory.rs` — champ `Option>` + `with_sandbox_enforcer` ; `start` apparie plan+enforcer et les injecte dans les ctors d'adapters.\n- `crates/infrastructure/src/session/claude.rs` (`build_spawn_line` ~L187, `send` ~L195) & `codex.rs` (~L162/L195) — stocker plan+enforcer, remplir `SpawnLine.sandbox`, passer l'enforcer à `run_turn`.\n- `crates/application/src/agent/lifecycle.rs` — `launch_structured` (~L1620) reçoit le plan (`spec.sandbox`) et le relaie à `factory.start`.\n- `crates/app-tauri/src/state.rs:408` — `StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer())`.\n- **Fakes à mettre à jour** (nouvelle signature `start`) : `crates/domain/tests/structured_session_d0.rs`, `crates/application/tests/structured_launch_d3.rs`, `crates/application/tests/orchestrator_service.rs`.\n\n---\n\n## Invariants & tests pour la QA\n1. **Parité avec le PTY (le test pivot)** : réplique de `pty_spawn_enforces_sandbox_plan_end_to_end` côté structuré, avec un **fake CLI / `sh`** qui émet une ligne JSONL et tente d'écrire **hors** grant (doit être bloqué kernel) et **dans** grant (doit réussir). **Zéro token** (aucun vrai claude/codex). Gardé derrière `landlock_is_enforced()` (skip propre sur kernel sans Landlock).\n2. **Companion négatif** : même factory+enforcer mais `spec.sandbox == None` ⇒ l'écriture hors-grant **réussit** (prouve que le blocage vient du plan, pas d'une restriction ambiante).\n3. **Fail-closed** : posture `Deny` sur kernel sans Landlock ⇒ `enforce` `Err` ⇒ `run_turn` renvoie une erreur et **aucun child ne tourne** (assert : le marqueur de sortie du fake CLI n'existe pas).\n4. **No-op par défaut** : `eff == None` ⇒ `compile_sandbox_plan` `None` ⇒ `spec.sandbox None` ⇒ chemin async tokio actuel, comportement natif (régression nulle). Vérifier que la suite structurée existante (conformance, D0/D3) reste **verte sans modification de comportement**.\n5. **Confinement de l'irréversibilité** : après un tour sandboxé, un **autre** tour (autre session, plan plus large) sur le même process IdeA n'est PAS affecté par la restriction du tour précédent (le thread jetable est mort). Test : deux tours successifs avec des grants disjoints, chacun voit son propre périmètre.\n6. **Timeout sous sandbox** : un fake CLI qui ne ferme jamais stdout ⇒ `run_turn(timeout)` tue le child et renvoie `Timeout`, sans laisser de zombie ni de thread bloqué.\n7. **Resume préservé** : le `conversation_id` est toujours capté correctement après un tour sandboxé (la restriction FS ne casse pas le parsing du `session_id`).\n\n## ⚠️ Risque produit à signaler (hors mécanisme, mais bloquant à valider)\nLes CLI structurées (claude/codex = binaires Node) ont des besoins FS ambiants **bien plus lourds** qu'un `sh` : lecture de `~/.claude` / `~/.codex` (credentials, cache de session pour le resume), `node_modules`, libs système, écriture de caches temporaires. `compile_sandbox_plan` ne **fence en écriture/lecture que les classes explicitement posées** et garde les lectures globalement ouvertes — mais si une policy pose un `Deny`/posture restrictive touchant `$HOME`, le **resume peut casser** (la CLI n'écrit plus son cache de session). Recommandation : LP4-4 livre le **mécanisme** validé au fake CLI (zéro token, points 1-7), et on **flag** qu'une validation e2e manuelle d'un vrai tour claude/codex sous un plan représentatif doit confirmer que le run dir + le home de la CLI restent atteignables avant d'activer le sandbox en prod pour le chemin structuré. C'est un sujet de **composition du plan** (run_dir reachability, déjà réservé dans `SandboxContext.run_dir` mais non consommé par la traduction pure), pas du mécanisme d'enforcement — à traiter en lot suivant si besoin.\n\n---\n**Résumé** : (b) avec thread jetable restreint + std spawn + `spawn_blocking`, fail-closed identique au PTY ; `SpawnLine.sandbox` ajouté ; enforcer injecté par construction dans la factory ; plan passé par-appel via `AgentSessionFactory::start(.., sandbox)`. (a) rejetée (alloc post-fork). Ne rien coder avant ce GO — QA doit pouvoir s'appuyer sur les 7 invariants ci-dessus."} +{"id":"8e17795b-57a2-4335-a316-a48248faf499","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781591804088,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Arbitrage LP4-4 — une divergence est remontée par DevBackend sur ton cadrage, elle touche un invariant crate-wide. Verdict requis.\n\nTon cadrage demandait, dans `drain_sandboxed` (chemin structuré), un `unsafe { cmd.pre_exec(|| Ok(())) }` VIDE pour forcer std sur le chemin déterministe fork+exec (parité avec portable-pty).\n\nBlocage : la crate `infrastructure` est `#![forbid(unsafe_code)]` (lib.rs:12). Un `forbid` ne peut pas être relâché par un `#[allow]` local. Le PTY n'avait jamais eu besoin d'unsafe car il hérite le fork+exec du pre_exec INTERNE de portable-pty ; sur le chemin structuré on pilote `std::process` en direct, donc le pre_exec serait à nous → unsafe → interdit.\n\nDevBackend a retenu (et livré, build+tests verts) l'option (a) : RETIRER le pre_exec. Son argument : `landlock_restrict_self` restreint le thread courant et toute sa descendance ; l'héritage est assuré par le noyau à travers fork/clone/vfork ET préservé par execve, Y COMPRIS via posix_spawn (= clone+execve), car l'enforcement vit au niveau des credentials de la tâche, hors d'atteinte de l'espace utilisateur. Le fork+exec n'était donc qu'une garantie de DÉTERMINISME du syscall de spawn, pas de sécurité ; enforce()-avant-spawn-sur-le-même-thread suffit.\n\nLes deux options sur la table :\n(a) rester comme livré : zéro unsafe, héritage noyau (choix par défaut de DevBackend).\n(b) passer lib.rs de `#![forbid(unsafe_code)]` à `#![deny(unsafe_code)]` + `#[allow(unsafe_code)]` scoppé sur drain_sandboxed pour rétablir le pre_exec vide.\n\nQuestion : valides-tu (a) — l'héritage de la restriction Landlock est-il bien garanti par le noyau indépendamment de fork+exec vs posix_spawn, rendant le pre_exec inutile à la sécurité — ou exiges-tu (b) (relâcher l'invariant unsafe-code de la crate) ? Si (a), confirme qu'on garde `#![forbid(unsafe_code)]` intact. Réponds avec le verdict + justification courte ; c'est juste cet arbitrage, pas une re-revue complète."} +{"id":"b41c78ed-f731-4552-9917-a95e63e16847","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781591843916,"source":{"kind":"agent","agentId":"dce19c75-9669-4e45-b8de-9950025157da"},"role":"response","text":"# Verdict LP4-4 : **(a) validée — on garde `#![forbid(unsafe_code)]` intact**\n\nDevBackend a raison, et son choix est même **cohérent avec mon cadrage initial** : j'y avais explicitement écrit que l'héritage tient sans le pre_exec (« even without it inheritance holds across posix_spawn's clone »), le pre_exec vide n'étant qualifié que d'« assurance de déterminisme », **jamais** de garantie de sécurité. Le retirer ne retire donc aucune propriété de sûreté.\n\n## Justification technique (l'héritage est bien garanti par le noyau)\n- L'enforcement Landlock est attaché aux **credentials de la tâche** (le domaine Landlock vit dans la `cred`, sous NO_NEW_PRIVS — que `restrict_self` pose lui-même). C'est de l'état **noyau**, hors d'atteinte de l'espace utilisateur.\n- `landlock_restrict_self` restreint le **thread appelant** ; tout enfant créé **par ce thread** hérite ses credentials → donc le domaine Landlock. L'héritage se fait à travers `fork`/`clone`/`vfork` et est **préservé par `execve`**.\n- `posix_spawn` (glibc) = `clone(CLONE_VM|CLONE_VFORK)` + `execve` émis **depuis le thread appelant** dans le même process : l'enfant hérite les creds exactement comme via fork+exec. Le mécanisme de spawn ne change que le **déterminisme du syscall**, pas l'héritage.\n\nLa seule précondition de sûreté — **le syscall de spawn doit être émis par le thread restreint** — est respectée par le design : `enforce(plan)` puis `Command::spawn()` sur le **même** thread jetable. ✔\n\n## Confirmation empirique décisive\nLe test e2e de parité (écriture hors-grant **bloquée** kernel / dans-grant réussie) passe sur le chemin structuré avec le spawn réellement utilisé. C'est la **preuve in vivo** que l'héritage tient indépendamment de fork+exec vs posix_spawn — exactement l'invariant en débat. Build + suite verts ⇒ rien à rouvrir.\n\n## Arbitrage de l'invariant crate-wide\nRelâcher `#![forbid(unsafe_code)]` → `deny + #[allow]` scoppé pour ne récupérer qu'une *garantie de déterminisme de spawn* (sans valeur de sécurité) serait un **mauvais échange** : on dégraderait une protection statique crate-wide pour une commodité nulle côté sûreté. **Non.**\n\n**Décision : on reste en (a). `crates/infrastructure/src/lib.rs:12` `#![forbid(unsafe_code)]` reste INTACT. Pas de pre_exec, pas d'unsafe.**\n\nUne seule exigence pour QA, à garder verte en permanence (déjà couverte par le test de parité) : l'invariant **« écriture hors-grant bloquée sur le chemin structuré »** est le garde-fou qui détecterait toute régression d'héritage si le mécanisme de spawn venait à changer. Tant qu'il est vert, (a) est prouvée."} diff --git a/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/handoff.md b/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/handoff.md new file mode 100644 index 0000000..4391e73 --- /dev/null +++ b/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/handoff.md @@ -0,0 +1,26 @@ +--- +upTo: 7de50c76-7f1b-439b-8d0d-0f03ec9199ec +objective: IMPLÉMENTATION (code de production uniquement — PAS les nouveaux tests, c'est QA qui les écrit ; mais ne casse aucun test existant). Cadrage validé par l'Architect. Objectif : permettre la délégation +--- +**Objectif :** IMPLÉMENTATION (code de production uniquement — PAS les nouveaux tests, c'est QA qui les écrit ; mais ne casse aucun test existant). Cadrage validé par l'Architect. Objectif : permettre la délégation + +- **Response:** Lot LP3-5 (composition root) terminé : la projection des permissions est désormais ACTIVE dans l'app. `cargo build --workspace` OK, `cargo test --workspace` = 1262 passed / 0 failed. Le vitest frontend a 4 échecs PRÉ-EXISTANTS (non causés par ce lot — détail plus bas). OÙ + COMMENT (crates/app-tauri/src/state.rs, dans le builder du composition root) 1) Registre construit une seule fois, juste avant `LaunchAgent` : ```rust let permission_projectors = Arc::new( PermissionProjectorRegistry::new() .with(Arc::new(ClaudePermissionProjector) as Arc) .with(Arc::new(CodexPermissionProjector) as Arc), ); ``` (projecteurs concrets importés de `infrastructure::{ClaudePermissionProjector, CodexPermissionProjector}` ; registre importé de `application::PermissionProjectorRegistry`. `with(...)` keye chaque projecteur par son `ProjectorKey` — Claude/Codex.) 2) MÊME `Arc` injecté dans LES DEUX use cases (source unique de vérité, comme prévu en LP3-4) : - `LaunchAgent::new(...).… .with_permission_store(Arc::clone(&permission_store_port)) // déjà présent .with_handoff_provider(…).with_provider_session_provider(…) .with_permission_projectors(Arc::clone(&permission_projectors))` // AJOUTÉ - `ChangeAgentProfile::new(...).with_structured(…) .with_permission_projectors(Arc::clone(&permission_projectors))` // AJOUTÉ Style/patterns identiques aux autres injections (handoff provider, provider session, structured). PERMISSION STORE CÂBLÉ AU LAUNCH — CONFIRMÉ `FsPermissionStore` était DÉJÀ instancié (state.rs:511) et injecté dans le LaunchAgent de prod via `.with_permission_store(Arc::clone(&permission_store_port))` (state.rs:662). Donc `resolve_effective_permissions` a bien une source : la projection n'est PAS vide en prod (elle dépend du contenu de `.ideai/permissions.json` ; sans policy posée ⇒ `eff == None` ⇒ projection vide = prompting natif conservé, conforme à l'invariant produit). Rien à ajouter de ce côté. Aucun contrat des lots précédents modifié. Le registre LaunchAgent interne de `ChangeAgentProfile` (relance composée) reçoit la projection via le `LaunchAgent` partagé ; le nettoyage au swap utilise le registre injecté directement. SORTIES RÉELLES - `cargo build --workspace` → Finished `dev` … OK (0 erreur, 0 warning). - `cargo test --workspace` → TOTAL **1262 passed, 0 failed**, 0 ignored. Tous les binaires de test (domain/application/infrastructure/app-tauri) verts, dont change_agent_profile, agent_lifecycle, permission_usecases, permission_store, mcp_server, orchestrator_*, dto_*. Aucun `FAILED`/panic. - `cd frontend && npx vitest run` → **Test Files 3 failed | 36 passed (39) ; Tests 4 failed | 349 passed (353)**. ANALYSE DES 4 ÉCHECS VITEST (NON imputables à LP3-5) Je n'ai touché AUCUN fichier frontend (ce lot est 100 % Rust). Les 4 échecs sont PRÉ-EXISTANTS, hérités du chantier frontend « permissions » en cours dans l'arbre de travail (présents dès le début de ma session) : - fichiers frontend déjà modifiés/non-suivis AVANT mon intervention : `M frontend/src/adapters/index.ts|input.ts|mock/index.ts`, `M frontend/src/domain/index.ts`, `M frontend/src/ports/index.ts`, `M features/projects/ProjectsView.tsx`, `M features/terminals/useWritePortal.ts`, et NON-SUIVIS `?? frontend/src/adapters/permission.ts`, `?? frontend/src/features/permissions/`. - échecs : `mock.test.ts > exposes all thirteen gateways` (le mock expose maintenant une gateway permissions de plus ⇒ le compte attendu n'est pas à jour), `LayoutGrid.chat.test.tsx` (2, routage cellule terminal/chat), `agentAlreadyRunning.test.tsx` (1, notice R0d). Tous dans des composants/adapters que LP3 ne touche pas et liés au feature permissions UI côté DevFrontend, pas à la projection backend. Conclusion : côté backend (mon périmètre), workspace 100 % vert et projection câblée bout-en-bout. Les 4 rouges vitest relèvent du lot frontend « permissions » en cours (à confier à DevFrontend) et ne sont pas une régression introduite par LP3-5. Si tu veux, je peux signaler précisément à DevFrontend le test `exposes all thirteen gateways` à mettre à jour (probable « fourteen » avec la gateway permissions). +- **Prompt:** Ping de diagnostic. Calcule 5 + 7 et réponds-moi uniquement avec le résultat via idea_reply. +- **Response:** 12 +- **Prompt:** Ping de diagnostic #2. Calcule 8 + 8 et réponds-moi uniquement avec le résultat via idea_reply. +- **Response:** 16 +- **Prompt:** [Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong DevBackend OK » pour confirmer que le canal fonctionne. +- **Response:** pong DevBackend OK +- **Prompt:** [Chantier permissions — état des lieux] J'ai audité le chantier permissions : domaine (LP0, modèle pur + resolve deny-wins/postures), store FsPermissionStore (LP1), use cases CRUD+resolve (LP2), projecteurs Claude/Codex + câblage launch-path/registry (LP3-3/4), composition root + commandes Tauri (LP3-5), et UI PermissionsPanel (projet + override agent). `cargo test --workspace` et les tests frontend permissions sont 100% verts. Tout est encore non commité. Question : d'après ta connaissance du chantier (ta conversation, ta mémoire, le code), reste-t-il un lot ouvert ou un gap concret à traiter sur les permissions (ex: LP4, edge cases non couverts, projection non testée end-to-end, parité Codex/Claude, sandbox OS réelle, validation/erreurs UI) ? Si oui, liste précisément le PROCHAIN lot à faire avec son périmètre. Si le chantier est complet, dis-le clairement. Réponds via idea_reply. +- **Response:** Bilan permissions — DevBackend Le chantier que tu décris (LP0 domaine → LP3-5 commandes Tauri + UI) correspond à la **voie « projection vers la config CLI »** et il est bien complet/vert. Mais ce n'est PAS tout le chantier permissions : il reste un lot ouvert, documenté noir sur blanc dans le code lui-même. ## Le gap concret : enforcement OS réel (sandbox), out of scope à dessein Le domaine déclare explicitement que l'application OS-sandbox est hors périmètre du lot livré. Trois modules le marquent : - `domain/src/permission.rs:12` — « OS-sandbox application … out of scope here » - `domain/src/fileguard.rs:16` et `infrastructure/src/fileguard/mod.rs:17` — « airtight revocation of raw fs access is an OS-sandbox (Landlock) concern, out of scope » - `application/src/orchestrator/context_guard.rs:23` — idem Autrement dit, ce qui est livré **configure** Claude (`settings.local.json` allow/deny/ask) et Codex (sandbox/approval modes via `config.toml`) — c'est de la permission **advisory**, qui dépend du bon vouloir de la CLI. Un agent qui contourne sa CLI (ou un `bash` direct) garde un accès FS brut. La couche **airtight** (révocation réelle des accès) n'existe pas encore. C'est exactement ce que dit la mémoire archi : permissions = **sandbox OS (Landlock Linux) + résumé injecté**. La moitié « sandbox OS » manque. ## PROCHAIN lot proposé — LP4 : enforcement OS (Landlock Linux d'abord) Périmètre : 1. **Port domaine** `SandboxEnforcer` (trait pur) : prend `EffectivePermissions` résolues → un plan d'enforcement OS (rulesets path RO/RW/exec, deny-wins déjà garanti par le domaine). Aucune I/O dans le domaine. 2. **Adapter infra Linux** : impl Landlock (crate `landlock`) appliquée **au process agent au spawn** (pré-exec), pas à IdeA. Adapters par OS, pas par CLI (conforme à `agent-permissions-architecture`). Windows/macOS = stubs no-op explicites pour ce lot. 3. **Câblage launch-path** : appliquer le ruleset au moment du spawn PTY, en cohérence avec la projection déjà écrite (même `EffectivePermissions`). 4. **Résumé injecté** : générer un résumé lisible des permissions effectives et l'injecter dans le contexte `.md` de l'agent (l'autre moitié du couple « sandbox + résumé » — à confirmer s'il existe déjà ; je n'en ai pas trouvé de générateur dédié). 5. **Tests** : domaine/app 100 % sans I/O (fakes) ; un test d'intégration Linux réel (sous-process tente un accès hors scope → refusé). ⚠️ Ce lot demande un **cadrage Architect** d'abord (nouveau port + frontière process/OS) : je ne devine pas le contrat. ## Deux réserves mineures sur le « 100 % vert » - **Parité Claude/Codex** : OK au niveau projecteurs (testés purs) ; mais vérifier que la projection Codex couvre bien le mapping posture→approval *et* sandbox (pas juste l'un des deux). - **Validation e2e réelle (AppImage)** : tous les tests sont app-level avec fakes + store infra. Aucune validation binaire réelle (Claude/Codex lisant vraiment la config écrite). C'est le gap e2e habituel du projet, à faire avant de déclarer la voie projection « prod-ready ». ## Note process Tout est non commité (`crates/{domain,application,infrastructure}/…/permission*`, `frontend/src/features/permissions/`). Vu que c'est vert et autonome, un commit de jalon avant d'ouvrir LP4 serait sain — décision sortante qui te revient. **Conclusion : le chantier n'est PAS complet. Voie « projection CLI » = livrée/verte ; lot ouvert restant = LP4 enforcement OS (Landlock) + résumé injecté, à cadrer par Architect.** +- **Prompt:** [LP4-0 — implémentation domaine pur, enforcement OS] L'Architecte a cadré LP4. Implémente UNIQUEMENT le sous-lot LP4-0 (domaine pur, zéro I/O, zéro dépendance landlock, aucun comportement runtime branché — donc zéro régression). Contrat : 1. Nouveau module `crates/domain/src/sandbox.rs` : - `SandboxPlan { allowed: Vec, default_posture: Posture }` - `PathGrant { abs_root: String, access: PathAccess }` où `PathAccess` = bitflags Ro|Rw|Exec - `SandboxContext<'a> { project_root: &'a str, run_dir: &'a str }` - `SandboxKind { Landlock, Unsupported }`, `SandboxStatus { Enforced, Unsupported, Degraded(String) }`, `SandboxError` (kernel trop vieux en fail-closed, etc.) - trait `SandboxEnforcer: Send + Sync { fn kind(&self) -> SandboxKind; fn enforce(&self, plan: &SandboxPlan) -> Result; }` (contrat Liskov : enforce appelé uniquement post-fork/pré-exec dans l'enfant ; un adapter Unsupported renvoie Ok(Unsupported) sans rien faire) - fn pure `compile_sandbox_plan(eff: Option<&EffectivePermissions>, ctx: &SandboxContext) -> Option`. INVARIANT : `eff == None ⇒ None` (rien posé ⇒ pas de sandbox ⇒ natif). Traduction globs→racines absolues calculée FAIL-CLOSED : un Deny tombant dans un Allow sans frontière de répertoire qui les sépare ⇒ on N'ACCORDE PAS le parent (on perd un allow plutôt que de laisser fuiter un deny). Pose cette sur-/sous-approximation conservatrice comme invariant testable. 2. `crates/domain/src/permission.rs` : fn pure `render_permission_summary(eff: Option<&EffectivePermissions>) -> Option` (bloc Markdown ; None⇒None ; doit mentionner explicitement que les règles de COMMANDE (ExecuteBash) restent advisory/non verrouillées OS, alors que les fichiers sont verrouillés quand supporté). 3. `crates/domain/src/ports.rs` : ajoute `sandbox: Option` à `SpawnSpec` (None ⇒ pas d'enforcement). Mets à jour tous les constructeurs/sites existants pour rester compilable. 4. Exporte les nouveaux types dans `crates/domain/src/lib.rs`. Borne de réalité à respecter : Landlock ne verrouille QUE les fichiers ; `ExecuteBash` (matching argv) n'est PAS enforceable ⇒ reste advisory. Ne touche PAS au modèle existant ni à resolve ni aux projecteurs LP3. N'implémente PAS d'adapter Landlock ni de câblage lifecycle (c'est LP4-1/LP4-2). Décision produit déjà tranchée pour plus tard : fallback fail-open+warning sauf posture Deny (à encoder côté adapter en LP4-1, pas ici). Assure que `cargo build -p domain` et `cargo test --workspace` restent verts (ajoute juste de quoi compiler ; QA écrira la suite de tests exhaustive). Quand c'est fait, réponds via idea_reply avec : fichiers créés/modifiés, signatures publiques exactes livrées, et confirmation que le workspace compile + tests existants au vert. +- **Response:** LP4-0 livré — domaine pur, zéro I/O, zéro dépendance landlock, zéro câblage runtime. `cargo build -p domain` OK, `cargo test --workspace` 100% vert (0 failed), 0 nouveau warning clippy. ## Fichiers **Créé** - `crates/domain/src/sandbox.rs` (nouveau module pur) **Modifiés** - `crates/domain/src/permission.rs` — ajout `render_permission_summary` - `crates/domain/src/ports.rs` — champ `sandbox` sur `SpawnSpec` - `crates/domain/src/lib.rs` — `pub mod sandbox;` + re-exports - 11 sites de construction de `SpawnSpec` mis à jour (`sandbox: None`) pour rester compilable : `infrastructure/src/runtime/mod.rs` (×2), `application/src/terminal/usecases.rs`, `app-tauri/src/state.rs` (×2), et les fakes de test `infrastructure/tests/{pty_adapter,orchestrator_watcher,mcp_server}.rs`, `application/tests/{agent_lifecycle,orchestrator_service,change_agent_profile,structured_launch_d3}.rs`. (`application/tests/profile_usecases.rs` = `unreachable!()`, rien à changer.) ## Signatures publiques exactes `crates/domain/src/sandbox.rs` : ```rust pub struct PathAccess(u8); impl PathAccess { pub const RO: Self; // 0b001 (read) pub const RW: Self; // 0b010 (write/delete) pub const EXEC: Self; // 0b100 (réservé, jamais émis par le compilateur) pub const fn empty() -> Self; pub const fn contains(self, other: Self) -> bool; pub const fn union(self, other: Self) -> Self; pub fn insert(&mut self, other: Self); pub const fn is_empty(self) -> bool; pub const fn bits(self) -> u8; } // + impl BitOr / BitOrAssign pub struct PathGrant { pub abs_root: String, pub access: PathAccess } pub struct SandboxPlan { pub allowed: Vec, pub default_posture: Posture } pub struct SandboxContext<'a> { pub project_root: &'a str, pub run_dir: &'a str } pub enum SandboxKind { Landlock, Unsupported } pub enum SandboxStatus { Enforced, Unsupported, Degraded(String) } pub enum SandboxError { KernelTooOld(String), Ruleset(String) } // thiserror pub trait SandboxEnforcer: Send + Sync { fn kind(&self) -> SandboxKind; fn enforce(&self, plan: &SandboxPlan) -> Result; } pub fn compile_sandbox_plan( eff: Option<&EffectivePermissions>, ctx: &SandboxContext, ) -> Option; ``` `crates/domain/src/permission.rs` : ```rust pub fn render_permission_summary(eff: Option<&EffectivePermissions>) -> Option; ``` `crates/domain/src/ports.rs` : ```rust pub struct SpawnSpec { /* … champs existants … */ pub sandbox: Option, // None ⇒ pas d'enforcement } ``` Re-exports `lib.rs` : `compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus` + `render_permission_summary`. ## Invariants encodés (à exploiter par QA) 1. **`eff == None ⇒ compile renvoie None`** (rien posé ⇒ natif). `Some` même avec `allowed` vide = il y a une politique. 2. **Bash ignoré** : seules les 3 capabilities fichier (Read→RO, Write/Delete→RW) produisent des grants. `ExecuteBash` n'est jamais traduit (Landlock ne verrouille pas l'exec) — borne de réalité respectée. 3. **Fail-closed (testable)** : chaque glob Allow → son *préfixe statique* (chemin littéral avant le 1er métacaractère `* ? [`, slash trimé). Le grant est **abandonné** si un Deny a un préfixe statique qui chevauche (égal, ou ancêtre/descendant de chemin) — un sandbox additif ne peut pas découper un deny sous-arbre. On perd l'allow plutôt que de laisser fuiter le deny. Cas notable : Allow `**` (racine projet) est abandonné dès qu'un seul Deny fichier existe. 4. **`render_permission_summary`** : bloc Markdown ; mentionne explicitement que les fichiers sont **OS-enforced when supported (Landlock)** et que les commandes restent **advisory, NOT OS-locked**. ## Non fait (volontairement, hors périmètre LP4-0) - Aucun adapter Landlock, aucun `SandboxEnforcer` concret (LP4-1). - Aucun câblage lifecycle/launch-path : `SpawnSpec.sandbox` est posé mais **jamais lu** ⇒ zéro changement de comportement runtime, zéro régression. - Fallback fail-open+warning (sauf posture Deny) : laissé à encoder côté adapter en LP4-1. - Modèle existant, `resolve`, projecteurs LP3 : **non touchés**. `compile_sandbox_plan` ne consomme pas `ctx.run_dir` (réservé à l'adapter : le run dir doit rester atteignable) — c'est documenté dans le code, pas un oubli. Prêt pour la suite de tests exhaustive de QA, puis LP4-1 (adapter Landlock) après cadrage Architect. +- **Prompt:** [LP4-0 — affinage sémantique des barrières Deny, AVANT commit] Décision d'orchestration sur le point remonté par QA : les barrières Deny doivent être PAR CLASSE D'ACCÈS, pas capability-indépendantes. Raison : autonomie maximale des agents — un Deny Write ne doit pas amputer un Allow Read (sinon on sur-restreint et l'agent se bloque/redemande, contraire à l'objectif produit). Refactore `compile_sandbox_plan` dans `crates/domain/src/sandbox.rs` ainsi : 1. Sépare les deux classes d'accès : - Classe RO : alimentée par les Allow `Read` ; barrières = les Deny `Read`. - Classe RW : alimentée par les Allow `Write` ET `Delete` ; barrières = les Deny `Write` ET `Delete`. (ExecuteBash : toujours ignoré, aucune classe.) 2. Pour CHAQUE classe indépendamment : réduis chaque Allow à son préfixe statique, puis abandonne (fail-closed) le grant SI une barrière de LA MÊME CLASSE a un préfixe statique chevauchant (égal, ancêtre OU descendant). Un Deny d'une autre classe n'a AUCUN effet sur cette classe. 3. Fusionne les racines survivantes en `PathGrant` par `abs_root`, en unionnant l'accès : une racine survivant en RO seul ⇒ `PathAccess::RO` ; survivant aussi en RW ⇒ `RO | RW` ; survivant en RW seul ⇒ `RW`. 4. Invariant `eff == None ⇒ None` et « politique posée ⇒ Some même si allowed vide » : inchangés. Exemple cible à garantir : Allow Read `src/**` + Deny Write `src/secret/**` (aucun Deny Read) ⇒ le grant `src` CONSERVE le RO (le write-deny ne touche pas la classe RO ; il n'y a pas de write-allow donc pas de RW). Avant ton refactor, ce cas perdait tout le grant — c'est précisément ce qu'on corrige. Mets à jour la doc du module/fonction pour décrire la sémantique par classe (et que c'est le deny-wins par capability+cible, fail-closed à l'intérieur d'une classe). Ne touche à rien d'autre (pas de Landlock, pas de lifecycle). Assure `cargo build -p domain` OK et `cargo test --workspace` toujours vert (certains tests QA existants supposaient l'ancienne sémantique capability-indépendante : signale lesquels deviennent obsolètes pour que QA les réécrive — ne les supprime pas toi-même si ce sont des tests QA, mais liste-les). Réponds via idea_reply avec : diff conceptuel, tests existants impactés à réécrire, et état build. +- **Response:** LP4-0 affiné — barrières Deny **par classe d'accès**. `cargo build -p domain` OK, `cargo test --workspace` vert (0 failed), 0 warning clippy sur sandbox.rs. Rien d'autre touché (pas de Landlock, pas de lifecycle). ## Diff conceptuel **Avant** (capability-blind) : on collectait UN seul jeu de barrières = les préfixes statiques de **tous** les Deny fichier, et tout Allow fichier dont le préfixe chevauchait était abandonné — peu importe la capability. ⇒ un `Deny Write` amputait un `Allow Read` (sur-restriction). **Après** (par classe) : deux jeux de barrières indépendants. - Classe **RO** : alimentée par `Allow Read` ; barrières = `Deny Read` uniquement. - Classe **RW** : alimentée par `Allow Write` ET `Allow Delete` ; barrières = `Deny Write` ET `Deny Delete`. - `ExecuteBash` : toujours sans classe, jamais de grant ni de barrière. Pour chaque classe **indépendamment** : Allow → préfixe statique, abandonné (fail-closed) ssi une barrière **de la même classe** chevauche (égal / ancêtre / descendant). Une barrière d'une autre classe n'a **aucun** effet. Puis fusion des racines survivantes par `abs_root` en unionnant l'accès : RO seul ⇒ `RO` ; RO+RW ⇒ `RO|RW` ; RW seul ⇒ `RW`. Sémantiquement : c'est le deny-wins par `capability`+cible de `permission.rs`, transposé à la granularité répertoire de Landlock, fail-closed à l'intérieur de chaque classe. **Cas cible garanti** (vérifié) : `Allow Read src/**` + `Deny Write src/secret/**` (aucun Deny Read) ⇒ grant `src` **CONSERVE le RO** (pas de RW car pas de Write-allow). Avant : tout le grant était perdu. Implémentation : helper `capability_access` remplacé par `access_class(cap) -> Option` (None pour ExecuteBash) ; deux Vec de barrières `ro_fences`/`rw_fences` ; sélection de la classe au moment de l'Allow. Invariants `eff==None ⇒ None` et « politique posée ⇒ Some même si allowed vide » : inchangés. Doc module + doc fonction réécrites pour décrire la sémantique par classe. ## Tests QA existants impactés (dans `crates/domain/src/sandbox.rs`, mod tests — écrits par QA) **3 tests obsolètes** (ils encodaient la sémantique capability-blind via un `Deny Write` qui faisait tomber un `Allow Read`). Je ne les ai **pas supprimés** ; je les ai marqués `#[ignore = "OBSOLETE … ticket 2db80d41 … QA to rewrite"]` pour garder le workspace vert tout en les laissant compiler et visibles. À réécrire par QA avec une barrière **de même classe** (`Deny Read`) : 1. `root_glob_with_single_file_deny_drops_root_grant` — `Allow Read **` + `Deny Write secret.txt` ; attendait `allowed` vide. Désormais le RO root survit. 2. `descendant_deny_drops_overlapping_allow` — `Allow Read src/**` + `Deny Write src/secret/**` ; attendait grant `src` absent. **C'est précisément le cas qu'on corrige** → le RO sur `src` est conservé. 3. `ancestor_deny_also_drops_allow` — `Allow Read src/sub/**` + `Deny Write src/**` ; attendait grant `src/sub` absent. Désormais conservé. **2 tests encore verts mais sémantiquement affaiblis** (ils passent toujours, mais leur assertion « gardé » n'est plus due à la disjointness puisque la barrière est d'une autre classe — à renforcer côté QA avec une barrière même-classe pour rester probants) : - `disjoint_deny_keeps_allow`, `sibling_prefix_is_not_a_descendant`. Les 6 autres tests sandbox (mapping accès, bash-only, union sur racine partagée, préfixe statique d'un fichier littéral, posture résiduelle, normalisation trailing slash) restent valides et verts. ## État build - `cargo build -p domain` : OK. - `cargo test --workspace` : **0 failed**, 0 binaire en échec. Module sandbox : 11 passed / 0 failed / 3 ignored. - clippy domain : 0 nouveau warning (les 2 préexistants sont dans `fileguard.rs`). Prêt pour le commit une fois que QA aura réécrit les 3 tests ignorés sous la nouvelle sémantique (et idéalement renforcé les 2 affaiblis). +- **Prompt:** [LP4-1 — adapter Landlock + hook pre_exec, infra Linux] On enchaîne le chantier permissions/enforcement OS. LP4-0 (domaine pur) est commité (b05d04a) : port `SandboxEnforcer`, `compile_sandbox_plan`, `SpawnSpec.sandbox: Option` (posé mais jamais lu pour l'instant). Implémente LP4-1 selon le cadrage Architecte. Périmètre LP4-1 (adapters OS + mécanisme pre_exec ; PAS le câblage application step 5d ni la composition root — ce sont LP4-2/LP4-3) : 1. Nouveau module `crates/infrastructure/src/sandbox/{mod.rs, landlock.rs, noop.rs}` : - `LandlockSandbox` (`#[cfg(target_os="linux")]`) impl `SandboxEnforcer` : traduit `SandboxPlan` → ruleset Landlock via la crate `landlock` (ajoute-la au Cargo.toml d'infrastructure, en best-effort/compat ABI). `enforce(&self, plan)` crée le ruleset, ajoute chaque `PathGrant` comme `path_beneath` avec les access rights correspondant à `PathAccess` (RO ⇒ lecture ; RW ⇒ lecture+écriture+création+suppression ; EXEC ⇒ exec — mais le domaine n'émet jamais EXEC pour l'instant), puis restreint le THREAD courant (appel destiné à l'enfant post-fork). Renvoie `SandboxStatus::Enforced` ; `Degraded(reason)` si compat ABI réduit la couverture ; et applique la politique fallback : kernel/ABI sans Landlock ⇒ `SandboxStatus::Unsupported` SAUF si `plan.default_posture == Posture::Deny` où il faut renvoyer `Err(SandboxError::KernelTooOld(...))` (fail-closed seulement en posture Deny ; fail-open+warning sinon). - `NoopSandbox` (autres OS / fallback) impl `SandboxEnforcer` : `kind()==Unsupported`, `enforce` renvoie toujours `Ok(SandboxStatus::Unsupported)`, jamais d'erreur. Doit compiler sur toutes plateformes. - `mod.rs` : sélection cfg-dépendante d'un constructeur `default_enforcer() -> Arc` (Landlock sur Linux, Noop ailleurs). Exporte dans `infrastructure/src/lib.rs`. 2. Hook `pre_exec` dans l'adapter PTY (`portable-pty`) : le `PortablePtyAdapter` reçoit un `Option>` (nouveau champ + builder additif, défaut `None` ⇒ aucun sandboxing, signature `new` inchangée). Dans `spawn`, si `spec.sandbox.is_some()` ET un enforcer est présent, installe via `CommandBuilder`/`pre_exec` (unsafe, Unix only) un hook qui, dans l'enfant post-fork pré-exec, appelle `enforcer.enforce(plan)` ; sur `Err` ⇒ faire échouer le spawn (l'enfant n'exec pas) ; sur `Ok(Unsupported/Degraded)` ⇒ continuer (log/warning best-effort). ⚠️ Respecte les contraintes async-signal-safety du pre_exec (pas d'alloc/lock hasardeux ; clone le plan en amont). Si `portable-pty` ne permet pas un pre_exec arbitraire, signale-le clairement et propose l'alternative la plus propre plutôt que de bricoler. 3. Aucun changement de comportement runtime tant que LP4-2/LP4-3 ne câblent pas : l'enforcer reste `None` partout, `spec.sandbox` reste `None`. ⇒ zéro régression. Tests : ajoute au moins un test d'intégration Linux réel pour `LandlockSandbox` (un sous-process lancé sous un plan qui interdit l'écriture d'un tmp path ⇒ l'écriture échoue EACCES, et un path autorisé réussit), gated `#[cfg(target_os="linux")]` + garde runtime de disponibilité Landlock (skip si indispo, façon tests SSH/WSL). Test `NoopSandbox` ⇒ Unsupported jamais Err. (QA fera la passe d'intégration exhaustive ensuite.) Assure `cargo build --workspace` et `cargo test --workspace` verts. Réponds via idea_reply avec : fichiers créés/modifiés, version de la crate `landlock` ajoutée, faisabilité réelle du pre_exec avec portable-pty (oui/non + comment), résultat des tests (et si le test Landlock réel a pu tourner sur ce kernel ou a été skippé), et confirmation zéro régression. +- **Response:** LP4-1 livré — adapters OS Landlock/Noop + mécanisme d'enforcement. `cargo build --workspace` OK, `cargo test --workspace` vert (0 failed, 0 ignored), 0 nouveau warning clippy. Zéro régression (enforcer None + spec.sandbox None partout ⇒ chemin dormant). ## Fichiers **Créés** - `crates/infrastructure/src/sandbox/mod.rs` — sélection cfg + `default_enforcer() -> Arc` + tests Noop/default - `crates/infrastructure/src/sandbox/landlock.rs` — `LandlockSandbox` (`#[cfg(target_os="linux")]`) + 2 tests d'intégration réels - `crates/infrastructure/src/sandbox/noop.rs` — `NoopSandbox` (toutes plateformes) **Modifiés** - `crates/infrastructure/Cargo.toml` — dép Linux-only `landlock = "0.4.5"` sous `[target.'cfg(target_os = "linux")'.dependencies]` - `crates/infrastructure/src/lib.rs` — `pub mod sandbox;` + exports `default_enforcer`, `NoopSandbox`, `LandlockSandbox` (cfg linux) - `crates/infrastructure/src/pty/mod.rs` — champ `sandbox_enforcer: Option>` + builder additif `with_sandbox_enforcer` (`new()` inchangé) + chemin de spawn sandboxé ## Version crate `landlock = "0.4.5"` (dernière ; ABI jusqu'à V7, on cible V1 + `CompatLevel::BestEffort`). Pull une seule dép transitveive légère (`enumflags2`). Linux-only ⇒ Windows/macOS ne compilent rien de Landlock (chemin Noop). ## Faisabilité pre_exec avec portable-pty : NON portable-pty 0.9 **n'expose aucun hook pre_exec utilisateur** : `CommandBuilder` n'a pas de méthode pre_exec, et `SlavePty::spawn_command` installe son **propre** pre_exec interne (setsid / controlling-tty, `unix.rs:238`) sans point d'extension, en consommant son propre `CommandBuilder` (pas un `std::process::Command` accessible). Impossible d'injecter `enforce()` dans le pre_exec de l'enfant directement. **Alternative choisie (propre, pas de bricolage, pas de fork de la crate, pas de binaire helper)** : on exploite une propriété kernel garantie — **un domaine Landlock est hérité au `fork` et préservé à l'`execve`**. Donc `spawn_command_sandboxed` : 1. lance un **thread jetable dédié**, y appelle `enforcer.enforce(plan)` (restreint CE thread, irréversible mais le thread meurt ensuite — les autres threads d'IdeA restent intacts) ; 2. appelle `slave.spawn_command(cmd)` **depuis ce thread** : `std::process::Command` fork depuis le thread appelant, et comme portable-pty pose toujours un pre_exec, std est forcé sur le chemin `fork`+`exec` (jamais `posix_spawn`) ⇒ l'enfant hérite du domaine restreint ; 3. sur `Err(enforce)` (fail-closed) ⇒ le spawn échoue, **aucun enfant ne tourne** ; sur `Ok(Unsupported/Degraded)` ⇒ on continue (best-effort). Tout est **déplacé** (move) dans le thread ⇒ aucune borne `Sync` requise du slave ; le `Child` (Send) revient à l'appelant. (Le câblage de l'enforcer dans la composition root et la pose de `spec.sandbox` restent LP4-2/LP4-3 — ici le chemin est prêt mais dormant.) ## Sémantique LandlockSandbox (faithful + per-class, miroir de LP4-0) - On *handle* une classe de droits **seulement si** le plan pose un grant de cette classe : RO présent ⇒ handle `from_read` ; RW présent ⇒ handle `from_write`. ⇒ un plan **write-only laisse les lectures globalement libres** (libc/`/etc` lisibles) et ne clôture que les écritures — exactement l'autonomie per-class. Corollaire documenté : un plan qui restreint les lectures gouverne *toutes* les lectures ⇒ le plan compilé devra inclure les chemins système (concern LP4-2, pas l'adapter). - RO⇒`from_read`, RW⇒`from_read|from_write` sur la racine ; intersection avec le handled set. - `restrict_self` ⇒ FullyEnforced→`Enforced` ; PartiallyEnforced→`Degraded(reason)` ; NotEnforced→fallback : `Unsupported` SAUF posture `Deny` ⇒ `Err(KernelTooOld)` (fail-closed). Plan vide/bash-only ⇒ `Enforced` (rien à poser). ## Tests - `landlock_write_only_plan_fences_writes_to_the_grant` (`#[cfg(target_os="linux")]`, garde runtime : skip si `status==Unsupported`) : **A TOURNÉ ET PASSÉ sur ce kernel** (pas skippé). Écriture dans la racine RW accordée ⇒ OK ; écriture hors racine ⇒ **EACCES (PermissionDenied)**. Enforcement réel validé. - `empty_plan_is_a_noop_enforced` : plan vide ⇒ `Enforced`, pas d'erreur (sur thread jetable). - `noop_enforcer_is_unsupported_and_never_errors` : `kind()==Unsupported`, `enforce` (même posture Deny) ⇒ `Ok(Unsupported)`, jamais Err. - `default_enforcer_matches_the_build_target` : Linux ⇒ Landlock. - **Workspace** : `cargo build --workspace` OK ; `cargo test --workspace` **0 failed, 0 ignored** (les 3 tests LP4-0 jadis ignorés ont été réécrits par QA dans b05d04a et sont verts). ## Zéro régression `PortablePtyAdapter::new()` : signature inchangée, `sandbox_enforcer = None`. Chemin sandboxé pris uniquement si `spec.sandbox.is_some() && enforcer.is_some()` — jamais le cas tant que LP4-2/LP4-3 ne câblent pas. Tous les tests existants verts. Reste pour LP4-2/LP4-3 : compilation du plan au launch (incl. chemins système read si RO), pose de `spec.sandbox`, injection de `default_enforcer()` dans `PortablePtyAdapter` à la composition root, et résumé permissions injecté. +- **Prompt:** [LP4-1 — durcissement : rendre testable la branche fail-closed posture Deny] QA a validé LP4-1 (vert, propriété de sûreté n°1 OK). Une seule branche reste non couverte : `(RulesetStatus::NotEnforced, Posture::Deny) ⇒ Err(SandboxError::KernelTooOld)`, intestable sur un kernel Landlock-capable car `restrict_self` ne renvoie jamais NotEnforced ici. C'est la branche sécurité-critique (fail-closed) — il faut la couvrir. Petit refactor sans changement de comportement : dans `crates/infrastructure/src/sandbox/landlock.rs`, extrais le mapping final en une fonction PURE, p.ex. `fn status_from_ruleset(status: RulesetStatus, posture: Posture) -> Result` (ou un enum miroir local si tu préfères ne pas dépendre du type de la crate dans la signature — choisis le plus propre/testable sans rien mocker). `enforce()` appelle cette fn après `restrict_self`. Mapping inchangé : - FullyEnforced ⇒ Ok(Enforced) - PartiallyEnforced ⇒ Ok(Degraded(reason)) - NotEnforced + posture==Deny ⇒ Err(KernelTooOld) - NotEnforced + posture!=Deny ⇒ Ok(Unsupported) Ajoute un test en table couvrant les 4 (×3 postures pertinentes) — notamment les deux cas NotEnforced qui prouvent le fail-closed seulement-en-Deny. Garde tout le reste identique. `cargo test -p infrastructure` + `cargo test --workspace` verts. Réponds via idea_reply avec le diff conceptuel, le nom/signature de la fn extraite, et la sortie de test. +- **Prompt:** Reprise du chantier permissions — lot LP4-3 (câblage bout-en-bout de l'enforcement OS). LP4-0/LP4-1/LP4-2 sont faits, committés (dernier commit 17ca65e) et verts. Il reste à ACTIVER la sandbox au runtime ; aujourd'hui tout est construit mais inactif (rien ne peuple le plan, rien n'injecte l'enforcer). Périmètre précis : 1) Composition root — injecter l'enforcer dans le PTY. - `crates/app-tauri/src/state.rs:396` et `crates/infrastructure/src/remote/mod.rs:41` construisent `PortablePtyAdapter::new()`. - Remplacer par `PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer())` (le builder additif existe déjà, le default_enforcer() choisit Landlock sur Linux / Noop ailleurs). 2) Launch path — peupler `SpawnSpec.sandbox`. - `crates/infrastructure/src/runtime/mod.rs:74` et `:217` posent `sandbox: None` en dur. - Au lancement d'un agent, résoudre sa policy : charger `ProjectPermissions` (store `.ideai/permissions.json` — adaptateur déjà en place côté infra/store), `resolve_for(agent_id)` → `EffectivePermissions`, puis `domain::sandbox::compile_sandbox_plan(...)` pour obtenir le `SandboxPlan`, et le passer dans `SpawnSpec.sandbox` (Some). Si la policy résout à None (rien posé), garder `sandbox: None` (invariant produit : on ne projette rien, comportement natif conservé). - Vérifie le branchement des dépendances (le runtime doit pouvoir lire le store de permissions ; suis le même chemin d'injection que les autres adaptateurs du runtime). Si une dépendance manque dans la construction du runtime, fais remonter clairement ce qui bloque plutôt que de bricoler. 3) Respecte strictement l'archi hexagonale : la résolution policy→plan est PURE (domaine), le runtime ne fait qu'orchestrer lecture store + appel pur + remplissage SpawnSpec. À la fin : `cargo build` + `cargo test -p domain -p infrastructure` (et le crate app-tauri si touché) doivent être verts. NE committe PAS toi-même (action figée par l'orchestrateur). Rends via idea_reply un rapport contenant : fichiers modifiés, comment le plan est résolu/injecté, blocages éventuels, et la sortie réelle des tests. Si quelque chose ne passe pas, dis-le avec la sortie brute — on enchaînera avec QA pour le test bout-en-bout (agent lancé avec Deny sur un chemin ne peut pas y écrire). +- **Response:** LP4-3 — câblage bout-en-bout de l'enforcement OS : FAIT, build + tests verts. NON committé (action figée par l'orchestrateur). ## Fichiers modifiés (3) 1. **crates/app-tauri/src/state.rs** (~396) — composition root : `PortablePtyAdapter::new()` → `PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer())`. 2. **crates/infrastructure/src/remote/mod.rs** (~41) — `LocalHost::new()` : même injection via `crate::sandbox::default_enforcer()` (chemin remote/local transparent). 3. **crates/application/src/agent/lifecycle.rs** — launch path (`LaunchAgent::execute`) : - ajout import `use domain::sandbox::{compile_sandbox_plan, SandboxContext};` - nouvelle étape **5d** insérée APRÈS la résolution des permissions (étape 3, `resolve_effective_permissions` → `Option`) et la projection advisory LP3 (étape 5c), AVANT le split structuré/PTY : ```rust spec.sandbox = compile_sandbox_plan( effective_permissions.as_ref(), &SandboxContext { project_root: input.project.root.as_str(), run_dir: run_dir.as_str(), }, ); ``` ## Comment le plan est résolu / injecté - **Résolution policy → plan (PURE, domaine).** Le store `.ideai/permissions.json` est déjà lu en étape 3 par `resolve_effective_permissions` (qui fait `store.load_permissions(project)` puis `doc.resolve_for(agent_id)` → `Option`). La dépendance `Option>` était DÉJÀ branchée dans `LaunchAgent` (via `with_permission_store`, utilisée par LP3) — rien à recâbler côté construction du runtime. Je réutilise donc cette `EffectivePermissions` au lieu de re-lire le store. - L'étape 5d appelle simplement la fonction pure `domain::sandbox::compile_sandbox_plan(eff, ctx)` et stocke le résultat dans `spec.sandbox`. Zéro I/O dans cette étape : le launch path ne fait qu'orchestrer (lecture store déjà faite → appel pur → remplissage SpawnSpec). Hexagonal respecté. - **Invariant produit conservé** : `eff == None` (rien posé) ⇒ `compile_sandbox_plan` renvoie `None` ⇒ `spec.sandbox = None` ⇒ comportement natif, aucune projection OS. Le `sandbox: None` codé en dur dans `runtime/mod.rs` (`detection_spec` L80, `prepare_invocation` L224) reste le défaut neutre de la fonction *pure* `prepare_invocation` (qui n'a pas accès aux permissions, par design) ; il est désormais écrasé en aval par l'étape 5d quand une policy existe. La détection (`detection_spec`) reste volontairement non sandboxée (simple probe `--version`). - **Injection de l'enforcer** : `default_enforcer()` (Landlock sur Linux / Noop ailleurs) est injecté dans le `PortablePtyAdapter` au composition root. Au spawn, `PortablePtyAdapter::spawn` n'applique le plan que si `(spec.sandbox = Some, enforcer = Some)` — sinon spawn nominal. Le branchement bout-en-bout est donc complet pour le chemin PTY. ## Point d'attention (non bloquant, à noter pour QA) L'étape 5d remplit `spec.sandbox` AVANT le split structuré/PTY, donc le plan est porté dans les deux cas. Mais l'enforcement effectif vit dans `PortablePtyAdapter` (chemin PTY brut). Le chemin **structuré** (`launch_structured` → `factory.start`, pas de `pty.spawn`) ne consomme pas encore `spec.sandbox` : un agent structuré (Claude/Codex en mode JSON) n'est PAS OS-sandboxé pour l'instant — il garde sa projection advisory LP3. Le test e2e « Deny sur un chemin ⇒ écriture impossible » doit donc viser un agent en **mode terminal/PTY** (profil sans structured_adapter) pour valider Landlock. Sandboxer le chemin structuré serait un lot ultérieur (l'AgentSession infra devrait enforcer dans son propre fork/exec). ## Sortie réelle des tests `cargo build` : Finished, OK. `cargo test -p domain -p infrastructure -p application -p app-tauri` : tous les binaires de test verts, **0 failed** partout. Suites notables : - domain : `141 passed; 0 failed` (lib, dont les 18 tests `compile_sandbox_plan` de sandbox.rs) - infrastructure : `154 passed; 0 failed` (lib, dont sandbox/noop + permission store) + suites d'intégration (`permission_store`, etc.) toutes à 0 failed - application : `72 passed; 0 failed` (lib) + `agent_lifecycle`, `change_agent_profile`, `permission_usecases` toutes vertes - app-tauri : `47 passed; 0 failed` Aucun warning nouveau introduit. Prêt pour QA (test bout-en-bout Landlock sur agent PTY avec Deny). +- **Prompt:** Lot LP4-4 — implémenter l'enforcement Landlock sur le chemin STRUCTURÉ (sessions Claude/Codex mode JSON), aujourd'hui non sandboxé. L'Architecte a validé l'approche (GO). Implémente exactement ce découpage ; ne réinvente pas la stratégie. STRATÉGIE VALIDÉE (approche b) : transposer la technique du PTY (`spawn_command_sandboxed` dans `crates/infrastructure/src/pty/mod.rs`). Le `pre_exec(enforce)` est INTERDIT (landlock alloue → deadlock malloc post-fork en process multithreadé). À la place : `enforce(plan)` sur un THREAD JETABLE AVANT le fork, puis spawn `std::process` synchrone depuis ce thread (l'enfant hérite le domaine Landlock via fork+exec), réconcilié à l'async par `spawn_blocking`. Le chemin non-sandboxé (sandbox==None OU pas d'enforcer, et tout non-Linux) reste le `drain` async tokio ACTUEL strictement inchangé (zéro régression). CONTRAT À MODIFIER : 1. `crates/domain/src/ports.rs` (~537) — `AgentSessionFactory::start` : ajouter param `sandbox: Option<&SandboxPlan>` (SandboxPlan est domaine, franchit déjà le port via SpawnSpec.sandbox — cohérent). 2. `crates/infrastructure/src/session/process.rs` — ajouter `pub sandbox: Option` à `SpawnLine` ; `run_turn` reçoit `enforcer: Option<&Arc>` ; nouveau `drain_sandboxed` : thread jetable → `enforcer.enforce(&plan)` (fail-closed : Err ⇒ échec du tour, AUCUN child ne tourne) → `std::process::Command::spawn` → poser un `unsafe { cmd.pre_exec(|| Ok(())) }` VIDE (async-signal-safe) pour forcer le chemin fork+exec déterministe → drain bloquant stdin/stdout→EOF→wait → Vec. Le thread meurt avec sa restriction. TIMEOUT sous sandbox : le thread renvoie son killer (Arc> ou pid) via un oneshot juste après spawn ; `tokio::time::timeout` sur le JoinHandle ; à expiration kill le child → EOF → le thread finit → renvoyer Timeout (pas de zombie/thread bloqué). 3. `crates/infrastructure/src/session/factory.rs` — `StructuredSessionFactory` gagne `Option>` + builder `with_sandbox_enforcer(...)` (jumeau exact de PortablePtyAdapter::with_sandbox_enforcer) ; `start` apparie plan (par-appel) + enforcer (par-instance) et les injecte dans `ClaudeSdkSession::new`/`CodexExecSession::new`. 4. `crates/infrastructure/src/session/claude.rs` (build_spawn_line ~187, send ~195) & `codex.rs` (~162/195) — stocker plan+enforcer, remplir `SpawnLine.sandbox`, passer l'enforcer à `run_turn`. 5. `crates/application/src/agent/lifecycle.rs` — `launch_structured` (~1620) reçoit le plan (`spec.sandbox`, déjà calculé en step 5d) et le relaie à `factory.start`. 6. `crates/app-tauri/src/state.rs` (~408) — `StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer())`. 7. FAKES à mettre à jour (nouvelle signature start) : `crates/domain/tests/structured_session_d0.rs`, `crates/application/tests/structured_launch_d3.rs`, `crates/application/tests/orchestrator_service.rs`. INVARIANT PRODUIT : eff==None ⇒ plan None ⇒ SpawnLine.sandbox None ⇒ drain async tokio inchangé, comportement natif. Respecte l'hexagonal (la résolution policy→plan reste pure dans le domaine ; l'infra ne fait qu'orchestrer + enforcer). À la fin : `cargo build` + `cargo test -p domain -p infrastructure -p application -p app-tauri` verts. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : fichiers modifiés, comment le timeout sous sandbox est géré, et la sortie RÉELLE des tests. Si quelque chose bloque ou ne passe pas, rapport clair avec sortie brute — je renverrai à l'Architecte ou on bouclera. Les tests d'enforcement e2e du chemin structuré seront écrits ensuite par QA (les 7 invariants), tu n'as pas à les écrire, mais assure-toi que les suites existantes restent vertes. +- **Response:** LP4-4 — enforcement Landlock sur le chemin STRUCTURÉ : FAIT, build + tests verts (80 suites, 0 failed). NON committé (figé par l'orchestrateur). ⚠️ UNE DIVERGENCE ASSUMÉE vs le cadrage (le `pre_exec` vide), détaillée plus bas — à valider par l'Architecte. Tout le reste est conforme au découpage. ## Fichiers modifiés (12) Production (6) : 1. `crates/domain/src/ports.rs` — `AgentSessionFactory::start` gagne `sandbox: Option<&crate::sandbox::SandboxPlan>` (dernier param). 2. `crates/infrastructure/src/session/process.rs` — `SpawnLine.sandbox: Option` ; `run_turn(spec, timeout, enforcer: Option<&Arc>)` ; nouveau `run_turn_sandboxed` (thread jetable + 2 oneshot killer/résultat + `tokio::time::timeout`) et `drain_sandboxed` (drain bloquant std), tous deux `#[cfg(target_os = "linux")]`. 3. `crates/infrastructure/src/session/factory.rs` — `StructuredSessionFactory` porte `Option>` + builder `with_sandbox_enforcer(...)` (jumeau de `PortablePtyAdapter`) ; `start` apparie plan (param, par-appel) + enforcer (champ, par-instance) → `ClaudeSdkSession::new`/`CodexExecSession::new`. (N'est plus `Copy`/`Debug` car porte un `Arc` ; `new()` n'est plus `const`.) 4. `crates/infrastructure/src/session/claude.rs` — stocke `sandbox`+`sandbox_enforcer`, remplit `SpawnLine.sandbox`, passe l'enforcer à `run_turn`. 5. `crates/infrastructure/src/session/codex.rs` — idem Claude. 6. `crates/application/src/agent/lifecycle.rs` — `launch_structured` reçoit `sandbox: Option<&SandboxPlan>` et le relaie à `factory.start` ; le call site passe `spec.sandbox.as_ref()` (déjà calculé en step 5d de LP4-3). 7. `crates/app-tauri/src/state.rs` — `StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer())`. Fakes/tests adaptés à la nouvelle signature (5) : `domain/tests/structured_session_d0.rs`, `application/tests/structured_launch_d3.rs`, `application/tests/orchestrator_service.rs` (les 3 fakes `start` + leurs call sites), plus `infrastructure/src/session/{mod.rs,conformance.rs}` (SpawnLine literals + tous les `run_turn`/`ClaudeSdkSession::new`/`CodexExecSession::new` internes). ## Gestion du timeout sous sandbox Le thread jetable n'est pas annulable de l'extérieur. Deux canaux `tokio::sync::oneshot` le réconcilient avec l'async : - **killer** : juste après le spawn, le thread envoie un `Arc>` ; il a au préalable **sorti `stdout`** du child (`.take()`) pour drainer SANS tenir le lock — donc le killer peut verrouiller à tout instant. - **résultat** : le thread envoie `Result, _>` à la fin. `tokio::time::timeout(dur, done_rx)` : - fini dans les temps ⇒ on rend le résultat ; - **expiration** ⇒ on `killer_rx.await` puis `child.lock().kill()` → l'enfant meurt → `stdout` EOF → la boucle de drain du thread sort → `child.wait()` (reap, **pas de zombie**) → le thread se termine. On rend `AgentSessionError::Timeout`. Le `JoinHandle` est détaché (pas de thread bloqué). `done_tx` lâché sans valeur (panique) ⇒ `Io`. `enforce` est **fail-closed** : `Err` ⇒ `AgentSessionError::Start` AVANT tout spawn (aucun enfant ne tourne). ## ⚠️ Divergence : pas de `pre_exec` vide (à arbitrer Architecte) Le cadrage demandait `unsafe { cmd.pre_exec(|| Ok(())) }` pour forcer std sur le chemin déterministe `fork`+`exec`. **Impossible ici** : la crate `infrastructure` est `#![forbid(unsafe_code)]` (lib.rs:12) — un `forbid` ne peut PAS être relâché localement par `#[allow]`. Le PTY n'avait jamais eu besoin d'`unsafe` : il obtient `fork`+`exec` gratuitement via le `pre_exec` **interne de portable-pty**. Sur le chemin structuré on pilote `std::process` en direct, donc le `pre_exec` serait à nous → `unsafe` → interdit. J'ai donc **retiré le `pre_exec`** sans perte de garantie de sécurité : `landlock_restrict_self` restreint le **thread courant et toute sa descendance**, héritage assuré par le noyau à travers `fork`/`clone`/`vfork` ET préservé par `execve` — **y compris via `posix_spawn`** (= `clone`+`execve` sous le capot), car l'enforcement vit au niveau des *credentials* de la tâche, hors d'atteinte de l'espace utilisateur. Le `fork`+`exec` n'était une garantie que de **déterminisme**, pas de sécurité ; l'enforce()-avant-spawn-sur-le-même-thread suffit. La technique validée (thread jetable, enforce avant spawn, fail-closed, reap) est intacte. Deux options pour l'Architecte s'il veut le `fork`+`exec` strict du cadrage : (a) rester comme livré (zéro unsafe, héritage noyau — mon choix par défaut) ; (b) passer lib.rs en `#![deny(unsafe_code)]` + `#[allow(unsafe_code)]` scoppé sur `drain_sandboxed` pour rétablir le `pre_exec`. J'attends son verdict avant de toucher l'invariant crate-wide. ## Invariant produit conservé `eff==None` ⇒ plan `None` ⇒ `SpawnLine.sandbox==None` ⇒ `run_turn` emprunte le **drain async tokio inchangé** (et **tout** non-Linux y reste, via `#[cfg(target_os="linux")]` sur le routage). Zéro régression. Hexagonal respecté : résolution policy→plan pure (domaine, step 5d), l'infra ne fait qu'orchestrer + enforcer ; le plan franchit le port en valeur domaine. ## Sortie réelle des tests `cargo build` : Finished, OK. `cargo test -p domain -p infrastructure -p application -p app-tauri` : **80 suites, toutes `ok`, 0 failed, 0 panic**. Notables : - domain (lib) : 141 passed ; `structured_session_d0` (fake start nouvelle signature) vert. - infrastructure (lib) : 156 passed (incluait 154 avant LP4-4 ; les nouveaux chemins compilent et tous les tests session/process restent verts) ; suites sandbox/permission toutes 0 failed. - application (lib) : 72 passed ; `structured_launch_d3`, `orchestrator_service` verts. - app-tauri : 47 passed. Un seul warning, **pré-existant et hors périmètre** : `method writes is never used` (orchestrator_service.rs:1641, helper de fixture mort avant LP4-4). Prêt pour QA (7 invariants e2e d'enforcement du chemin structuré : agent structuré lancé avec Deny ⇒ écriture impossible, etc.). \ No newline at end of file diff --git a/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/log.jsonl b/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/log.jsonl new file mode 100644 index 0000000..0a70afa --- /dev/null +++ b/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/log.jsonl @@ -0,0 +1,41 @@ +{"id":"d34885b1-f2b3-4a3b-8586-6b478035b2a5","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781380106786,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"IMPLÉMENTATION (code de production uniquement — PAS les nouveaux tests, c'est QA qui les écrit ; mais ne casse aucun test existant). Cadrage validé par l'Architect. Objectif : permettre la délégation inter-agents (idea_ask_agent/idea_reply) vers des agents au profil CODEX.\n\n## Principe directeur (isolation)\nCodex lit ses serveurs MCP dans `$CODEX_HOME/config.toml` (défaut ~/.codex), table TOML `[mcp_servers.]` (command/args/env) — c'est GLOBAL. Pour isoler par agent et éviter toute collision cross-projet, IdeA écrit le `config.toml` DANS LE RUN DIR de l'agent et pousse `CODEX_HOME={runDir}/.codex` dans l'env du process. Miroir exact de ce que Claude fait avec `.mcp.json` dans son cwd isolé. On ne touche JAMAIS au ~/.codex global.\n\n## Lots à implémenter (ordre D1→I1)\n\n### D1 — domaine : `crates/domain/src/profile.rs`\n- Ajouter une variante à l'enum `McpConfigStrategy` (tag serde \"strategy\") :\n `TomlConfigHome { target: String, home_env: String }`\n - `target` = chemin relatif sûr du config.toml (convention : \".codex/config.toml\").\n - `home_env` = nom de variable d'env pointée sur le DOSSIER PARENT de `target` (ex. \"CODEX_HOME\").\n- Constructeur validé (parse-don't-validate, comme les variantes existantes) : `toml_config_home(target, home_env) -> Result` : valider `target` via la même validation `relative_safe` que `ConfigFile` (rejet \"..\", rejet chemin absolu) et `home_env` comme nom de variable d'env valide.\n- Ajouter `impl AgentProfile { pub fn materializes_idea_bridge(&self) -> bool }` : SOURCE DE VÉRITÉ UNIQUE de la whitelist des couples (adaptateur structuré × stratégie MCP) qu'IdeA matérialise réellement :\n - `Some(Claude)` + `McpConfigStrategy::ConfigFile { target == \".mcp.json\" }` ⇒ true\n - `Some(Codex)` + `McpConfigStrategy::TomlConfigHome { .. }` ⇒ true\n - tout autre couple (y compris mcp absent) ⇒ false.\n\n### D2 — domaine : encodeur TOML partagé\n- Factoriser la donnée de wiring en une struct (ex. `McpServerWiring { command, args, transport }`) et fournir DEUX encodeurs : l'existant JSON (réutiliser) + un nouvel encodeur TOML produisant la table `[mcp_servers.idea]` (command, args, transport). Objectif : éliminer la dérive entre les sites qui sérialisent aujourd'hui (`mcp_server_declaration` en application, `mcp_server_entry` en app-tauri). Échappement TOML correct pour chemins avec espaces/backslash (équivalent du json_string actuel). Place-le là où c'est partageable par application ET app-tauri (domaine de préférence).\n\n### A1 — application : `crates/application/src/agent/lifecycle.rs`\n- Dans `apply_mcp_config`, ajouter le bras `TomlConfigHome { target, home_env }` :\n - écrire le `config.toml` (contenu via l'encodeur TOML D2) au chemin `{runDir}/` via `self.fs.write`, en respectant LA MÊME sémantique clobber/non-clobber que `.mcp.json` (clobber si `runtime.is_some()`, non-clobber sinon — calque le bras `ConfigFile` existant) ;\n - pousser `(home_env, parent_dir_de_target)` dans `spec.env` (le dossier parent de `target`, ex. `{runDir}/.codex`).\n- Ajouter le sibling `mcp_server_declaration_toml` si nécessaire (réutilise McpServerWiring D2).\n\n### A2 — application : `crates/application/src/orchestrator/service.rs`\n- Réexprimer `guard_mcp_bridge_supported` via `profile.materializes_idea_bridge()` : `if profile.materializes_idea_bridge() { Ok(()) } else { Err(Invalid(msg générique)) }`. Profil introuvable ⇒ `Ok(())` (inchangé). Généraliser le message d'erreur (ne plus dire « cible un agent Claude »).\n\n### I1 — app-tauri : `crates/app-tauri/src/state.rs`\n- Ajouter le pendant Codex de la réconciliation/migration du run dir (aujourd'hui Claude-only : `is_codex_mcp_profile`, `migrate_codex_run_dir`, `mcp_server_entry_toml` réutilisant McpServerWiring D2). Brancher dans le flux `reconcile_claude_run_dirs`/`migrate_claude_run_dirs` (dispatcher par profil, ou généraliser le nom). Même règle clobber/non-clobber. Idempotent.\n\n## Contraintes\n- Hexagonal/SOLID : AUCUN nouveau port ni adapter (réutilise `FileSystem`, `SpawnSpec.env`, `McpRuntime`). Ajout strictement déclaratif (1 variante d'enum) + encodeur + garde unifiée (Open/Closed).\n- N'écris PAS les nouveaux tests (QA s'en charge), mais le workspace doit COMPILER et les tests EXISTANTS rester verts.\n- Si un seed de profil Codex par défaut existe quelque part (first-run/providers), mets sa capacité MCP en `TomlConfigHome { target: \".codex/config.toml\", home_env: \"CODEX_HOME\" }` + transport Stdio + structured_adapter Codex. Repère-le ; sinon signale-le.\n\n## À rendre via idea_reply\n(a) liste des fichiers modifiés + résumé par lot ;\n(b) sortie réelle de `cargo build --workspace` ET `cargo test -p domain -p application` (au minimum) — vert exigé ;\n(c) tout écart vs le cadrage (ex. signature réelle de `apply_mcp_config`, emplacement choisi pour McpServerWiring) pour que QA et moi sachions où brancher les tests ;\n(d) confirmation que `~/.codex` global n'est jamais touché (seulement `{runDir}`).\nBoucle jusqu'au vert avant de répondre."} +{"id":"5876db69-e659-4034-9ae0-5fbbc48dbfe9","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781424333074,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"LP0 — Implémente le socle domaine `permission` (crate `domain`, module `permission`). PUR : aucun I/O (pas de tokio, std::fs, std::process). `serde` autorisé (format persisté). Respecte l'hexagonal strict et le style du crate domain existant (regarde p.ex. crates/domain/src/fileguard.rs et conversation.rs pour le style/idiomes).\n\nCONTRAT (figé par l'Architect, ne pas dévier) :\n\nVO / enums :\n- `Capability` = Read | Write | Delete | ExecuteBash\n- `Effect` = Allow | Deny\n- `Posture` = Ask | Allow | Deny\n- `PathScope` = { globs: Vec } // globs RELATIFS au project root, jamais absolus hors-root, pas de `..`\n- `CommandMatcher` = Exact(String) | Prefix(String) | Glob(Glob)\n- `CommandRule` = { matcher: CommandMatcher, effect: Effect }\n- `PermissionRule` = { capability: Capability, effect: Effect, paths: PathScope, commands: Vec }\n- `PermissionSet` = { rules: Vec, fallback: Posture }\n- `EffectivePermissions` = VO de sortie normalisé/aplati (résultat de resolve), seul input des futurs projecteurs.\n\nINVARIANTS (à valider, erreurs typées type `PermissionError`) :\n- `ExecuteBash` est la SEULE capacité qui porte des `commands` ; une règle bash avec `paths` non vide = erreur ; une règle fichier (Read/Write/Delete) avec `commands` non vide = erreur.\n- `PathScope.globs` : relatifs, pas de `..`, pas d'absolu sortant du root (réutilise/inspire-toi de la garde de `ConventionFile.target` si elle existe).\n- `Glob` non vide et compilable.\n- Un PermissionSet peut contenir Allow ET Deny sur la même capacité (scopes différents) = légal.\n\nFONCTION PURE DE RÉSOLUTION (cœur, à tester exhaustivement) :\n`pub fn resolve(project: Option<&PermissionSet>, agent: Option<&PermissionSet>) -> Option`\n1. project==None && agent==None ⇒ retourne **None** (rien posé ⇒ on ne projettera rien ⇒ le moteur prompte au run). INVARIANT PRODUIT CLÉ.\n2. Sinon fusion HÉRITAGE + OVERRIDE : on part des règles projet, on superpose les règles agent.\n3. DENY PRIORITAIRE (deny-wins) : pour une capacité+scope donnés, si un Deny matche (projet OU agent), il gagne sur tout Allow, à tous les niveaux. Non surchargeable par un allow plus spécifique.\n4. `fallback` : l'agent peut resserrer, mais un deny projet reste prioritaire.\nresolve doit être totale, déterministe.\n\nPÉRIMÈTRE LP0 STRICT : modèle + invariants + resolve + tests. PAS de PermissionStore, PAS de projection Claude/Codex, PAS d'I/O — ce sont les lots suivants. Expose proprement le module (pub use) pour que LP1/LP2/LP3 s'y branchent.\n\nTESTS : tu écris les tests unitaires de base, mais l'agent QA fera la batterie de tables de vérité — produis un code testable et des constructeurs/`new` validants.\n\nQuand c'est fait : `cargo test -p domain` doit passer. Réponds via idea_reply avec : fichiers créés/modifiés, résumé de l'API publique exposée, et le résultat réel de `cargo test -p domain` (sortie). Si ça ne compile pas ou que des tests échouent, dis-le franchement avec la sortie."} +{"id":"e6b22fe4-fe6e-48ae-927e-3ec09657db9d","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781424756331,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"LP0 livré — socle domaine `permission` PUR (zéro I/O), tests verts.\n\n## Fichiers\n- **Créé** `crates/domain/src/permission.rs` (~640 l. avec tests).\n- **Modifié** `crates/domain/src/lib.rs` : `pub mod permission;` + ré-exports curatés.\n- Réutilise `crate::validation::relative_safe` (déjà la garde de `ConventionFile.target`) pour la path-safety, comme demandé. Aucune nouvelle dépendance Cargo (moteur glob pur maison, pas de crate `glob`).\n\n## API publique exposée (ré-exportée depuis `domain`)\n- VO/enums : `Capability` (Read|Write|Delete|ExecuteBash, + `is_file()`/`is_bash()`), `Effect` (Allow|Deny), `Posture` (Ask|Allow|Deny, + `tighten()`), `Glob` (`new`→valide non-vide+compilable, `matches`, `pattern`), `PathScope` (`new`/`empty`/`globs`/`is_empty`/`matches`), `CommandMatcher` (`exact`/`prefix`/`glob`/`matches`), `CommandRule{matcher,effect}`.\n- Règles : `PermissionRule` via constructeurs validants `file(cap,effect,paths)`, `bash(effect,commands)`, et `new(cap,effect,paths,commands)` (chemin désérialisé) ; accesseurs `capability/effect/paths/commands`.\n- `PermissionSet{rules,fallback}` (`new`, `rules`, `fallback`).\n- `EffectivePermissions` (sortie aplatie ; `rules`, `fallback`, + décisions deny-wins testables `decide_file(cap,path)` et `decide_bash(cmd)` → `Posture`).\n- `PermissionError` (typée) : EmptyGlob, InvalidGlob, EmptyCommandMatcher, PathNotRelativeSafe, BashRuleHasPaths, FileRuleHasCommands, NotAFileCapability.\n- Fonction pure : `resolve(project, agent) -> Option` (ré-exportée aussi sous `resolve_permissions`).\n\n## Invariants implémentés\n- ExecuteBash = seule capacité à porter `commands` ; bash+paths ⇒ `BashRuleHasPaths` ; fichier+commands ⇒ `FileRuleHasCommands`.\n- `PathScope` : relatif, pas de `..`, pas d'absolu (via `relative_safe`).\n- `Glob` non-vide + compilable (validation des classes `[...]` terminées).\n- Allow ET Deny sur la même capacité (scopes ≠) = légal (pas d'erreur de conflit).\n\n## resolve (totale, déterministe)\n1. `None,None ⇒ None` (invariant produit : rien posé ⇒ rien projeté ⇒ prompt natif au run). ✔ testé.\n2. Fusion héritage+override : règles projet puis règles agent superposées (union préservée pour les projecteurs).\n3. Deny-wins appliqué par `decide_*` sur l'union : un Deny (projet OU agent, niveau règle OU commande) gagne sur tout Allow, non surchargeable par un allow plus spécifique. ✔ testé (`project_deny_beats_agent_allow`).\n4. `fallback` = le plus restrictif des deux (`Posture::tighten`, ordre Allow { pub project_root: &'a str, pub run_dir: &'a str }\n\npub enum ProjectedFile {\n /// Fichier 100% possédé par IdeA → clobber au launch, suppression au swap-away.\n Replace { rel_path: String, contents: String },\n /// Fichier co-possédé (ex. config.toml Codex) → merge des seules clés gérées, jamais supprimé au swap.\n MergeToml { rel_path: String, managed_tables: Vec, managed_keys: Vec, contents: String },\n}\n\npub struct PermissionProjection {\n pub files: Vec,\n pub args: Vec,\n pub env: Vec<(String, String)>,\n}\n\npub trait PermissionProjector: Send + Sync {\n fn key(&self) -> ProjectorKey;\n /// PUR. `eff == None` ⇒ projection VIDE (on garde le prompting natif de la CLI — invariant produit de resolve()).\n fn project(&self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext) -> PermissionProjection;\n /// Chemins run-dir-relatifs des fichiers `Replace` possédés (pour le nettoyage au swap).\n fn owned_replace_paths(&self) -> Vec;\n}\n```\n`ProjectorKey` : un type clé déclaratif. Choisis la forme la plus simple et idiomatique cohérente avec le reste du domaine (enum fermé Claude|Codex, ou newtype String). Regarde comment les autres clés/ids du domaine sont faits avant de trancher, et reste cohérent. Doit être Serialize/Deserialize, Clone, Eq, Hash.\n\nDans crates/domain/src/profile.rs (struct `AgentProfile`) :\n- Ajouter un champ `projector: Option` avec `#[serde(default)]` (compat : profiles.json existants sans le champ doivent désérialiser → None).\n- Mettre à jour le builder / constructeur du profil en cohérence avec le style existant.\n- Profils builtin Claude et Codex : poser respectivement la clé claude / codex.\n\nExports : ajoute les nouveaux types publics dans domain/src/lib.rs (bloc `pub use permission::{…}`) comme les types permission existants.\n\nCONTRAINTES :\n- Module permission.rs reste PUR (règle de dépendance de l'archi). Le trait ne fait que TRADUIRE EffectivePermissions → valeur (un PLAN), il n'écrit RIEN.\n- Ne touche PAS encore aux impls concrètes ni au lancement (c'est LP3-2 et +). Ce lot = uniquement le contrat domaine + le champ profil + exports.\n- Ne casse aucun call-site existant : `cargo build -p domain` doit passer.\n\nQuand c'est fait, réponds via idea_reply en listant : les types ajoutés, la forme retenue pour ProjectorKey (et pourquoi), les modifs sur AgentProfile/builtins, et le résultat de `cargo build -p domain`. QA écrira les tests juste après — ne les écris pas toi-même mais assure-toi que ça compile."} +{"id":"ea669242-c358-4f49-8a1a-92412d6dfadd","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781523851178,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"Lot LP3-1 (domaine) terminé — contrat pur uniquement, aucune I/O, aucune impl concrète.\n\nTYPES AJOUTÉS (crates/domain/src/permission.rs) :\n- `ProjectorKey` (enum fermé `Claude | Codex`)\n- `ProjectionContext<'a>` { project_root, run_dir } (emprunts, jamais stocké)\n- `ProjectedFile` { Replace { rel_path, contents } | MergeToml { rel_path, managed_tables, managed_keys, contents } }\n- `PermissionProjection` { files, args, env } + ctor `empty()` (la valeur-invariant rendue quand eff == None)\n- trait `PermissionProjector: Send + Sync` { key(), project(eff, ctx), owned_replace_paths() }\n\nFORME RETENUE POUR ProjectorKey : **enum fermé** `Claude | Codex` (et non newtype String).\nPourquoi : c'est l'idiome déjà en place dans le domaine pour les clés de famille de moteur — calqué pile sur `StructuredAdapter` (Claude|Codex) juste à côté. Dérivations : `Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize` + `#[serde(rename_all = \"camelCase\")]` ⇒ couvre les exigences (Serialize/Deserialize/Clone/Eq/Hash). Avantages vs String : match exhaustif garanti dans le chemin de lancement (LP3-2+), impossible de porter une valeur inconnue au runtime, sérialisation stable `\"claude\"`/`\"codex\"` cohérente avec `provider_key()`.\n\nMODIFS AgentProfile (crates/domain/src/profile.rs) :\n- Champ `pub projector: Option` avec `#[serde(default, skip_serializing_if = \"Option::is_none\")]` ⇒ compat : profiles.json sans le champ → None, et zéro régression de sérialisation (clé omise quand None).\n- `AgentProfile::new` initialise `projector: None` (signature `new` inchangée, zéro régression d'appel).\n- Builder `const fn with_projector(self, ProjectorKey) -> Self`, cohérent avec `with_structured_adapter`/`with_mcp`/etc.\n\nBUILTINS (crates/application/src/agent/catalogue.rs) :\n- profil « claude » : `.with_projector(ProjectorKey::Claude)`\n- profil « codex » : `.with_projector(ProjectorKey::Codex)`\n- Gemini/Aider : inchangés (pas de projector).\n\nEXPORTS (crates/domain/src/lib.rs) : ajout dans le bloc `pub use permission::{…}` de `PermissionProjection, PermissionProjector, ProjectedFile, ProjectionContext, ProjectorKey`.\n\nPÉRIMÈTRE RESPECTÉ : module permission.rs reste PUR (le trait TRADUIT EffectivePermissions → PLAN, n'écrit rien) ; aucune impl concrète ni câblage de lancement (réservés LP3-2+) ; aucun call-site existant cassé.\n\nRÉSULTAT BUILD :\n- `cargo build -p domain` → Finished (OK, 0 erreur/warning).\n- `cargo build -p application` → Finished (OK) — vérifié pour confirmer que les builtins du catalogue compilent.\n\nJe n'ai PAS écrit de tests (réservé à QA), mais ça compile aux deux niveaux."} +{"id":"c288ef08-5d67-45ea-81eb-59ba12c65c7d","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781523974768,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Lot LP3-2 (infrastructure) — implémenter `ClaudePermissionProjector` et `CodexPermissionProjector`. C'est avant tout un REFACTOR D'EXTRACTION : le code de traduction existe déjà dans application/src/agent/lifecycle.rs et est testé ; tu le déplaces derrière le trait `PermissionProjector` (livré en LP3-1) sous forme de projecteurs PURS rendant un `PermissionProjection`. Ne change PAS les règles de traduction (postures→allow/deny/ask, modes sandbox), juste leur emplacement et leur forme de sortie.\n\nCRÉER : crates/infrastructure/src/permission/ (nouveau module ; déclare-le dans infrastructure/src/lib.rs et expose `ClaudePermissionProjector`, `CodexPermissionProjector`).\n\nSOURCE à extraire (dans application/src/agent/lifecycle.rs) — repère et reprends la logique de :\n- Claude : `claude_settings_seed` (~l.2063) et ses helpers → produit le contenu de `.claude/settings.local.json` (listes allow/deny/ask + `additionalDirectories=[project_root]`). \n- Codex : `apply_codex_cli_permission_args` (~l.2226, args `--sandbox`/`--ask-for-approval`) et `codex_config_toml` (~l.2258, clés `sandbox_mode`/`approval_policy`).\n- Les tests de traduction existants se trouvent vers lifecycle.rs l.2957-3068 — tu peux t'y référer pour ne pas dévier des règles (QA les relocalisera/réutilisera au lot test).\n\nCE QUE CHAQUE PROJECTEUR DOIT RENDRE (trait LP3-1) :\n- `key()` → ProjectorKey::Claude / ::Codex.\n- `project(eff: Option<&EffectivePermissions>, ctx: &ProjectionContext) -> PermissionProjection` :\n * `eff == None` ⇒ `PermissionProjection::empty()` (invariant produit : on ne projette rien, prompting natif conservé). NE PAS oublier ce cas.\n * Claude : `files = [ProjectedFile::Replace { rel_path: \".claude/settings.local.json\", contents: }]`, args/env vides. Le JSON dérive de eff via `decide_file`/`decide_bash` exactement comme l'actuel `claude_settings_seed`. Utilise `ctx.project_root` pour `additionalDirectories`.\n * Codex : `files = [ProjectedFile::MergeToml { rel_path: \"config.toml\", managed_tables: [...], managed_keys: [\"sandbox_mode\",\"approval_policy\"], contents: }]` + `args = [--sandbox …, --ask-for-approval …]`. Reprends la dérivation posture→mode de l'existant.\n- `owned_replace_paths()` → Claude : `[\".claude/settings.local.json\"]` ; Codex : `[]` (config.toml est co-possédé via MergeToml, jamais supprimé).\n\nCONTRAINTES :\n- Projecteurs PURS : ils n'écrivent RIEN sur le FS (pas de `FileSystem`). Ils rendent le PLAN. L'écriture/fold sera faite par LancheAgent en LP3-3.\n- NE TOUCHE PAS encore à lifecycle.rs (suppression des anciens appels seed_cli_permissions / découplage MCP = LP3-3). Pour ce lot, le code source d'origine peut rester en place en double temporairement — l'important est que les nouveaux projecteurs existent, compilent, et soient corrects. (Si tu préfères factoriser des helpers communs, garde-les dans le module infra/permission.)\n- Respecte le style/erreurs de l'infrastructure existante. `cargo build -p infrastructure` doit passer.\n\nRéponds via idea_reply : fichiers créés, structure des deux projecteurs, toute divergence inévitable vs le code d'origine (idéalement aucune), et le résultat de `cargo build -p infrastructure`. QA testera ensuite (golden par posture, deny-wins, escaping, args/toml Codex)."} +{"id":"7315b9a5-51cd-47c7-bc37-bdc0a798bc0c","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781524117938,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"Lot LP3-2 (infrastructure) terminé — extraction pure derrière le trait `PermissionProjector` (LP3-1). Aucune règle de traduction modifiée, seulement déplacée et reshapée en `PermissionProjection`.\n\nFICHIERS CRÉÉS (nouveau module crates/infrastructure/src/permission/) :\n- `mod.rs` — déclare/réexporte les deux projecteurs ; helpers communs `pub(crate)` extraits verbatim de lifecycle.rs : `json_escape`, `toml_string`.\n- `claude.rs` — `ClaudePermissionProjector` (+ helpers privés `claude_settings_seed`, `merge_default_deny`, `claude_permission_entries`, `push_path/delete/bash_entries`, `command_matcher_pattern`, `json_string_array`, tous repris verbatim).\n- `codex.rs` — `CodexPermissionProjector` (+ `codex_sandbox_mode`, `codex_approval_policy` verbatim).\n\nDÉCLARATION/EXPORTS (crates/infrastructure/src/lib.rs) : `pub mod permission;` + `pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};`.\n\nSTRUCTURE DES DEUX PROJECTEURS (structs unit, `Default + Copy`, purs, zéro `FileSystem`/I/O) :\n\n• ClaudePermissionProjector\n - `key()` → `ProjectorKey::Claude`\n - `project(None, _)` → `PermissionProjection::empty()` (invariant produit conservé)\n - `project(Some, ctx)` → `files = [Replace { rel_path: \".claude/settings.local.json\", contents: }]`, args/env vides. Le JSON est produit par `claude_settings_seed(ctx.project_root, eff)` — identique mot pour mot à l'ancien : `defaultMode` (Deny→plan / Ask→acceptEdits / Allow|None→bypassPermissions), `additionalDirectories=[project_root]` (JSON-escaped), listes allow/deny dérivées des `rules()` (defaults Read/Edit/Write/Bash si allow vide), `merge_default_deny` (garde-fous sudo/rm -rf/mkfs/dd/shutdown/reboot), `enabledMcpjsonServers:[\"idea\"]`, `sandbox.enabled=false`.\n - `owned_replace_paths()` → `[\".claude/settings.local.json\"]`\n\n• CodexPermissionProjector\n - `key()` → `ProjectorKey::Codex`\n - `project(None, _)` → `PermissionProjection::empty()`\n - `project(Some, _ctx)` → `files = [MergeToml { rel_path, managed_tables: [], managed_keys: [\"sandbox_mode\",\"approval_policy\"], contents: \"sandbox_mode = \\\"…\\\"\\napproval_policy = \\\"…\\\"\\n\" }]` + `args = [\"--sandbox\", , \"--ask-for-approval\", ]`, env vide. Dérivation posture→mode verbatim : sandbox Deny→read-only / Ask|Allow→workspace-write ; approval Allow→never / Ask|Deny→on-request.\n - `owned_replace_paths()` → `[]` (config.toml co-possédé, jamais supprimé au swap).\n\nDIVERGENCES vs code d'origine (2, assumées et justifiées — règles inchangées) :\n1. **rel_path Codex = \".codex/config.toml\"** et non `\"config.toml\"` comme l'exemple littéral du ticket. Raison : le contrat `ProjectedFile.rel_path` est « run-dir-relatif », et le fichier que Codex lit réellement est `$CODEX_HOME/config.toml` avec `CODEX_HOME={runDir}/.codex` (cf. profil builtin `toml_config_home(\".codex/config.toml\", \"CODEX_HOME\")`). `\"config.toml\"` à la racine du run dir ne serait pas lu par Codex. J'ai donc aligné sur le chemin réel ; si LP3-3 préfère composer ce préfixe ailleurs, à ajuster d'un mot.\n2. **Périmètre Codex limité aux clés de permission** : le `MergeToml.contents` ne contient QUE `sandbox_mode`/`approval_policy` (donc `managed_tables = []`). Le merge de `mcp_servers.idea` et les entrées `projects.*` (trust) de l'ancien `codex_config_toml` ne sont PAS des concerns de permission — ils restent au câblage MCP (découplage prévu en LP3-3). C'est conforme au scope du lot (projection de permission seule).\n\nNON TOUCHÉ : lifecycle.rs reste inchangé (code source d'origine en place en double temporairement, suppression des anciens appels = LP3-3).\n\nRÉSULTAT BUILD : `cargo build -p infrastructure` → Finished (OK, 0 erreur/warning ; domain + application + infrastructure recompilent proprement).\n\nTests laissés à QA (golden par posture, deny-wins, escaping JSON/TOML, args/toml Codex)."} +{"id":"10c48abb-a4a2-46ea-a8bf-8718bbae0e84","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781524298657,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Lot LP3-3 (application) — câbler la projection des permissions dans le chemin de lancement, via un registre, et DÉCOUPLER la projection Codex de MCP. Lot délicat : tu touches LaunchAgent. Conserve la non-régression (builders optionnels).\n\nFichiers : crates/application/src/agent/lifecycle.rs principalement (+ exports lib si besoin).\n\n1) REGISTRE\n- Définis `PermissionProjectorRegistry` = map `ProjectorKey -> Arc`. Place-le là où c'est cohérent (application, près de LaunchAgent, ou un petit module). Méthode `get(key) -> Option<&Arc>`.\n- Injecte-le dans `LaunchAgent` via un builder OPTIONNEL `with_permission_projectors(Arc)` — même pattern que `with_handoff_provider` / `with_mcp` existants. Si absent → aucune projection (comportement actuel préservé pour les call-sites/tests legacy).\n\n2) SÉLECTION DU PROJECTEUR\n- À partir du profil de l'agent lancé : clé = `profile.projector`. \n- FALLBACK DE MIGRATION (profil sans projector, ex. profiles.json ancien) : si `profile.projector == None`, dérive la clé via l'heuristique actuelle — convention-file `CLAUDE.md` ⇒ Claude ; `StructuredAdapter::Codex` ou stratégie TomlConfigHome ⇒ Codex. Sinon None.\n- `None` ⇒ pas de projection.\n\n3) ÉTAPE DE PROJECTION (remplace l'existant)\n- Crée une étape unique `apply_permission_projection(profile, run_dir, project_root, eff, &mut spec)` qui :\n * résout `EffectivePermissions` pour l'agent (le use case/le store de permissions est déjà là — réutilise ResolveAgentPermissions / le ProjectPermissions résolu ; eff peut être None).\n * sélectionne le projecteur (cf. 2), appelle `project(eff.as_ref(), &ProjectionContext{project_root, run_dir})`.\n * ÉCRIT les fichiers du plan : `Replace` ⇒ CLOBBER systématique (écrase) ; `MergeToml` ⇒ merge des seules clés gérées via les helpers TOML existants (`set_top_level_toml_value`/`replace_toml_table`).\n * FOLD `args` et `env` du plan dans `spec` AVANT le split structuré/PTY.\n- PLACEMENT : juste après `apply_injection` (~l.1294) et après `apply_mcp_config` (~l.1312), donc AVANT le split structuré/PTY (~l.1321) et avant `pty.spawn`. Les deux chemins (structuré ET PTY) doivent hériter de la projection.\n\n4) NETTOYAGE DE L'EXISTANT (le cœur du lot)\n- SUPPRIME l'appel à `seed_cli_permissions` (~l.1234) et la fonction si elle n'est plus utilisée (sa logique vit maintenant dans le projecteur Claude infra). Note : l'ancien était NON-clobbering ; le nouveau est clobber — c'est voulu (permet la re-projection au swap).\n- DÉCOUPLE Codex de MCP : dans `apply_mcp_config` (~l.1838 / branche TomlConfigHome), RETIRE tout ce qui écrit `sandbox_mode`/`approval_policy` et les args `--sandbox`/`--ask-for-approval`. `apply_mcp_config` ne fait PLUS que du MCP (mcp_servers.idea, trust/projects). La projection sandbox Codex vient désormais UNIQUEMENT de l'étape de projection. Conséquence voulue : un profil Codex SANS MCP reçoit quand même sa sandbox.\n- Supprime de lifecycle.rs le code de traduction dupliqué en LP3-2 (claude_settings_seed, apply_codex_cli_permission_args, codex_config_toml partie perms + helpers) s'il n'est plus référencé, MAIS garde les helpers TOML génériques (`set_top_level_toml_value` etc.) et la partie MCP de codex_config_toml. Les tests de traduction de lifecycle.rs (~l.2957-3068) qui testaient ces fonctions vont casser à la compilation : signale-les à QA (elles sont remplacées par les tests infra LP3-2) — tu peux les retirer si elles testent du code supprimé.\n\nCONTRAINTE : `cargo build -p application` doit passer. Ne pré-instancie PAS le registre dans app-tauri (c'est LP3-5). Mais pour permettre à QA de tester, expose ce qu'il faut (le registre, le builder).\n\nRéponds via idea_reply : ce que tu as câblé/supprimé/découplé, l'emplacement exact de la nouvelle étape, les tests de lifecycle.rs que tu as dû retirer/adapter (avec justification), et `cargo build -p application`. Si le découplage MCP a un effet de bord sur des tests MCP existants, dis-le précisément."} +{"id":"2b572959-ea69-4057-8574-d20306b032bc","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781524876088,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"Lot LP3-3 (application) terminé : projection des permissions câblée via registre dans LaunchAgent, et projection Codex DÉCOUPLÉE de MCP. `cargo build -p application` OK, `cargo test -p application` 100 % vert (tous les fichiers de tests).\n\nCE QUI A ÉTÉ CÂBLÉ\n1) Registre — `PermissionProjectorRegistry` (map `ProjectorKey → Arc`) défini dans lifecycle.rs près de LaunchAgent. API : `new()`, `with(Arc)` (builder), `insert(..)`, `get(key) -> Option<&Arc<..>>` (clé = `projector.key()`). Exporté : `application::PermissionProjectorRegistry` (via agent/mod.rs + lib.rs) pour QA et LP3-5.\n2) Injection — builder OPTIONNEL `LaunchAgent::with_permission_projectors(Arc)`, même pattern que `with_handoff_provider`/`with_structured`. Champ `projectors: Option<..>` = None par défaut ⇒ aucune projection (call-sites/tests legacy inchangés, signature `new` intacte).\n3) Sélection du projecteur — `fn select_projector_key(profile) -> Option` : (a) `profile.projector` si présent ; (b) FALLBACK migration legacy : convention-file `CLAUDE.md` ⇒ Claude ; `StructuredAdapter::Codex` OU stratégie MCP `TomlConfigHome` ⇒ Codex ; sinon None.\n4) Étape de projection — `async fn apply_permission_projection(profile, run_dir, project_root, eff, &mut spec)` : résout le projecteur, appelle `project(eff, &ProjectionContext{project_root, run_dir})`, puis applique le PLAN : `Replace` ⇒ CLOBBER (écrase systématiquement, crée le sous-dossier via `ensure_run_dir_parent`) ; `MergeToml` ⇒ lit l'existant et merge SEULES les clés/tables gérées via `merge_managed_toml` (réutilise/raffine les helpers TOML existants). `args`/`env` du plan foldés dans `spec`. No-op si registre absent / profil non projetable / `eff == None`.\n\nEMPLACEMENT EXACT DE LA NOUVELLE ÉTAPE\nDans `LaunchAgent::execute`, nouvelle étape « 5c » insérée APRÈS `apply_injection` (convention file) ET APRÈS `apply_mcp_config`, et AVANT le point de routage structuré/PTY « 5b » (donc avant `pty.spawn` ET avant `launch_structured`). Les deux chemins héritent donc de la projection (fichiers écrits + args/env dans `spec`, `spec` étant cloné/consommé par les deux branches en aval).\n\nNETTOYAGE / DÉCOUPLAGE\n- SUPPRIMÉ l'appel `seed_cli_permissions` (ex-étape 3b) ET la méthode. L'ancien était non-clobbering ; le nouveau projecteur Claude écrit en CLOBBER (voulu : permet la re-projection au swap).\n- DÉCOUPLÉ Codex de MCP : retiré l'appel `apply_codex_cli_permission_args(spec, permissions)` de `apply_mcp_config`, et retiré l'écriture `sandbox_mode`/`approval_policy` de `codex_config_toml` (param `permissions` supprimé). `apply_mcp_config` ne fait PLUS que du MCP (table `mcp_servers.idea` + trust `projects.*`) ; sa signature perd le param `permissions` (call-site interne mis à jour). CONSÉQUENCE VOULUE : un profil Codex SANS MCP reçoit quand même sa sandbox (la projection est une étape indépendante de `apply_mcp_config`).\n- SUPPRIMÉ le code de traduction dupliqué en LP3-2, désormais non référencé : `claude_settings_seed` + helpers (`merge_default_deny`, `claude_permission_entries`, `push_path/delete/bash_entries`, `command_matcher_pattern`, `json_string_array`), `apply_codex_cli_permission_args`, `codex_sandbox_mode`, `codex_approval_policy`. CONSERVÉ les helpers TOML génériques (`replace_toml_table`, `ensure_codex_trust`, `prepend_line`, `append_block`, `toml_string`, `json_escape`) + la partie MCP de `codex_config_toml`. `set_top_level_toml_value` refactoré en `set_top_level_toml_line` (cœur ligne-à-ligne, réutilisé par `merge_managed_toml`). Nouveaux helpers de merge : `merge_managed_toml`, `extract_top_level_toml_line`, `extract_toml_table`.\n\nTESTS lifecycle.rs RETIRÉS/ADAPTÉS (justifiés)\n- RETIRÉS les 3 tests `claude_settings_seed_*` (autonomy/guardrails, escaping, projects_effective_permissions) : ils testaient `claude_settings_seed`, code déplacé → couvert par les tests du `ClaudePermissionProjector` infra (LP3-2, QA les a déjà écrits).\n- ADAPTÉS les 2 tests `codex_config_*` : `codex_config_toml` est désormais MCP-only (plus de param `permissions`). `codex_config_trusts_run_and_project_and_replaces_only_idea_mcp` ne vérifie plus `sandbox_mode`/`approval_policy` (asserte au contraire leur ABSENCE) ; `codex_config_without_explicit_permissions_keeps_native_policy_keys` → renommé `codex_config_is_mcp_only_and_adds_no_permission_keys`.\n- AJOUTÉ `merge_managed_toml_upserts_only_managed_keys` (vérifie l'upsert des seules clés gérées sans toucher au reste — table MCP + clé user préservées).\n\nEFFET DE BORD sur tests d'intégration (1 fichier, agent_lifecycle.rs) — `launch_orders_prepare_then_injection_then_spawn` testait l'ANCIEN ordre « seed (fs.write) → prepare → injection → spawn » et le seed Claude (Bug #5). Comme la fixture n'injecte PAS de registre, plus aucun seed n'est écrit. ADAPTÉ : ordre attendu = `prepare → injection(fs.write) → spawn` ; les asserts de seed remplacés par `assert!(fs.seed_writes().is_empty())` + commentaire renvoyant la couverture de projection aux tests LP3-2/LP3-3. Aucun autre test MCP impacté (les intégrations MCP passent par `LaunchAgent::execute`, signature publique inchangée).\n\nPAS FAIT (hors scope) : pas de pré-instanciation du registre dans app-tauri (LP3-5) — vérifié que `cargo build -p app-tauri` passe quand même (registre non câblé ⇒ comportement actuel).\n\nRÉSULTATS\n- `cargo build -p application` → Finished OK.\n- `cargo test -p application` → tous verts (37+6+48+12+6+7+8+8+7+3+… 0 failed).\n- `cargo build -p app-tauri` → Finished OK (régression nulle, registre non encore câblé).\n\nQA : tests de l'étape de projection à écrire — sélection clé (explicite + 3 fallbacks legacy), clobber `Replace`, merge `MergeToml` (idempotence + préservation), fold args/env, no-op (registre absent / eff None), et le cas « Codex sans MCP reçoit sa sandbox »."} +{"id":"af819c26-e600-41ad-b0f4-ffe32196009c","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781525293280,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Lot LP3-4 (application) — nettoyage des fichiers de permission du profil précédent au swap cross-profile. La re-projection elle-même est DÉJÀ automatique (ChangeAgentProfile compose LaunchAgent, qui re-résout EffectivePermissions profil-agnostique et re-projette via le projecteur du NOUVEAU profil). Il ne te reste qu'à AJOUTER le nettoyage des fichiers orphelins du profil précédent.\n\nFichier : crates/application/src/agent/lifecycle.rs — `ChangeAgentProfile::execute` (~l.418, relaunch construit ~l.573).\n\nFAIT STRUCTURANT : le run dir est stable par agent id (`agent_run_dir(root, agent.id)`). Donc après un swap, les fichiers de config du profil précédent SURVIVENT dans le même run dir. Ex. Claude→Codex laisse un `.claude/settings.local.json` périmé. À nettoyer.\n\nRÈGLE DE NETTOYAGE (validée par l'Architect) :\n- Fichiers `Replace` (100% possédés, ex. `.claude/settings.local.json`) : au swap, supprimer (best-effort) l'ensemble `owned_replace_paths(ancien_projecteur) − owned_replace_paths(nouveau_projecteur)`. Donc on ne supprime QUE les fichiers possédés par l'ancien profil que le nouveau ne réécrira pas. (Si on swappe Claude→Claude, différence vide, rien supprimé ; Claude→Codex supprime `.claude/settings.local.json` ; Codex→Claude ne supprime rien côté Replace car Codex n'a pas de Replace.)\n- Fichiers `MergeToml` (co-possédés, ex. `.codex/config.toml`) : JAMAIS supprimés (le fichier n'est lu que par la CLI concernée, inoffensif). Aucune action.\n- Best-effort : si la suppression échoue (fichier déjà absent), ne pas faire échouer le swap.\n\nIMPLÉMENTATION :\n- ChangeAgentProfile doit connaître : l'ANCIEN profil (donc l'ancien projecteur) + le NOUVEAU + le FileSystem + le registre. L'ancien profil_id est disponible via le manifeste AVANT mutation. Le run dir est dérivable de l'agent id.\n- Injecte le registre dans ChangeAgentProfile (builder optionnel cohérent : `with_permission_projectors`, ou réutilise ce qui est passé au LaunchAgent interne — choisis l'option la plus propre et explique). Si pas de registre → pas de nettoyage (comportement legacy).\n- Le nettoyage s'exécute AVANT la relance (avant que LaunchAgent ne re-projette le nouveau profil), pour éviter de supprimer un fichier que le nouveau profil vient d'écrire. Calcule la différence sur les `owned_replace_paths`, supprime les orphelins, PUIS relance.\n- NON-RÉGRESSION CRITIQUE : l'id de paire stable + le handoff cross-profile (P8d) doivent rester intacts. Ne touche pas à cette logique, ajoute seulement l'étape de nettoyage.\n\nCONTRAINTE : `cargo build -p application` passe. Builder optionnel ⇒ call-sites/tests legacy inchangés.\n\nRéponds via idea_reply : comment tu as injecté le registre dans ChangeAgentProfile, l'emplacement exact de l'étape de nettoyage (avant relance), la façon dont tu récupères l'ancien projecteur + run dir, la confirmation que l'id de paire/handoff n'est pas touché, et `cargo build -p application`. QA testera ensuite les scénarios de swap."} +{"id":"ba075ad9-534e-41e6-a55b-04297c537aed","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781525532288,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"Lot LP3-4 (application) terminé : nettoyage des fichiers de permission orphelins au swap cross-profile. `cargo build -p application` OK ; tout le workspace compile (domain/infrastructure/app-tauri) et `cargo test -p application` 100 % vert (dont les 59 tests change_agent_profile/lifecycle, inchangés).\n\nINJECTION DU REGISTRE DANS ChangeAgentProfile\n- Builder OPTIONNEL `ChangeAgentProfile::with_permission_projectors(Arc)`, miroir exact de `with_structured`. Nouveau champ `projectors: Option>` = None par défaut ⇒ pas de nettoyage (legacy), signature `new` inchangée (tests A verts).\n- Choix : registre INJECTÉ SÉPARÉMENT (pas extrait du LaunchAgent interne). Raison : `LaunchAgent.projectors` est privé et sans getter ; ajouter un accesseur pour exposer un détail interne casserait l'encapsulation. Le câblage (LP3-5) passera le **même** `Arc` aux deux (`LaunchAgent::with_permission_projectors` ET `ChangeAgentProfile::with_permission_projectors`) → source unique de vérité, zéro divergence.\n\nRÉCUPÉRATION ANCIEN PROJECTEUR + RUN DIR\n- Ancien profil_id : capturé AVANT mutation, à l'étape 1 (`let previous_profile_id = entry.profile_id;`), juste après résolution de l'entrée du manifeste.\n- Profils : l'étape 3 (déjà un `profiles.list()`) est élargie pour capturer le NOUVEAU profil (validation : NotFound si absent) ET le PRÉCÉDENT (`previous_profile: Option` ; absent si le profil a été supprimé entre-temps ⇒ nettoyage sauté).\n- Projecteur : `select_projector_key(previous_profile)` puis `registry.get(key)` (réutilise exactement la sélection LP3-3, fallback legacy inclus). Idem pour le nouveau (ensemble « gardé »).\n- Run dir : `agent_run_dir(&input.project.root, &input.agent_id)` (stable par agent id — c'est précisément pourquoi les fichiers survivent).\n\nEMPLACEMENT EXACT DE L'ÉTAPE\nNouvelle étape **5b**, dans `ChangeAgentProfile::execute`, APRÈS `invalidate_engine_link` (étape 5) et AVANT `relaunch_if_live` (étape 6) — donc avant que le `LaunchAgent` composé ne re-projette le nouveau profil. Méthode `cleanup_swapped_out_files(project_root, agent_id, previous_profile.as_ref(), &new_profile)`.\n\nRÈGLE APPLIQUÉE\n- Supprime `owned_replace_paths(ancien) − owned_replace_paths(nouveau)` : seuls les fichiers `Replace` possédés par l'ancien projecteur que le nouveau ne réécrira pas. Claude→Codex supprime `.claude/settings.local.json` ; Claude→Claude diff vide (rien) ; Codex→Claude rien côté Replace (Codex n'a pas de Replace, `owned_replace_paths()==[]`).\n- `MergeToml` (ex. `.codex/config.toml`) : JAMAIS supprimé (aucune action).\n- Best-effort : chaque suppression via `self.fs.remove_file(&path)` en `let _ = …` ; un échec/absence ne fait jamais échouer le swap.\n\nPORT FileSystem — DÉCISION (1 point structurant à signaler)\nLe port `FileSystem` n'exposait AUCUNE suppression. Plutôt que d'ajouter une méthode obligatoire (qui aurait cassé la compilation des ~18 implémentations, surtout les fakes de tests QA), j'ai ajouté `async fn remove_file(&self, path) -> Result<(), FsError>` AVEC une **implémentation par défaut no-op `Ok(())`** :\n- zéro ripple : tous les adapters/fakes existants compilent inchangés ;\n- l'adapter réel `LocalFileSystem` (infrastructure) **override** avec une vraie suppression `tokio::fs::remove_file`, idempotente (NotFound ⇒ Ok, cohérent best-effort) ;\n- conséquence pour QA : pour ASSERTER une suppression dans un test de swap, le fake doit override `remove_file` (sinon le défaut no-op renvoie Ok sans rien supprimer — ce qui reste un comportement valide best-effort). Documenté dans le doc-comment du port.\n\nNON-RÉGRESSION id de paire / handoff (P8d) — CONFIRMÉE\nAucune ligne de `invalidate_engine_link` ni de `relaunch_if_live` n'est modifiée : le `pair_id` préservé (conversation_id stable User↔agent) et sa propagation dans `LaunchAgentInput.conversation_id` (réinjection handoff P7 + routage resume P8c) sont intacts. La nouvelle étape ne touche QUE des fichiers de config moteur dans le run dir, jamais les layouts/manifeste/conversation. Le swap reste : mutate manifest → invalidate engine link (préserve pair id) → [nouveau] cleanup fichiers → relaunch (pair id) → publish.\n\nRÉSULTATS\n- `cargo build -p application` → Finished OK.\n- `cargo build -p domain -p infrastructure -p app-tauri` → OK (override remove_file + défaut compilent partout).\n- `cargo test -p application` → tous verts (59 passed sur la suite agent, etc.), aucune régression.\n\nQA : scénarios de swap à tester — Claude→Codex (supprime `.claude/settings.local.json`, garde `.codex/config.toml`), Claude→Claude (rien supprimé), Codex→Claude (rien côté Replace), profil précédent supprimé / registre absent (no-op), best-effort sur fichier déjà absent. NB : utiliser un fake FileSystem qui override `remove_file` pour observer les suppressions."} +{"id":"c3e6113c-ab77-4fe3-b94c-3eb685c5ab7a","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781525871537,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Lot LP3-5 (composition root) — instancier le registre de projecteurs et le câbler dans app-tauri. Dernier lot : c'est ici que la projection devient réellement active dans l'app.\n\nFichier : crates/app-tauri/src/ (là où LaunchAgent et ChangeAgentProfile sont construits/injectés — state.rs / lib.rs ; regarde comment les autres dépendances comme le handoff provider, le mcp, le permission store sont déjà instanciées et injectées).\n\nÀ FAIRE :\n1. Construire UN `PermissionProjectorRegistry` contenant les deux projecteurs concrets de l'infrastructure : `ClaudePermissionProjector` (clé Claude) et `CodexPermissionProjector` (clé Codex). Utilise l'API du registre (`new()` + `with(...)`/`insert(...)`).\n2. Wrapper dans un `Arc` et injecter le MÊME `Arc` dans LES DEUX :\n - `LaunchAgent::with_permission_projectors(arc.clone())`\n - `ChangeAgentProfile::with_permission_projectors(arc.clone())`\n (source unique de vérité, comme l'a prévu DevBackend en LP3-4).\n3. Vérifie que le permission store (`FsPermissionStore`) est bien injecté dans LaunchAgent (`with_permission_store` ou équivalent) pour que `resolve` ait une source — s'il ne l'était pas encore au lancement réel, câble-le. Sinon la projection serait toujours vide en prod.\n\nCONTRAINTES :\n- Respecte le style d'instanciation/injection déjà en place dans app-tauri (mêmes patterns que les autres adapters).\n- Ne change pas les contrats des lots précédents.\n\nVALIDATION FINALE (règle d'or §3, lot LP3-5 = workspace entier vert) :\n- `cargo build --workspace` OK.\n- `cargo test --workspace` : exécute-le et donne la SORTIE RÉELLE (résumé pass/fail par crate). Si quoi que ce soit échoue, rapport clair.\n- `cd frontend && npx vitest run` doit rester vert (tu n'y touches pas, mais confirme la non-régression globale).\n\nRéponds via idea_reply : où et comment tu as instancié+injecté le registre, la confirmation que le permission store est câblé au launch, et les sorties réelles de `cargo build --workspace`, `cargo test --workspace` et le vitest frontend."} +{"id":"f10747be-51de-4d0d-9ea8-42907461306b","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781526058746,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"Lot LP3-5 (composition root) terminé : la projection des permissions est désormais ACTIVE dans l'app. `cargo build --workspace` OK, `cargo test --workspace` = 1262 passed / 0 failed. Le vitest frontend a 4 échecs PRÉ-EXISTANTS (non causés par ce lot — détail plus bas).\n\nOÙ + COMMENT (crates/app-tauri/src/state.rs, dans le builder du composition root)\n1) Registre construit une seule fois, juste avant `LaunchAgent` :\n```rust\nlet permission_projectors = Arc::new(\n PermissionProjectorRegistry::new()\n .with(Arc::new(ClaudePermissionProjector) as Arc)\n .with(Arc::new(CodexPermissionProjector) as Arc),\n);\n```\n (projecteurs concrets importés de `infrastructure::{ClaudePermissionProjector, CodexPermissionProjector}` ; registre importé de `application::PermissionProjectorRegistry`. `with(...)` keye chaque projecteur par son `ProjectorKey` — Claude/Codex.)\n2) MÊME `Arc` injecté dans LES DEUX use cases (source unique de vérité, comme prévu en LP3-4) :\n - `LaunchAgent::new(...).…\n .with_permission_store(Arc::clone(&permission_store_port)) // déjà présent\n .with_handoff_provider(…).with_provider_session_provider(…)\n .with_permission_projectors(Arc::clone(&permission_projectors))` // AJOUTÉ\n - `ChangeAgentProfile::new(...).with_structured(…)\n .with_permission_projectors(Arc::clone(&permission_projectors))` // AJOUTÉ\n Style/patterns identiques aux autres injections (handoff provider, provider session, structured).\n\nPERMISSION STORE CÂBLÉ AU LAUNCH — CONFIRMÉ\n`FsPermissionStore` était DÉJÀ instancié (state.rs:511) et injecté dans le LaunchAgent de prod via `.with_permission_store(Arc::clone(&permission_store_port))` (state.rs:662). Donc `resolve_effective_permissions` a bien une source : la projection n'est PAS vide en prod (elle dépend du contenu de `.ideai/permissions.json` ; sans policy posée ⇒ `eff == None` ⇒ projection vide = prompting natif conservé, conforme à l'invariant produit). Rien à ajouter de ce côté.\n\nAucun contrat des lots précédents modifié. Le registre LaunchAgent interne de `ChangeAgentProfile` (relance composée) reçoit la projection via le `LaunchAgent` partagé ; le nettoyage au swap utilise le registre injecté directement.\n\nSORTIES RÉELLES\n- `cargo build --workspace` → Finished `dev` … OK (0 erreur, 0 warning).\n- `cargo test --workspace` → TOTAL **1262 passed, 0 failed**, 0 ignored. Tous les binaires de test (domain/application/infrastructure/app-tauri) verts, dont change_agent_profile, agent_lifecycle, permission_usecases, permission_store, mcp_server, orchestrator_*, dto_*. Aucun `FAILED`/panic.\n- `cd frontend && npx vitest run` → **Test Files 3 failed | 36 passed (39) ; Tests 4 failed | 349 passed (353)**.\n\nANALYSE DES 4 ÉCHECS VITEST (NON imputables à LP3-5)\nJe n'ai touché AUCUN fichier frontend (ce lot est 100 % Rust). Les 4 échecs sont PRÉ-EXISTANTS, hérités du chantier frontend « permissions » en cours dans l'arbre de travail (présents dès le début de ma session) :\n- fichiers frontend déjà modifiés/non-suivis AVANT mon intervention : `M frontend/src/adapters/index.ts|input.ts|mock/index.ts`, `M frontend/src/domain/index.ts`, `M frontend/src/ports/index.ts`, `M features/projects/ProjectsView.tsx`, `M features/terminals/useWritePortal.ts`, et NON-SUIVIS `?? frontend/src/adapters/permission.ts`, `?? frontend/src/features/permissions/`.\n- échecs : `mock.test.ts > exposes all thirteen gateways` (le mock expose maintenant une gateway permissions de plus ⇒ le compte attendu n'est pas à jour), `LayoutGrid.chat.test.tsx` (2, routage cellule terminal/chat), `agentAlreadyRunning.test.tsx` (1, notice R0d). Tous dans des composants/adapters que LP3 ne touche pas et liés au feature permissions UI côté DevFrontend, pas à la projection backend.\n\nConclusion : côté backend (mon périmètre), workspace 100 % vert et projection câblée bout-en-bout. Les 4 rouges vitest relèvent du lot frontend « permissions » en cours (à confier à DevFrontend) et ne sont pas une régression introduite par LP3-5. Si tu veux, je peux signaler précisément à DevFrontend le test `exposes all thirteen gateways` à mettre à jour (probable « fourteen » avec la gateway permissions)."} +{"id":"b8ff4040-332c-49bf-bec9-ed3c6592bae8","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781527477376,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Ping de diagnostic. Calcule 5 + 7 et réponds-moi uniquement avec le résultat via idea_reply."} +{"id":"c0a93530-67a4-4dcb-a091-59d61b12250a","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781527482471,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"12"} +{"id":"4845e5c8-501b-4a34-bdc3-c31071e31d47","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781527959689,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Ping de diagnostic #2. Calcule 8 + 8 et réponds-moi uniquement avec le résultat via idea_reply."} +{"id":"fc7a4925-2987-497d-9faa-964b8d45c166","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781527962139,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"16"} +{"id":"e6c411f9-e126-42ea-beaa-5c44f85efe0b","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781544373837,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong DevBackend OK » pour confirmer que le canal fonctionne."} +{"id":"0fc1857e-4680-430d-bfcf-59a25b4a04b6","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781544379518,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"pong DevBackend OK"} +{"id":"dea720eb-48d7-4ad8-a591-ef37521c3a57","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781545295114,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[Chantier permissions — état des lieux] J'ai audité le chantier permissions : domaine (LP0, modèle pur + resolve deny-wins/postures), store FsPermissionStore (LP1), use cases CRUD+resolve (LP2), projecteurs Claude/Codex + câblage launch-path/registry (LP3-3/4), composition root + commandes Tauri (LP3-5), et UI PermissionsPanel (projet + override agent). `cargo test --workspace` et les tests frontend permissions sont 100% verts. Tout est encore non commité.\n\nQuestion : d'après ta connaissance du chantier (ta conversation, ta mémoire, le code), reste-t-il un lot ouvert ou un gap concret à traiter sur les permissions (ex: LP4, edge cases non couverts, projection non testée end-to-end, parité Codex/Claude, sandbox OS réelle, validation/erreurs UI) ? Si oui, liste précisément le PROCHAIN lot à faire avec son périmètre. Si le chantier est complet, dis-le clairement. Réponds via idea_reply."} +{"id":"419fcf55-e508-4f87-8ace-80385eed3126","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781545364461,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"Bilan permissions — DevBackend\n\nLe chantier que tu décris (LP0 domaine → LP3-5 commandes Tauri + UI) correspond à la **voie « projection vers la config CLI »** et il est bien complet/vert. Mais ce n'est PAS tout le chantier permissions : il reste un lot ouvert, documenté noir sur blanc dans le code lui-même.\n\n## Le gap concret : enforcement OS réel (sandbox), out of scope à dessein\n\nLe domaine déclare explicitement que l'application OS-sandbox est hors périmètre du lot livré. Trois modules le marquent :\n- `domain/src/permission.rs:12` — « OS-sandbox application … out of scope here »\n- `domain/src/fileguard.rs:16` et `infrastructure/src/fileguard/mod.rs:17` — « airtight revocation of raw fs access is an OS-sandbox (Landlock) concern, out of scope »\n- `application/src/orchestrator/context_guard.rs:23` — idem\n\nAutrement dit, ce qui est livré **configure** Claude (`settings.local.json` allow/deny/ask) et Codex (sandbox/approval modes via `config.toml`) — c'est de la permission **advisory**, qui dépend du bon vouloir de la CLI. Un agent qui contourne sa CLI (ou un `bash` direct) garde un accès FS brut. La couche **airtight** (révocation réelle des accès) n'existe pas encore. C'est exactement ce que dit la mémoire archi : permissions = **sandbox OS (Landlock Linux) + résumé injecté**. La moitié « sandbox OS » manque.\n\n## PROCHAIN lot proposé — LP4 : enforcement OS (Landlock Linux d'abord)\n\nPérimètre :\n1. **Port domaine** `SandboxEnforcer` (trait pur) : prend `EffectivePermissions` résolues → un plan d'enforcement OS (rulesets path RO/RW/exec, deny-wins déjà garanti par le domaine). Aucune I/O dans le domaine.\n2. **Adapter infra Linux** : impl Landlock (crate `landlock`) appliquée **au process agent au spawn** (pré-exec), pas à IdeA. Adapters par OS, pas par CLI (conforme à `agent-permissions-architecture`). Windows/macOS = stubs no-op explicites pour ce lot.\n3. **Câblage launch-path** : appliquer le ruleset au moment du spawn PTY, en cohérence avec la projection déjà écrite (même `EffectivePermissions`).\n4. **Résumé injecté** : générer un résumé lisible des permissions effectives et l'injecter dans le contexte `.md` de l'agent (l'autre moitié du couple « sandbox + résumé » — à confirmer s'il existe déjà ; je n'en ai pas trouvé de générateur dédié).\n5. **Tests** : domaine/app 100 % sans I/O (fakes) ; un test d'intégration Linux réel (sous-process tente un accès hors scope → refusé).\n\n⚠️ Ce lot demande un **cadrage Architect** d'abord (nouveau port + frontière process/OS) : je ne devine pas le contrat.\n\n## Deux réserves mineures sur le « 100 % vert »\n- **Parité Claude/Codex** : OK au niveau projecteurs (testés purs) ; mais vérifier que la projection Codex couvre bien le mapping posture→approval *et* sandbox (pas juste l'un des deux).\n- **Validation e2e réelle (AppImage)** : tous les tests sont app-level avec fakes + store infra. Aucune validation binaire réelle (Claude/Codex lisant vraiment la config écrite). C'est le gap e2e habituel du projet, à faire avant de déclarer la voie projection « prod-ready ».\n\n## Note process\nTout est non commité (`crates/{domain,application,infrastructure}/…/permission*`, `frontend/src/features/permissions/`). Vu que c'est vert et autonome, un commit de jalon avant d'ouvrir LP4 serait sain — décision sortante qui te revient.\n\n**Conclusion : le chantier n'est PAS complet. Voie « projection CLI » = livrée/verte ; lot ouvert restant = LP4 enforcement OS (Landlock) + résumé injecté, à cadrer par Architect.**"} +{"id":"b6c5f35b-c8e9-4f39-965a-cd1dd05c04aa","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781548788067,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[LP4-0 — implémentation domaine pur, enforcement OS] L'Architecte a cadré LP4. Implémente UNIQUEMENT le sous-lot LP4-0 (domaine pur, zéro I/O, zéro dépendance landlock, aucun comportement runtime branché — donc zéro régression). Contrat :\n\n1. Nouveau module `crates/domain/src/sandbox.rs` :\n - `SandboxPlan { allowed: Vec, default_posture: Posture }`\n - `PathGrant { abs_root: String, access: PathAccess }` où `PathAccess` = bitflags Ro|Rw|Exec\n - `SandboxContext<'a> { project_root: &'a str, run_dir: &'a str }`\n - `SandboxKind { Landlock, Unsupported }`, `SandboxStatus { Enforced, Unsupported, Degraded(String) }`, `SandboxError` (kernel trop vieux en fail-closed, etc.)\n - trait `SandboxEnforcer: Send + Sync { fn kind(&self) -> SandboxKind; fn enforce(&self, plan: &SandboxPlan) -> Result; }` (contrat Liskov : enforce appelé uniquement post-fork/pré-exec dans l'enfant ; un adapter Unsupported renvoie Ok(Unsupported) sans rien faire)\n - fn pure `compile_sandbox_plan(eff: Option<&EffectivePermissions>, ctx: &SandboxContext) -> Option`. INVARIANT : `eff == None ⇒ None` (rien posé ⇒ pas de sandbox ⇒ natif). Traduction globs→racines absolues calculée FAIL-CLOSED : un Deny tombant dans un Allow sans frontière de répertoire qui les sépare ⇒ on N'ACCORDE PAS le parent (on perd un allow plutôt que de laisser fuiter un deny). Pose cette sur-/sous-approximation conservatrice comme invariant testable.\n2. `crates/domain/src/permission.rs` : fn pure `render_permission_summary(eff: Option<&EffectivePermissions>) -> Option` (bloc Markdown ; None⇒None ; doit mentionner explicitement que les règles de COMMANDE (ExecuteBash) restent advisory/non verrouillées OS, alors que les fichiers sont verrouillés quand supporté).\n3. `crates/domain/src/ports.rs` : ajoute `sandbox: Option` à `SpawnSpec` (None ⇒ pas d'enforcement). Mets à jour tous les constructeurs/sites existants pour rester compilable.\n4. Exporte les nouveaux types dans `crates/domain/src/lib.rs`.\n\nBorne de réalité à respecter : Landlock ne verrouille QUE les fichiers ; `ExecuteBash` (matching argv) n'est PAS enforceable ⇒ reste advisory. Ne touche PAS au modèle existant ni à resolve ni aux projecteurs LP3. N'implémente PAS d'adapter Landlock ni de câblage lifecycle (c'est LP4-1/LP4-2). Décision produit déjà tranchée pour plus tard : fallback fail-open+warning sauf posture Deny (à encoder côté adapter en LP4-1, pas ici).\n\nAssure que `cargo build -p domain` et `cargo test --workspace` restent verts (ajoute juste de quoi compiler ; QA écrira la suite de tests exhaustive). Quand c'est fait, réponds via idea_reply avec : fichiers créés/modifiés, signatures publiques exactes livrées, et confirmation que le workspace compile + tests existants au vert."} +{"id":"2df5d2c0-366d-4c35-9d5e-21e9047ffeda","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781549256981,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"LP4-0 livré — domaine pur, zéro I/O, zéro dépendance landlock, zéro câblage runtime. `cargo build -p domain` OK, `cargo test --workspace` 100% vert (0 failed), 0 nouveau warning clippy.\n\n## Fichiers\n**Créé**\n- `crates/domain/src/sandbox.rs` (nouveau module pur)\n\n**Modifiés**\n- `crates/domain/src/permission.rs` — ajout `render_permission_summary`\n- `crates/domain/src/ports.rs` — champ `sandbox` sur `SpawnSpec`\n- `crates/domain/src/lib.rs` — `pub mod sandbox;` + re-exports\n- 11 sites de construction de `SpawnSpec` mis à jour (`sandbox: None`) pour rester compilable :\n `infrastructure/src/runtime/mod.rs` (×2), `application/src/terminal/usecases.rs`,\n `app-tauri/src/state.rs` (×2), et les fakes de test\n `infrastructure/tests/{pty_adapter,orchestrator_watcher,mcp_server}.rs`,\n `application/tests/{agent_lifecycle,orchestrator_service,change_agent_profile,structured_launch_d3}.rs`.\n (`application/tests/profile_usecases.rs` = `unreachable!()`, rien à changer.)\n\n## Signatures publiques exactes\n\n`crates/domain/src/sandbox.rs` :\n```rust\npub struct PathAccess(u8);\nimpl PathAccess {\n pub const RO: Self; // 0b001 (read)\n pub const RW: Self; // 0b010 (write/delete)\n pub const EXEC: Self; // 0b100 (réservé, jamais émis par le compilateur)\n pub const fn empty() -> Self;\n pub const fn contains(self, other: Self) -> bool;\n pub const fn union(self, other: Self) -> Self;\n pub fn insert(&mut self, other: Self);\n pub const fn is_empty(self) -> bool;\n pub const fn bits(self) -> u8;\n}\n// + impl BitOr / BitOrAssign\n\npub struct PathGrant { pub abs_root: String, pub access: PathAccess }\npub struct SandboxPlan { pub allowed: Vec, pub default_posture: Posture }\npub struct SandboxContext<'a> { pub project_root: &'a str, pub run_dir: &'a str }\n\npub enum SandboxKind { Landlock, Unsupported }\npub enum SandboxStatus { Enforced, Unsupported, Degraded(String) }\npub enum SandboxError { KernelTooOld(String), Ruleset(String) } // thiserror\n\npub trait SandboxEnforcer: Send + Sync {\n fn kind(&self) -> SandboxKind;\n fn enforce(&self, plan: &SandboxPlan) -> Result;\n}\n\npub fn compile_sandbox_plan(\n eff: Option<&EffectivePermissions>,\n ctx: &SandboxContext,\n) -> Option;\n```\n\n`crates/domain/src/permission.rs` :\n```rust\npub fn render_permission_summary(eff: Option<&EffectivePermissions>) -> Option;\n```\n\n`crates/domain/src/ports.rs` :\n```rust\npub struct SpawnSpec {\n /* … champs existants … */\n pub sandbox: Option, // None ⇒ pas d'enforcement\n}\n```\n\nRe-exports `lib.rs` : `compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus` + `render_permission_summary`.\n\n## Invariants encodés (à exploiter par QA)\n1. **`eff == None ⇒ compile renvoie None`** (rien posé ⇒ natif). `Some` même avec `allowed` vide = il y a une politique.\n2. **Bash ignoré** : seules les 3 capabilities fichier (Read→RO, Write/Delete→RW) produisent des grants. `ExecuteBash` n'est jamais traduit (Landlock ne verrouille pas l'exec) — borne de réalité respectée.\n3. **Fail-closed (testable)** : chaque glob Allow → son *préfixe statique* (chemin littéral avant le 1er métacaractère `* ? [`, slash trimé). Le grant est **abandonné** si un Deny a un préfixe statique qui chevauche (égal, ou ancêtre/descendant de chemin) — un sandbox additif ne peut pas découper un deny sous-arbre. On perd l'allow plutôt que de laisser fuiter le deny. Cas notable : Allow `**` (racine projet) est abandonné dès qu'un seul Deny fichier existe.\n4. **`render_permission_summary`** : bloc Markdown ; mentionne explicitement que les fichiers sont **OS-enforced when supported (Landlock)** et que les commandes restent **advisory, NOT OS-locked**.\n\n## Non fait (volontairement, hors périmètre LP4-0)\n- Aucun adapter Landlock, aucun `SandboxEnforcer` concret (LP4-1).\n- Aucun câblage lifecycle/launch-path : `SpawnSpec.sandbox` est posé mais **jamais lu** ⇒ zéro changement de comportement runtime, zéro régression.\n- Fallback fail-open+warning (sauf posture Deny) : laissé à encoder côté adapter en LP4-1.\n- Modèle existant, `resolve`, projecteurs LP3 : **non touchés**.\n\n`compile_sandbox_plan` ne consomme pas `ctx.run_dir` (réservé à l'adapter : le run dir doit rester atteignable) — c'est documenté dans le code, pas un oubli.\n\nPrêt pour la suite de tests exhaustive de QA, puis LP4-1 (adapter Landlock) après cadrage Architect."} +{"id":"0d58c38b-4b03-4486-86a9-9ae618faabce","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781551898068,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[LP4-0 — affinage sémantique des barrières Deny, AVANT commit] Décision d'orchestration sur le point remonté par QA : les barrières Deny doivent être PAR CLASSE D'ACCÈS, pas capability-indépendantes. Raison : autonomie maximale des agents — un Deny Write ne doit pas amputer un Allow Read (sinon on sur-restreint et l'agent se bloque/redemande, contraire à l'objectif produit).\n\nRefactore `compile_sandbox_plan` dans `crates/domain/src/sandbox.rs` ainsi :\n\n1. Sépare les deux classes d'accès :\n - Classe RO : alimentée par les Allow `Read` ; barrières = les Deny `Read`.\n - Classe RW : alimentée par les Allow `Write` ET `Delete` ; barrières = les Deny `Write` ET `Delete`.\n (ExecuteBash : toujours ignoré, aucune classe.)\n2. Pour CHAQUE classe indépendamment : réduis chaque Allow à son préfixe statique, puis abandonne (fail-closed) le grant SI une barrière de LA MÊME CLASSE a un préfixe statique chevauchant (égal, ancêtre OU descendant). Un Deny d'une autre classe n'a AUCUN effet sur cette classe.\n3. Fusionne les racines survivantes en `PathGrant` par `abs_root`, en unionnant l'accès : une racine survivant en RO seul ⇒ `PathAccess::RO` ; survivant aussi en RW ⇒ `RO | RW` ; survivant en RW seul ⇒ `RW`.\n4. Invariant `eff == None ⇒ None` et « politique posée ⇒ Some même si allowed vide » : inchangés.\n\nExemple cible à garantir : Allow Read `src/**` + Deny Write `src/secret/**` (aucun Deny Read) ⇒ le grant `src` CONSERVE le RO (le write-deny ne touche pas la classe RO ; il n'y a pas de write-allow donc pas de RW). Avant ton refactor, ce cas perdait tout le grant — c'est précisément ce qu'on corrige.\n\nMets à jour la doc du module/fonction pour décrire la sémantique par classe (et que c'est le deny-wins par capability+cible, fail-closed à l'intérieur d'une classe). Ne touche à rien d'autre (pas de Landlock, pas de lifecycle). Assure `cargo build -p domain` OK et `cargo test --workspace` toujours vert (certains tests QA existants supposaient l'ancienne sémantique capability-indépendante : signale lesquels deviennent obsolètes pour que QA les réécrive — ne les supprime pas toi-même si ce sont des tests QA, mais liste-les). Réponds via idea_reply avec : diff conceptuel, tests existants impactés à réécrire, et état build."} +{"id":"00ebcc0b-a0d1-4681-97e5-4a786d97d55a","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781552089641,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"LP4-0 affiné — barrières Deny **par classe d'accès**. `cargo build -p domain` OK, `cargo test --workspace` vert (0 failed), 0 warning clippy sur sandbox.rs. Rien d'autre touché (pas de Landlock, pas de lifecycle).\n\n## Diff conceptuel\n**Avant** (capability-blind) : on collectait UN seul jeu de barrières = les préfixes statiques de **tous** les Deny fichier, et tout Allow fichier dont le préfixe chevauchait était abandonné — peu importe la capability. ⇒ un `Deny Write` amputait un `Allow Read` (sur-restriction).\n\n**Après** (par classe) : deux jeux de barrières indépendants.\n- Classe **RO** : alimentée par `Allow Read` ; barrières = `Deny Read` uniquement.\n- Classe **RW** : alimentée par `Allow Write` ET `Allow Delete` ; barrières = `Deny Write` ET `Deny Delete`.\n- `ExecuteBash` : toujours sans classe, jamais de grant ni de barrière.\n\nPour chaque classe **indépendamment** : Allow → préfixe statique, abandonné (fail-closed) ssi une barrière **de la même classe** chevauche (égal / ancêtre / descendant). Une barrière d'une autre classe n'a **aucun** effet. Puis fusion des racines survivantes par `abs_root` en unionnant l'accès : RO seul ⇒ `RO` ; RO+RW ⇒ `RO|RW` ; RW seul ⇒ `RW`.\n\nSémantiquement : c'est le deny-wins par `capability`+cible de `permission.rs`, transposé à la granularité répertoire de Landlock, fail-closed à l'intérieur de chaque classe.\n\n**Cas cible garanti** (vérifié) : `Allow Read src/**` + `Deny Write src/secret/**` (aucun Deny Read) ⇒ grant `src` **CONSERVE le RO** (pas de RW car pas de Write-allow). Avant : tout le grant était perdu.\n\nImplémentation : helper `capability_access` remplacé par `access_class(cap) -> Option` (None pour ExecuteBash) ; deux Vec de barrières `ro_fences`/`rw_fences` ; sélection de la classe au moment de l'Allow. Invariants `eff==None ⇒ None` et « politique posée ⇒ Some même si allowed vide » : inchangés. Doc module + doc fonction réécrites pour décrire la sémantique par classe.\n\n## Tests QA existants impactés (dans `crates/domain/src/sandbox.rs`, mod tests — écrits par QA)\n**3 tests obsolètes** (ils encodaient la sémantique capability-blind via un `Deny Write` qui faisait tomber un `Allow Read`). Je ne les ai **pas supprimés** ; je les ai marqués `#[ignore = \"OBSOLETE … ticket 2db80d41 … QA to rewrite\"]` pour garder le workspace vert tout en les laissant compiler et visibles. À réécrire par QA avec une barrière **de même classe** (`Deny Read`) :\n1. `root_glob_with_single_file_deny_drops_root_grant` — `Allow Read **` + `Deny Write secret.txt` ; attendait `allowed` vide. Désormais le RO root survit.\n2. `descendant_deny_drops_overlapping_allow` — `Allow Read src/**` + `Deny Write src/secret/**` ; attendait grant `src` absent. **C'est précisément le cas qu'on corrige** → le RO sur `src` est conservé.\n3. `ancestor_deny_also_drops_allow` — `Allow Read src/sub/**` + `Deny Write src/**` ; attendait grant `src/sub` absent. Désormais conservé.\n\n**2 tests encore verts mais sémantiquement affaiblis** (ils passent toujours, mais leur assertion « gardé » n'est plus due à la disjointness puisque la barrière est d'une autre classe — à renforcer côté QA avec une barrière même-classe pour rester probants) :\n- `disjoint_deny_keeps_allow`, `sibling_prefix_is_not_a_descendant`.\n\nLes 6 autres tests sandbox (mapping accès, bash-only, union sur racine partagée, préfixe statique d'un fichier littéral, posture résiduelle, normalisation trailing slash) restent valides et verts.\n\n## État build\n- `cargo build -p domain` : OK.\n- `cargo test --workspace` : **0 failed**, 0 binaire en échec. Module sandbox : 11 passed / 0 failed / 3 ignored.\n- clippy domain : 0 nouveau warning (les 2 préexistants sont dans `fileguard.rs`).\n\nPrêt pour le commit une fois que QA aura réécrit les 3 tests ignorés sous la nouvelle sémantique (et idéalement renforcé les 2 affaiblis)."} +{"id":"ad790fa0-7727-48f7-9c21-0d083113bd25","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781552597304,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[LP4-1 — adapter Landlock + hook pre_exec, infra Linux] On enchaîne le chantier permissions/enforcement OS. LP4-0 (domaine pur) est commité (b05d04a) : port `SandboxEnforcer`, `compile_sandbox_plan`, `SpawnSpec.sandbox: Option` (posé mais jamais lu pour l'instant). Implémente LP4-1 selon le cadrage Architecte.\n\nPérimètre LP4-1 (adapters OS + mécanisme pre_exec ; PAS le câblage application step 5d ni la composition root — ce sont LP4-2/LP4-3) :\n\n1. Nouveau module `crates/infrastructure/src/sandbox/{mod.rs, landlock.rs, noop.rs}` :\n - `LandlockSandbox` (`#[cfg(target_os=\"linux\")]`) impl `SandboxEnforcer` : traduit `SandboxPlan` → ruleset Landlock via la crate `landlock` (ajoute-la au Cargo.toml d'infrastructure, en best-effort/compat ABI). `enforce(&self, plan)` crée le ruleset, ajoute chaque `PathGrant` comme `path_beneath` avec les access rights correspondant à `PathAccess` (RO ⇒ lecture ; RW ⇒ lecture+écriture+création+suppression ; EXEC ⇒ exec — mais le domaine n'émet jamais EXEC pour l'instant), puis restreint le THREAD courant (appel destiné à l'enfant post-fork). Renvoie `SandboxStatus::Enforced` ; `Degraded(reason)` si compat ABI réduit la couverture ; et applique la politique fallback : kernel/ABI sans Landlock ⇒ `SandboxStatus::Unsupported` SAUF si `plan.default_posture == Posture::Deny` où il faut renvoyer `Err(SandboxError::KernelTooOld(...))` (fail-closed seulement en posture Deny ; fail-open+warning sinon).\n - `NoopSandbox` (autres OS / fallback) impl `SandboxEnforcer` : `kind()==Unsupported`, `enforce` renvoie toujours `Ok(SandboxStatus::Unsupported)`, jamais d'erreur. Doit compiler sur toutes plateformes.\n - `mod.rs` : sélection cfg-dépendante d'un constructeur `default_enforcer() -> Arc` (Landlock sur Linux, Noop ailleurs). Exporte dans `infrastructure/src/lib.rs`.\n\n2. Hook `pre_exec` dans l'adapter PTY (`portable-pty`) : le `PortablePtyAdapter` reçoit un `Option>` (nouveau champ + builder additif, défaut `None` ⇒ aucun sandboxing, signature `new` inchangée). Dans `spawn`, si `spec.sandbox.is_some()` ET un enforcer est présent, installe via `CommandBuilder`/`pre_exec` (unsafe, Unix only) un hook qui, dans l'enfant post-fork pré-exec, appelle `enforcer.enforce(plan)` ; sur `Err` ⇒ faire échouer le spawn (l'enfant n'exec pas) ; sur `Ok(Unsupported/Degraded)` ⇒ continuer (log/warning best-effort). ⚠️ Respecte les contraintes async-signal-safety du pre_exec (pas d'alloc/lock hasardeux ; clone le plan en amont). Si `portable-pty` ne permet pas un pre_exec arbitraire, signale-le clairement et propose l'alternative la plus propre plutôt que de bricoler.\n\n3. Aucun changement de comportement runtime tant que LP4-2/LP4-3 ne câblent pas : l'enforcer reste `None` partout, `spec.sandbox` reste `None`. ⇒ zéro régression.\n\nTests : ajoute au moins un test d'intégration Linux réel pour `LandlockSandbox` (un sous-process lancé sous un plan qui interdit l'écriture d'un tmp path ⇒ l'écriture échoue EACCES, et un path autorisé réussit), gated `#[cfg(target_os=\"linux\")]` + garde runtime de disponibilité Landlock (skip si indispo, façon tests SSH/WSL). Test `NoopSandbox` ⇒ Unsupported jamais Err. (QA fera la passe d'intégration exhaustive ensuite.)\n\nAssure `cargo build --workspace` et `cargo test --workspace` verts. Réponds via idea_reply avec : fichiers créés/modifiés, version de la crate `landlock` ajoutée, faisabilité réelle du pre_exec avec portable-pty (oui/non + comment), résultat des tests (et si le test Landlock réel a pu tourner sur ce kernel ou a été skippé), et confirmation zéro régression."} +{"id":"48d80ea1-cdce-4464-9414-3851757d1caf","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781553328391,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"LP4-1 livré — adapters OS Landlock/Noop + mécanisme d'enforcement. `cargo build --workspace` OK, `cargo test --workspace` vert (0 failed, 0 ignored), 0 nouveau warning clippy. Zéro régression (enforcer None + spec.sandbox None partout ⇒ chemin dormant).\n\n## Fichiers\n**Créés**\n- `crates/infrastructure/src/sandbox/mod.rs` — sélection cfg + `default_enforcer() -> Arc` + tests Noop/default\n- `crates/infrastructure/src/sandbox/landlock.rs` — `LandlockSandbox` (`#[cfg(target_os=\"linux\")]`) + 2 tests d'intégration réels\n- `crates/infrastructure/src/sandbox/noop.rs` — `NoopSandbox` (toutes plateformes)\n\n**Modifiés**\n- `crates/infrastructure/Cargo.toml` — dép Linux-only `landlock = \"0.4.5\"` sous `[target.'cfg(target_os = \"linux\")'.dependencies]`\n- `crates/infrastructure/src/lib.rs` — `pub mod sandbox;` + exports `default_enforcer`, `NoopSandbox`, `LandlockSandbox` (cfg linux)\n- `crates/infrastructure/src/pty/mod.rs` — champ `sandbox_enforcer: Option>` + builder additif `with_sandbox_enforcer` (`new()` inchangé) + chemin de spawn sandboxé\n\n## Version crate\n`landlock = \"0.4.5\"` (dernière ; ABI jusqu'à V7, on cible V1 + `CompatLevel::BestEffort`). Pull une seule dép transitveive légère (`enumflags2`). Linux-only ⇒ Windows/macOS ne compilent rien de Landlock (chemin Noop).\n\n## Faisabilité pre_exec avec portable-pty : NON\nportable-pty 0.9 **n'expose aucun hook pre_exec utilisateur** : `CommandBuilder` n'a pas de méthode pre_exec, et `SlavePty::spawn_command` installe son **propre** pre_exec interne (setsid / controlling-tty, `unix.rs:238`) sans point d'extension, en consommant son propre `CommandBuilder` (pas un `std::process::Command` accessible). Impossible d'injecter `enforce()` dans le pre_exec de l'enfant directement.\n\n**Alternative choisie (propre, pas de bricolage, pas de fork de la crate, pas de binaire helper)** : on exploite une propriété kernel garantie — **un domaine Landlock est hérité au `fork` et préservé à l'`execve`**. Donc `spawn_command_sandboxed` :\n1. lance un **thread jetable dédié**, y appelle `enforcer.enforce(plan)` (restreint CE thread, irréversible mais le thread meurt ensuite — les autres threads d'IdeA restent intacts) ;\n2. appelle `slave.spawn_command(cmd)` **depuis ce thread** : `std::process::Command` fork depuis le thread appelant, et comme portable-pty pose toujours un pre_exec, std est forcé sur le chemin `fork`+`exec` (jamais `posix_spawn`) ⇒ l'enfant hérite du domaine restreint ;\n3. sur `Err(enforce)` (fail-closed) ⇒ le spawn échoue, **aucun enfant ne tourne** ; sur `Ok(Unsupported/Degraded)` ⇒ on continue (best-effort).\nTout est **déplacé** (move) dans le thread ⇒ aucune borne `Sync` requise du slave ; le `Child` (Send) revient à l'appelant. (Le câblage de l'enforcer dans la composition root et la pose de `spec.sandbox` restent LP4-2/LP4-3 — ici le chemin est prêt mais dormant.)\n\n## Sémantique LandlockSandbox (faithful + per-class, miroir de LP4-0)\n- On *handle* une classe de droits **seulement si** le plan pose un grant de cette classe : RO présent ⇒ handle `from_read` ; RW présent ⇒ handle `from_write`. ⇒ un plan **write-only laisse les lectures globalement libres** (libc/`/etc` lisibles) et ne clôture que les écritures — exactement l'autonomie per-class. Corollaire documenté : un plan qui restreint les lectures gouverne *toutes* les lectures ⇒ le plan compilé devra inclure les chemins système (concern LP4-2, pas l'adapter).\n- RO⇒`from_read`, RW⇒`from_read|from_write` sur la racine ; intersection avec le handled set.\n- `restrict_self` ⇒ FullyEnforced→`Enforced` ; PartiallyEnforced→`Degraded(reason)` ; NotEnforced→fallback : `Unsupported` SAUF posture `Deny` ⇒ `Err(KernelTooOld)` (fail-closed). Plan vide/bash-only ⇒ `Enforced` (rien à poser).\n\n## Tests\n- `landlock_write_only_plan_fences_writes_to_the_grant` (`#[cfg(target_os=\"linux\")]`, garde runtime : skip si `status==Unsupported`) : **A TOURNÉ ET PASSÉ sur ce kernel** (pas skippé). Écriture dans la racine RW accordée ⇒ OK ; écriture hors racine ⇒ **EACCES (PermissionDenied)**. Enforcement réel validé.\n- `empty_plan_is_a_noop_enforced` : plan vide ⇒ `Enforced`, pas d'erreur (sur thread jetable).\n- `noop_enforcer_is_unsupported_and_never_errors` : `kind()==Unsupported`, `enforce` (même posture Deny) ⇒ `Ok(Unsupported)`, jamais Err.\n- `default_enforcer_matches_the_build_target` : Linux ⇒ Landlock.\n- **Workspace** : `cargo build --workspace` OK ; `cargo test --workspace` **0 failed, 0 ignored** (les 3 tests LP4-0 jadis ignorés ont été réécrits par QA dans b05d04a et sont verts).\n\n## Zéro régression\n`PortablePtyAdapter::new()` : signature inchangée, `sandbox_enforcer = None`. Chemin sandboxé pris uniquement si `spec.sandbox.is_some() && enforcer.is_some()` — jamais le cas tant que LP4-2/LP4-3 ne câblent pas. Tous les tests existants verts.\n\nReste pour LP4-2/LP4-3 : compilation du plan au launch (incl. chemins système read si RO), pose de `spec.sandbox`, injection de `default_enforcer()` dans `PortablePtyAdapter` à la composition root, et résumé permissions injecté."} +{"id":"5d68ed83-0c11-4ed4-9a9f-562a008af244","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781553574676,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[LP4-1 — durcissement : rendre testable la branche fail-closed posture Deny] QA a validé LP4-1 (vert, propriété de sûreté n°1 OK). Une seule branche reste non couverte : `(RulesetStatus::NotEnforced, Posture::Deny) ⇒ Err(SandboxError::KernelTooOld)`, intestable sur un kernel Landlock-capable car `restrict_self` ne renvoie jamais NotEnforced ici. C'est la branche sécurité-critique (fail-closed) — il faut la couvrir.\n\nPetit refactor sans changement de comportement : dans `crates/infrastructure/src/sandbox/landlock.rs`, extrais le mapping final en une fonction PURE, p.ex. `fn status_from_ruleset(status: RulesetStatus, posture: Posture) -> Result` (ou un enum miroir local si tu préfères ne pas dépendre du type de la crate dans la signature — choisis le plus propre/testable sans rien mocker). `enforce()` appelle cette fn après `restrict_self`. Mapping inchangé :\n- FullyEnforced ⇒ Ok(Enforced)\n- PartiallyEnforced ⇒ Ok(Degraded(reason))\n- NotEnforced + posture==Deny ⇒ Err(KernelTooOld)\n- NotEnforced + posture!=Deny ⇒ Ok(Unsupported)\n\nAjoute un test en table couvrant les 4 (×3 postures pertinentes) — notamment les deux cas NotEnforced qui prouvent le fail-closed seulement-en-Deny. Garde tout le reste identique. `cargo test -p infrastructure` + `cargo test --workspace` verts. Réponds via idea_reply avec le diff conceptuel, le nom/signature de la fn extraite, et la sortie de test."} +{"id":"565b6fd5-4186-4be1-b93c-c8458b6ab6c2","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781589677808,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Reprise du chantier permissions — lot LP4-3 (câblage bout-en-bout de l'enforcement OS). LP4-0/LP4-1/LP4-2 sont faits, committés (dernier commit 17ca65e) et verts. Il reste à ACTIVER la sandbox au runtime ; aujourd'hui tout est construit mais inactif (rien ne peuple le plan, rien n'injecte l'enforcer).\n\nPérimètre précis :\n\n1) Composition root — injecter l'enforcer dans le PTY.\n - `crates/app-tauri/src/state.rs:396` et `crates/infrastructure/src/remote/mod.rs:41` construisent `PortablePtyAdapter::new()`.\n - Remplacer par `PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer())` (le builder additif existe déjà, le default_enforcer() choisit Landlock sur Linux / Noop ailleurs).\n\n2) Launch path — peupler `SpawnSpec.sandbox`.\n - `crates/infrastructure/src/runtime/mod.rs:74` et `:217` posent `sandbox: None` en dur.\n - Au lancement d'un agent, résoudre sa policy : charger `ProjectPermissions` (store `.ideai/permissions.json` — adaptateur déjà en place côté infra/store), `resolve_for(agent_id)` → `EffectivePermissions`, puis `domain::sandbox::compile_sandbox_plan(...)` pour obtenir le `SandboxPlan`, et le passer dans `SpawnSpec.sandbox` (Some). Si la policy résout à None (rien posé), garder `sandbox: None` (invariant produit : on ne projette rien, comportement natif conservé).\n - Vérifie le branchement des dépendances (le runtime doit pouvoir lire le store de permissions ; suis le même chemin d'injection que les autres adaptateurs du runtime). Si une dépendance manque dans la construction du runtime, fais remonter clairement ce qui bloque plutôt que de bricoler.\n\n3) Respecte strictement l'archi hexagonale : la résolution policy→plan est PURE (domaine), le runtime ne fait qu'orchestrer lecture store + appel pur + remplissage SpawnSpec.\n\nÀ la fin : `cargo build` + `cargo test -p domain -p infrastructure` (et le crate app-tauri si touché) doivent être verts. NE committe PAS toi-même (action figée par l'orchestrateur). Rends via idea_reply un rapport contenant : fichiers modifiés, comment le plan est résolu/injecté, blocages éventuels, et la sortie réelle des tests. Si quelque chose ne passe pas, dis-le avec la sortie brute — on enchaînera avec QA pour le test bout-en-bout (agent lancé avec Deny sur un chemin ne peut pas y écrire)."} +{"id":"c3762d68-4771-4406-bd57-3b995af6d00c","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781589889539,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"LP4-3 — câblage bout-en-bout de l'enforcement OS : FAIT, build + tests verts. NON committé (action figée par l'orchestrateur).\n\n## Fichiers modifiés (3)\n\n1. **crates/app-tauri/src/state.rs** (~396) — composition root :\n `PortablePtyAdapter::new()` → `PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer())`.\n\n2. **crates/infrastructure/src/remote/mod.rs** (~41) — `LocalHost::new()` :\n même injection via `crate::sandbox::default_enforcer()` (chemin remote/local transparent).\n\n3. **crates/application/src/agent/lifecycle.rs** — launch path (`LaunchAgent::execute`) :\n - ajout import `use domain::sandbox::{compile_sandbox_plan, SandboxContext};`\n - nouvelle étape **5d** insérée APRÈS la résolution des permissions (étape 3, `resolve_effective_permissions` → `Option`) et la projection advisory LP3 (étape 5c), AVANT le split structuré/PTY :\n ```rust\n spec.sandbox = compile_sandbox_plan(\n effective_permissions.as_ref(),\n &SandboxContext {\n project_root: input.project.root.as_str(),\n run_dir: run_dir.as_str(),\n },\n );\n ```\n\n## Comment le plan est résolu / injecté\n\n- **Résolution policy → plan (PURE, domaine).** Le store `.ideai/permissions.json` est déjà lu en étape 3 par `resolve_effective_permissions` (qui fait `store.load_permissions(project)` puis `doc.resolve_for(agent_id)` → `Option`). La dépendance `Option>` était DÉJÀ branchée dans `LaunchAgent` (via `with_permission_store`, utilisée par LP3) — rien à recâbler côté construction du runtime. Je réutilise donc cette `EffectivePermissions` au lieu de re-lire le store.\n- L'étape 5d appelle simplement la fonction pure `domain::sandbox::compile_sandbox_plan(eff, ctx)` et stocke le résultat dans `spec.sandbox`. Zéro I/O dans cette étape : le launch path ne fait qu'orchestrer (lecture store déjà faite → appel pur → remplissage SpawnSpec). Hexagonal respecté.\n- **Invariant produit conservé** : `eff == None` (rien posé) ⇒ `compile_sandbox_plan` renvoie `None` ⇒ `spec.sandbox = None` ⇒ comportement natif, aucune projection OS. Le `sandbox: None` codé en dur dans `runtime/mod.rs` (`detection_spec` L80, `prepare_invocation` L224) reste le défaut neutre de la fonction *pure* `prepare_invocation` (qui n'a pas accès aux permissions, par design) ; il est désormais écrasé en aval par l'étape 5d quand une policy existe. La détection (`detection_spec`) reste volontairement non sandboxée (simple probe `--version`).\n- **Injection de l'enforcer** : `default_enforcer()` (Landlock sur Linux / Noop ailleurs) est injecté dans le `PortablePtyAdapter` au composition root. Au spawn, `PortablePtyAdapter::spawn` n'applique le plan que si `(spec.sandbox = Some, enforcer = Some)` — sinon spawn nominal. Le branchement bout-en-bout est donc complet pour le chemin PTY.\n\n## Point d'attention (non bloquant, à noter pour QA)\n\nL'étape 5d remplit `spec.sandbox` AVANT le split structuré/PTY, donc le plan est porté dans les deux cas. Mais l'enforcement effectif vit dans `PortablePtyAdapter` (chemin PTY brut). Le chemin **structuré** (`launch_structured` → `factory.start`, pas de `pty.spawn`) ne consomme pas encore `spec.sandbox` : un agent structuré (Claude/Codex en mode JSON) n'est PAS OS-sandboxé pour l'instant — il garde sa projection advisory LP3. Le test e2e « Deny sur un chemin ⇒ écriture impossible » doit donc viser un agent en **mode terminal/PTY** (profil sans structured_adapter) pour valider Landlock. Sandboxer le chemin structuré serait un lot ultérieur (l'AgentSession infra devrait enforcer dans son propre fork/exec).\n\n## Sortie réelle des tests\n\n`cargo build` : Finished, OK.\n\n`cargo test -p domain -p infrastructure -p application -p app-tauri` : tous les binaires de test verts, **0 failed** partout. Suites notables :\n- domain : `141 passed; 0 failed` (lib, dont les 18 tests `compile_sandbox_plan` de sandbox.rs)\n- infrastructure : `154 passed; 0 failed` (lib, dont sandbox/noop + permission store) + suites d'intégration (`permission_store`, etc.) toutes à 0 failed\n- application : `72 passed; 0 failed` (lib) + `agent_lifecycle`, `change_agent_profile`, `permission_usecases` toutes vertes\n- app-tauri : `47 passed; 0 failed`\n\nAucun warning nouveau introduit. Prêt pour QA (test bout-en-bout Landlock sur agent PTY avec Deny)."} +{"id":"bc91e6c6-f49e-45a0-9287-b7c15a6e0e4f","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781590912473,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Lot LP4-4 — implémenter l'enforcement Landlock sur le chemin STRUCTURÉ (sessions Claude/Codex mode JSON), aujourd'hui non sandboxé. L'Architecte a validé l'approche (GO). Implémente exactement ce découpage ; ne réinvente pas la stratégie.\n\nSTRATÉGIE VALIDÉE (approche b) : transposer la technique du PTY (`spawn_command_sandboxed` dans `crates/infrastructure/src/pty/mod.rs`). Le `pre_exec(enforce)` est INTERDIT (landlock alloue → deadlock malloc post-fork en process multithreadé). À la place : `enforce(plan)` sur un THREAD JETABLE AVANT le fork, puis spawn `std::process` synchrone depuis ce thread (l'enfant hérite le domaine Landlock via fork+exec), réconcilié à l'async par `spawn_blocking`. Le chemin non-sandboxé (sandbox==None OU pas d'enforcer, et tout non-Linux) reste le `drain` async tokio ACTUEL strictement inchangé (zéro régression).\n\nCONTRAT À MODIFIER :\n1. `crates/domain/src/ports.rs` (~537) — `AgentSessionFactory::start` : ajouter param `sandbox: Option<&SandboxPlan>` (SandboxPlan est domaine, franchit déjà le port via SpawnSpec.sandbox — cohérent).\n2. `crates/infrastructure/src/session/process.rs` — ajouter `pub sandbox: Option` à `SpawnLine` ; `run_turn` reçoit `enforcer: Option<&Arc>` ; nouveau `drain_sandboxed` : thread jetable → `enforcer.enforce(&plan)` (fail-closed : Err ⇒ échec du tour, AUCUN child ne tourne) → `std::process::Command::spawn` → poser un `unsafe { cmd.pre_exec(|| Ok(())) }` VIDE (async-signal-safe) pour forcer le chemin fork+exec déterministe → drain bloquant stdin/stdout→EOF→wait → Vec. Le thread meurt avec sa restriction. TIMEOUT sous sandbox : le thread renvoie son killer (Arc> ou pid) via un oneshot juste après spawn ; `tokio::time::timeout` sur le JoinHandle ; à expiration kill le child → EOF → le thread finit → renvoyer Timeout (pas de zombie/thread bloqué).\n3. `crates/infrastructure/src/session/factory.rs` — `StructuredSessionFactory` gagne `Option>` + builder `with_sandbox_enforcer(...)` (jumeau exact de PortablePtyAdapter::with_sandbox_enforcer) ; `start` apparie plan (par-appel) + enforcer (par-instance) et les injecte dans `ClaudeSdkSession::new`/`CodexExecSession::new`.\n4. `crates/infrastructure/src/session/claude.rs` (build_spawn_line ~187, send ~195) & `codex.rs` (~162/195) — stocker plan+enforcer, remplir `SpawnLine.sandbox`, passer l'enforcer à `run_turn`.\n5. `crates/application/src/agent/lifecycle.rs` — `launch_structured` (~1620) reçoit le plan (`spec.sandbox`, déjà calculé en step 5d) et le relaie à `factory.start`.\n6. `crates/app-tauri/src/state.rs` (~408) — `StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer())`.\n7. FAKES à mettre à jour (nouvelle signature start) : `crates/domain/tests/structured_session_d0.rs`, `crates/application/tests/structured_launch_d3.rs`, `crates/application/tests/orchestrator_service.rs`.\n\nINVARIANT PRODUIT : eff==None ⇒ plan None ⇒ SpawnLine.sandbox None ⇒ drain async tokio inchangé, comportement natif. Respecte l'hexagonal (la résolution policy→plan reste pure dans le domaine ; l'infra ne fait qu'orchestrer + enforcer).\n\nÀ la fin : `cargo build` + `cargo test -p domain -p infrastructure -p application -p app-tauri` verts. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : fichiers modifiés, comment le timeout sous sandbox est géré, et la sortie RÉELLE des tests. Si quelque chose bloque ou ne passe pas, rapport clair avec sortie brute — je renverrai à l'Architecte ou on bouclera. Les tests d'enforcement e2e du chemin structuré seront écrits ensuite par QA (les 7 invariants), tu n'as pas à les écrire, mais assure-toi que les suites existantes restent vertes."} +{"id":"7de50c76-7f1b-439b-8d0d-0f03ec9199ec","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781591777173,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"LP4-4 — enforcement Landlock sur le chemin STRUCTURÉ : FAIT, build + tests verts (80 suites, 0 failed). NON committé (figé par l'orchestrateur).\n\n⚠️ UNE DIVERGENCE ASSUMÉE vs le cadrage (le `pre_exec` vide), détaillée plus bas — à valider par l'Architecte. Tout le reste est conforme au découpage.\n\n## Fichiers modifiés (12)\n\nProduction (6) :\n1. `crates/domain/src/ports.rs` — `AgentSessionFactory::start` gagne `sandbox: Option<&crate::sandbox::SandboxPlan>` (dernier param).\n2. `crates/infrastructure/src/session/process.rs` — `SpawnLine.sandbox: Option` ; `run_turn(spec, timeout, enforcer: Option<&Arc>)` ; nouveau `run_turn_sandboxed` (thread jetable + 2 oneshot killer/résultat + `tokio::time::timeout`) et `drain_sandboxed` (drain bloquant std), tous deux `#[cfg(target_os = \"linux\")]`.\n3. `crates/infrastructure/src/session/factory.rs` — `StructuredSessionFactory` porte `Option>` + builder `with_sandbox_enforcer(...)` (jumeau de `PortablePtyAdapter`) ; `start` apparie plan (param, par-appel) + enforcer (champ, par-instance) → `ClaudeSdkSession::new`/`CodexExecSession::new`. (N'est plus `Copy`/`Debug` car porte un `Arc` ; `new()` n'est plus `const`.)\n4. `crates/infrastructure/src/session/claude.rs` — stocke `sandbox`+`sandbox_enforcer`, remplit `SpawnLine.sandbox`, passe l'enforcer à `run_turn`.\n5. `crates/infrastructure/src/session/codex.rs` — idem Claude.\n6. `crates/application/src/agent/lifecycle.rs` — `launch_structured` reçoit `sandbox: Option<&SandboxPlan>` et le relaie à `factory.start` ; le call site passe `spec.sandbox.as_ref()` (déjà calculé en step 5d de LP4-3).\n7. `crates/app-tauri/src/state.rs` — `StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer())`.\n\nFakes/tests adaptés à la nouvelle signature (5) : `domain/tests/structured_session_d0.rs`, `application/tests/structured_launch_d3.rs`, `application/tests/orchestrator_service.rs` (les 3 fakes `start` + leurs call sites), plus `infrastructure/src/session/{mod.rs,conformance.rs}` (SpawnLine literals + tous les `run_turn`/`ClaudeSdkSession::new`/`CodexExecSession::new` internes).\n\n## Gestion du timeout sous sandbox\n\nLe thread jetable n'est pas annulable de l'extérieur. Deux canaux `tokio::sync::oneshot` le réconcilient avec l'async :\n- **killer** : juste après le spawn, le thread envoie un `Arc>` ; il a au préalable **sorti `stdout`** du child (`.take()`) pour drainer SANS tenir le lock — donc le killer peut verrouiller à tout instant.\n- **résultat** : le thread envoie `Result, _>` à la fin.\n\n`tokio::time::timeout(dur, done_rx)` :\n- fini dans les temps ⇒ on rend le résultat ;\n- **expiration** ⇒ on `killer_rx.await` puis `child.lock().kill()` → l'enfant meurt → `stdout` EOF → la boucle de drain du thread sort → `child.wait()` (reap, **pas de zombie**) → le thread se termine. On rend `AgentSessionError::Timeout`. Le `JoinHandle` est détaché (pas de thread bloqué). `done_tx` lâché sans valeur (panique) ⇒ `Io`.\n\n`enforce` est **fail-closed** : `Err` ⇒ `AgentSessionError::Start` AVANT tout spawn (aucun enfant ne tourne).\n\n## ⚠️ Divergence : pas de `pre_exec` vide (à arbitrer Architecte)\n\nLe cadrage demandait `unsafe { cmd.pre_exec(|| Ok(())) }` pour forcer std sur le chemin déterministe `fork`+`exec`. **Impossible ici** : la crate `infrastructure` est `#![forbid(unsafe_code)]` (lib.rs:12) — un `forbid` ne peut PAS être relâché localement par `#[allow]`. Le PTY n'avait jamais eu besoin d'`unsafe` : il obtient `fork`+`exec` gratuitement via le `pre_exec` **interne de portable-pty**. Sur le chemin structuré on pilote `std::process` en direct, donc le `pre_exec` serait à nous → `unsafe` → interdit.\n\nJ'ai donc **retiré le `pre_exec`** sans perte de garantie de sécurité : `landlock_restrict_self` restreint le **thread courant et toute sa descendance**, héritage assuré par le noyau à travers `fork`/`clone`/`vfork` ET préservé par `execve` — **y compris via `posix_spawn`** (= `clone`+`execve` sous le capot), car l'enforcement vit au niveau des *credentials* de la tâche, hors d'atteinte de l'espace utilisateur. Le `fork`+`exec` n'était une garantie que de **déterminisme**, pas de sécurité ; l'enforce()-avant-spawn-sur-le-même-thread suffit. La technique validée (thread jetable, enforce avant spawn, fail-closed, reap) est intacte.\n\nDeux options pour l'Architecte s'il veut le `fork`+`exec` strict du cadrage : (a) rester comme livré (zéro unsafe, héritage noyau — mon choix par défaut) ; (b) passer lib.rs en `#![deny(unsafe_code)]` + `#[allow(unsafe_code)]` scoppé sur `drain_sandboxed` pour rétablir le `pre_exec`. J'attends son verdict avant de toucher l'invariant crate-wide.\n\n## Invariant produit conservé\n\n`eff==None` ⇒ plan `None` ⇒ `SpawnLine.sandbox==None` ⇒ `run_turn` emprunte le **drain async tokio inchangé** (et **tout** non-Linux y reste, via `#[cfg(target_os=\"linux\")]` sur le routage). Zéro régression. Hexagonal respecté : résolution policy→plan pure (domaine, step 5d), l'infra ne fait qu'orchestrer + enforcer ; le plan franchit le port en valeur domaine.\n\n## Sortie réelle des tests\n\n`cargo build` : Finished, OK.\n\n`cargo test -p domain -p infrastructure -p application -p app-tauri` : **80 suites, toutes `ok`, 0 failed, 0 panic**. Notables :\n- domain (lib) : 141 passed ; `structured_session_d0` (fake start nouvelle signature) vert.\n- infrastructure (lib) : 156 passed (incluait 154 avant LP4-4 ; les nouveaux chemins compilent et tous les tests session/process restent verts) ; suites sandbox/permission toutes 0 failed.\n- application (lib) : 72 passed ; `structured_launch_d3`, `orchestrator_service` verts.\n- app-tauri : 47 passed.\n\nUn seul warning, **pré-existant et hors périmètre** : `method writes is never used` (orchestrator_service.rs:1641, helper de fixture mort avant LP4-4).\n\nPrêt pour QA (7 invariants e2e d'enforcement du chemin structuré : agent structuré lancé avec Deny ⇒ écriture impossible, etc.)."} diff --git a/.ideai/conversations/dd35eeb0-2329-0dfa-3255-df7dba90a0aa/handoff.md b/.ideai/conversations/dd35eeb0-2329-0dfa-3255-df7dba90a0aa/handoff.md new file mode 100644 index 0000000..e2796b5 --- /dev/null +++ b/.ideai/conversations/dd35eeb0-2329-0dfa-3255-df7dba90a0aa/handoff.md @@ -0,0 +1,7 @@ +--- +upTo: 419c60a5-2aca-4711-ad83-0a185bb3214e +objective: Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois. +--- +**Objectif :** Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois. + +- **Prompt:** Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois. \ No newline at end of file diff --git a/.ideai/conversations/dd35eeb0-2329-0dfa-3255-df7dba90a0aa/log.jsonl b/.ideai/conversations/dd35eeb0-2329-0dfa-3255-df7dba90a0aa/log.jsonl new file mode 100644 index 0000000..805bead --- /dev/null +++ b/.ideai/conversations/dd35eeb0-2329-0dfa-3255-df7dba90a0aa/log.jsonl @@ -0,0 +1 @@ +{"id":"419c60a5-2aca-4711-ad83-0a185bb3214e","conversation":"dd35eeb0-2329-0dfa-3255-df7dba90a0aa","atMs":1781427081894,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"prompt","text":"Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois."} diff --git a/.ideai/layouts.json b/.ideai/layouts.json index 549d22d..dc90ac1 100644 --- a/.ideai/layouts.json +++ b/.ideai/layouts.json @@ -1,24 +1,24 @@ { "version": 1, - "activeId": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5", + "activeId": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34", "layouts": [ { - "id": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5", + "id": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34", "name": "Default", "kind": "terminal", "tree": { "root": { "type": "split", "node": { - "id": "1eb96c70-e954-42d0-907b-f8528d0442bf", + "id": "2ea41e76-262d-4dc3-a674-348ceac9b543", "direction": "row", "children": [ { "node": { "type": "leaf", "node": { - "id": "db40e3de-4980-4bed-b6fa-1c136f49d30e", - "session": "d7d4c099-eda3-4b33-82e6-e6f8d57b8b97", + "id": "14d20dcb-f243-4381-a130-2d4d3d224f42", + "session": "4e60d6c2-6be6-499c-a296-577ce1e5ec62", "agent": "a6ced819-b893-4213-b003-9e9dc79b9641", "agentWasRunning": true } @@ -29,10 +29,9 @@ "node": { "type": "leaf", "node": { - "id": "92826533-1a7c-4d9e-b6b4-f1e904ddc81a", - "session": "5d20f53a-ac76-4677-9a6f-064d3319cc73", - "agent": "edce8090-4c57-47c5-a319-c08fd172438b", - "agentWasRunning": true + "id": "71f5cc81-9bef-4b2b-b7dc-2e0a5a4eafcb", + "session": "bb08a08e-71f3-4ab2-842c-71a8da5c74b4", + "agent": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5" } }, "weight": 1.0 diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 73331b8..5cefda9 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -3,3 +3,5 @@ - [agent-context-memory-and-profile-handoff](agent-context-memory-and-profile-handoff.md) — Decisions sur l'injection de contexte, la memoire durable, l'etat live et le handoff de profil entre agents IA. - [idea-product-directives-main-handoff](idea-product-directives-main-handoff.md) — Directives produit consolidees pour guider Main sur la robustesse, la persistance, le handoff cross-profile et la sobriete UX. - [remaining-work-idea-agent-control-ide](remaining-work-idea-agent-control-ide.md) — Etat des lieux des acquis et des chantiers restants pour aligner IdeA avec la cible d'IDE de controle d'agents IA. +- [mcp-bridge-and-delegation-runtime-notes](mcp-bridge-and-delegation-runtime-notes.md) — Pieges runtime du pont MCP/delegation et regle de rebuild de l'AppImage (binaire qui tourne = AppImage, pas les sources). +- [permissions-sandbox-system-state](permissions-sandbox-system-state.md) — Systeme de permissions/sandbox complet (Landlock sur PTY + structure) et le risque residuel $HOME/resume du chemin structure. diff --git a/.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md b/.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md new file mode 100644 index 0000000..c0dc322 --- /dev/null +++ b/.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md @@ -0,0 +1,51 @@ +--- +name: mcp-bridge-and-delegation-runtime-notes +description: Pieges runtime du pont MCP et de la delegation inter-agents IdeA, et la regle de rebuild de l'AppImage. +metadata: + type: project +--- +# Pont MCP & delegation : pieges runtime + +Deux bugs trouves le 2026-06-13 en testant la conversation inter-agents (`idea_ask_agent` vers TestConversation), tous deux corriges. Notes utiles pour ne pas reperdre du temps : + +## Le binaire qui tourne = AppImage installee, pas les sources +L'IdeA en cours d'utilisation est `/home/anthony/Documents/IdeA_0.1.0_amd64.AppImage`. **Ce meme binaire sert a la fois de serveur orchestrateur (il tient `OrchestratorService`/`InputMediator` et compose les evenements) ET de binaire-pont** (` mcp-server …` declare dans chaque `.ideai/run//.mcp.json`). Donc **tout correctif cote serveur ou cote pont n'est actif dans l'app que apres rebuild + reinstall de l'AppImage et relance d'IdeA**. Un binaire `target/debug` fraichement compile ne valide que le pont (qui parle au serveur via le socket) ; il ne valide pas la composition cote serveur. +**Comment appliquer :** apres une correction backend, rebuild AppImage (`npm --prefix frontend run build` puis, depuis `crates/app-tauri/`, `../../frontend/node_modules/.bin/tauri build --bundles appimage`), remplacer l'AppImage, relancer IdeA, puis retester. **Exclure NSIS** (`--bundles appimage`) sur Linux. **Piege FUSE (2026-06-14)** : l'etape finale `linuxdeploy` echoue avec `failed to run linuxdeploy` (linuxdeploy est une AppImage qui se monte via FUSE). Workaround obligatoire : prefixer `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1`. Le compile Rust reussit avant ce point ; seul le bundling casse, et l'`AppDir` est genere mais pas le `.AppImage`. L'artefact final = `target/release/bundle/appimage/IdeA_0.1.0_amd64.AppImage`. + +## Env AppImage pollue le shell +La session shell herite des variables de l'AppImage montee (`APPDIR`, `LD_LIBRARY_PATH`, `PYTHONHOME` -> `/tmp/.mount_IdeA_*`). Consequences : `python3` casse (`No module named 'encodings'`) et lancer un binaire app-tauri fraichement compile tente de booter WebKit et crash. **Workaround :** lancer avec un env propre (`env -i PATH=/usr/bin:/bin HOME=$HOME XDG_RUNTIME_DIR=/run/user/1000 ...`), utiliser `jq` plutot que python. + +## Bug 1 — pont MCP en lockstep (corrige) +`mcp_bridge.rs::relay` lisait 1 ligne client -> attendait 1 reponse loopback, en boucle. MCP n'est pas 1:1 : `notifications/initialized` n'a pas de reponse => deadlock juste apres `initialize`, et `tools/list` n'etait jamais relaye => les outils `idea_*` ne se chargeaient jamais (`claude mcp list` affichait quand meme "Connected" car `initialize` repond). Corrige en relay **full-duplex** (deux pompes concurrentes) + drain borne (`DRAIN_GRACE`) a la fermeture stdin. Symptome cote utilisateur : 3 jours de "outils MCP pas charges". + +## Bug 2 — prefixe de delegation perdu (corrige) +Le signal `[IdeA · tâche de · ticket ]` qui dit a l'agent cible "reponds via `idea_reply`, jamais en texte" n'etait plus compose nulle part : supprime du backend (`service.rs` C3 §5.1) lors du passage de l'ecriture PTY au frontend, mais le frontend (`useWritePortal.ts`) ecrit `head.text` verbatim et ne l'ajoutait pas. La cible recevait la tache brute, repondait en texte => `idea_ask_agent` timeout. Corrige en composant le prefixe dans `infrastructure/src/input/mod.rs` (`delegation_preamble`) a l'emission de `DomainEvent::DelegationReady` ; la tache brute reste dans le `Ticket`/historique. Rappel : la correlation `idea_reply` marche par ticket OU par tete de FIFO (fallback), donc le ticket echo est recommande mais pas strictement requis. + +## Bug 3 — cold-launch race : 1er tour perdu (corrige 2026-06-14) +Deleguer a un agent **froid** (pas encore lance) via `idea_ask_agent` echouait silencieusement : terminal cible vide, ask bloque jusqu'au timeout 300s ; un agent **chaud** (deja a son prompt, lance manuellement) marchait. Cause : avec `with_structured` non cable sur l'orchestrateur (regression assumee `aa2f67a`), tous les `ask` passent par le chemin PTY `ensure_live_pty` (`application/src/orchestrator/service.rs`). Un agent jamais vu etait initialise `Idle` (`BusyTracker::start_turn` -> `or_insert(Idle)`, `infrastructure/src/input/mod.rs`), donc le 1er `enqueue` publiait `DelegationReady` **immediatement** et ecrivait la tache dans le PTY avant que le CLI ait affiche son prompt -> tache perdue. Le prompt-ready watcher (lot C5) ne gardait que les tours suivants. Fix : `ensure_live_pty` renvoie un flag `cold_launch` ; si cold ET `prompt_ready_pattern` non vide, l'orchestrateur appelle `mark_starting(agent)` -> la `DelegationReady` du 1er tour est **differee** puis publiee par le watcher a l'apparition du prompt. Sans pattern ou agent chaud -> livraison immediate (zero regression). Touche `domain/src/input.rs` (port `mark_starting`), `infrastructure/src/input/mod.rs`, `service.rs`. + +## Bug 4 — le fix cold-launch ne s'armait jamais en prod (corrige 2026-06-15) +Le « fix Bug 3 corrige 2026-06-14 » etait trop optimiste : il ne s'arme que si `gate_cold_start = cold_launch && prompt_ready_pattern non vide` (`service.rs`). Or le profil **Claude Code** (`664cc20c`, partage par TOUS les agents) dans `~/.local/share/app.idea.ide/profiles.json` n'a **aucun** `prompt_ready_pattern`. Donc `mark_starting` n'etait jamais appele -> `enqueue` publiait `DelegationReady` immediatement -> tache ecrite dans le PTY avant le prompt de `claude` -> 1er tour perdu -> `idea_ask_agent` vers un agent **froid** bloque jusqu'au timeout (un agent **chaud** marche). Le Bug 3 n'etait valide que par des tests unitaires qui injectent un pattern a la main : le gap d'integration (profil sans pattern) n'avait pas ete vu. +**Fix (option B, signal MCP, sans sniff de prompt TUI) :** on gate le cold-launch des qu'un MCP est configure sur le profil (`gate_cold_start = cold_launch && (pattern non vide || profil.mcp.is_some())`), et on **libere** le tour differe quand le pont MCP de l'agent froid se connecte (= son CLI est up + outils charges) : nouveau port `InputMediator::release_cold_start(agent)` (drain du `deferred`, **sans** `mark_idle` car c'est un signal de DEMARRAGE, idempotent et OR-safe avec `prompt_ready`), `McpServer` fire un `ready_sink: Fn(&str)` sur `initialize` avec le `requester` du handshake, et la composition root (`state.rs::ensure_mcp_server`) parse le requester en `AgentId` -> `OrchestratorService::release_agent_cold_start`. Touche `domain/src/input.rs`, `infrastructure/src/input/mod.rs`, `infrastructure/src/orchestrator/mcp/server.rs`, `application/src/orchestrator/service.rs`, `app-tauri/src/state.rs`. Tests unitaires verts ; **validation live = rebuild AppImage + relance IdeA** (cf. section binaire qui tourne). Filet OR : si un jour un profil porte un `prompt_ready_pattern`, les deux signaux coexistent. +**Methodo :** ne PAS deleguer la reparation du systeme inter-agents via le systeme inter-agents (casse) — utiliser les subagents natifs (outil Agent). + +## Bug 5 — la boucle `serve` du serveur MCP est en lockstep, un `ask` sans reponse wedge TOUTE la connexion (corrige 2026-06-15) +Symptome : 1er `idea_ask_agent` vers un agent qui repond -> OK ; puis `ask` vers un agent qui ne rappelle JAMAIS `idea_reply` (ex. QA lance mais qui ne repond pas) -> ensuite **tout** appel suivant du MEME demandeur (meme `idea_list_agents`, sans rendezvous) se bloque. Cote utilisateur : "DevBackend ne marche plus" alors qu'il repondait 11s avant — en realite c'est la connexion du DEMANDEUR (Main) qui est morte, pas la cible. Annuler l'appel cote client ne deparke rien. +Cause : `infrastructure/src/orchestrator/mcp/server.rs::serve` lisait 1 requete puis **awaitait `handle_raw` en entier** avant de relire. Pour `idea_ask_agent`, `handle_raw -> dispatch -> service.dispatch` attend le `idea_reply` de la cible : pendant cette attente la boucle ne relit plus rien -> pipeline de la connexion fige. C'est l'analogue COTE SERVEUR du Bug 1 (lockstep) qui n'avait ete corrige que cote pont (`mcp_bridge.rs`). NB : la couche application bornait deja le rendezvous (`service.rs::ask_agent` via `tokio::time::timeout`), donc l'attente n'etait pas infinie — le vrai coupable etait bien la serialisation de `serve`, pas l'absence de timeout. +Fix : `serve` reecrite full-duplex non bloquante — `tokio::select!` entre `transport.recv()` et un canal `mpsc::unbounded::>>` ; chaque message entrant traite dans une tache `tokio::spawn` qui possede un clone cheap `self.for_requester(self.requester.clone())` ('static) + un clone du `tx` ; reponses (`Some`) ou notifications (`None`) renvoyees par le canal et ecrites par la meme boucle ; arret gracieux via compteur `in_flight` (on draine les taches en vol apres EOF). Les reponses MCP portent l'`id` -> ordre indifferent. Le trait `Transport` (`&mut self` recv/send) et les signatures `serve`/`serve_as` restent intacts. + filet de securite : timeout serveur **isole au seul `idea_ask_agent`** (`ASK_RENDEZVOUS_TIMEOUT` 24h, finie ; setter `with_ask_rendezvous_timeout` `#[doc(hidden)]` pour les tests). Tests : `infrastructure/tests/mcp_server.rs` -> `pending_ask_does_not_wedge_the_connection_concurrent_call_still_answered` (anti-wedge, echoue sur l'ancien code) + `ask_agent_rendezvous_times_out_with_a_jsonrpc_error`. Verts : `cargo test -p infrastructure` (20 mcp_server), `cargo test -p app-tauri --lib mcp_e2e_loopback_tests` (6). **Validation live = rebuild AppImage + relance IdeA** (la connexion wedgee ne se deparke pas de l'interieur : il FAUT relancer IdeA). AppImage du 2026-06-15 10:58 contient le fix ; backup `~/Documents/IdeA_0.1.0_amd64.AppImage.old-prewedgefix`. +Reste a investiguer (separe, non bloquant) : pourquoi QA lance ne repond pas du tout a une delegation (son `claude` n'appelle pas `idea_reply`) — impossible a creuser tant que la connexion du demandeur est wedgee ; le fix Bug 5 permet desormais de le diagnostiquer sans tout figer. + +## Bug 6 — la tache n'est JAMAIS ecrite dans le PTY d'un agent delegue en arriere-plan (corrige 2026-06-15) +C'EST la cause racine du « reste a investiguer » du Bug 5. Symptome : un agent lance a la main (cellule visible, ex. DevBackend) repond ; un agent **froid auto-lance par `idea_ask_agent`** (ex. QA) ne repond JAMAIS → `ask` bloque jusqu'au timeout (24h, `ASK_AGENT_TIMEOUT`). Reproduction sure et non bloquante : `idea_stop_agent` puis `idea_launch_agent(task=…, visibility=background)`, et observer le dossier de session `claude` de la cible (`~/.claude/projects//*.jsonl`) : **aucune nouvelle session** en 28 s = la tache n'a jamais ete soumise (le pont MCP de la cible, lui, se connecte bien). +Cause : depuis ARCHITECTURE §20, l'**ecriture physique du PTV est faite par le write-portal FRONTEND** (`useWritePortal`), qui n'est monte **que pour une cellule de layout (leaf) visible**. Or `ensure_live_pty` cold-lance la cible en **background avec `node_id: None`** (`service.rs`) → aucune cellule montee → `useWritePortal` n'existe pas → l'event `DelegationReady` n'a **aucun consommateur** → tache perdue. Les fix Bug 3/4 (differer/liberer la `DelegationReady`) ne pouvaient donc jamais marcher pour un agent background : ils publient un event que personne n'ecoute cote UI. +Fix (option « writer PTY backend ») : le mediateur ecrit lui-meme la tache dans le PTY **quand aucune cellule frontend n'est attachee**. Registre `front_owned` dans `MediatedInbox`, alimente par le front via `bindHandle`/`unbindHandle` → commande Tauri `set_front_attached` → `OrchestratorService::set_agent_front_attached` → `InputMediator::set_front_attached`. Point de livraison unique = `BusyTracker::publish_deferred` (chemin chaud immediat ET drains froids `prompt_ready`/`release_cold_start`), qui passe par un **HeadlessSink** optionnel cable par `MediatedInbox::with_pty`/`with_events` : agent dans `front_owned` ⇒ `Some(d)` (publie l'event, le front ecrit, inchange) ; sinon ⇒ ecrit texte puis (apres `submit_delay_ms`, defaut 60ms, anti-paste-detection) la `submit_sequence` dans le handle PTY bound, sur un `std::thread` detache (le watcher prompt-ready tourne sur un std::thread, PAS tokio — ne pas utiliser `tokio::spawn`). Touche `domain/src/input.rs` (port `set_front_attached`), `infrastructure/src/input/mod.rs` (HeadlessSink + front_owned + handles en `Arc`), `application/src/orchestrator/service.rs`, `app-tauri/src/{dto,commands,lib}.rs`, `frontend/src/{ports,adapters/input,adapters/mock,features/terminals/useWritePortal}`. Tests verts : `cargo test -p infrastructure` (input 34, dont `headless_agent_without_front_cell_is_written_by_the_backend` + `front_attached_agent_is_delivered_via_event_not_backend_write`), app-tauri lib 42, useWritePortal 9, tsc. **Validation live = rebuild AppImage + relance IdeA** (build 2026-06-15 11:42 ; backup `~/Documents/IdeA_0.1.0_amd64.AppImage.old-prefrontwriterfix`). +NB diagnostic : `~/.claude/projects//*.jsonl` = transcript de session `claude` de l'agent ; pas de nouveau fichier apres une delegation = tache jamais soumise. `ss -xp | grep idea-mcp` = ponts MCP connectes cote serveur. + +## Bug 7 — une delegation interrompue/annulee laisse la cible `Busy` a vie (corrige 2026-06-15, sources ; pas encore en AppImage) +Symptome : un agent qui repondait (ex. DevFrontend a « 123x4=492 », QA pendant LP3) ne repond plus du tout aux delegations suivantes ; `idea_ask_agent` bloque jusqu'au timeout. DevBackend, lui, continue de marcher. Diagnostic ecarte 2 fausses pistes : (a) PAS le socket MCP — les 6 ponts sont ESTAB (`ss -xp | grep idea-mcp`), pont vivant ; (b) PAS « lance a la main vs par IdeA » — DevBackend est aussi auto-lance et marche. Le vrai discriminant : **une delegation vers cet agent a-t-elle ete interrompue/annulee cote demandeur ?** DevFrontend coince par l'interruption d'une tache LP3 ; QA coince par un ping diag rejete ; DevBackend jamais annule => OK. Confirmation cote transcript : la tache figure dans le `log.jsonl` de la conversation (donc enqueue cote serveur a eu lieu) mais PAS dans le transcript `claude` de la cible (`~/.claude/projects//*.jsonl`) => jamais ecrite dans son PTY. +Cause (lue dans `application/src/orchestrator/service.rs`, chemins `ask` PTY ~l.847-911 ET `ask_structured` ~l.927-1011) : l'agent passe `Idle→Busy` des `input.enqueue` (~l.978). Il ne redevient `Idle` que sur la branche **succes** (`input.mark_idle`, ~l.1001). Les branches **erreur/annulation/timeout** (~l.901-910 et ~l.1004-1009) appellent `mailbox.cancel_head` mais **jamais `mark_idle`**. Pire : quand le demandeur interrompt l'appel, le futur `ask_agent` est **dropped** => AUCUNE branche du `select!` ne s'execute => l'agent reste `Busy` pour toujours. Une cible `Busy` met les delegations suivantes en file derriere un tour fantome, jamais livrees au PTY. L'etat `Busy` vit en memoire dans le process serveur et **ne se deparke pas de l'interieur** : deblocage immediat = **relancer IdeA** (comme le wedge du Bug 5). +Fix (corrige cote sources, `service.rs`) : garde RAII `BusyTurnGuard` (Arc clones de `InputMediator` + mailbox, `agent_id`, `ticket_id`, flag `armed`) cree juste apres l'enqueue dans les DEUX chemins (`ask` PTY et `ask_structured`) ; son `Drop` (si arme) appelle `cancel_head(agent,ticket)` puis `mark_idle(agent)` ; `disarm()` sur la branche succes (le `mark_idle` propre existant reste, pas de double cancel). Les `cancel_head` redondants des branches erreur/`_cancelled`/`_elapsed` ont ete retires au profit du garde (`cancel_head` est positionnel/idempotent). Indispensable que ce soit un garde et pas un `mark_idle` dans les branches : le cas reel est un futur DROPPED, aucune branche du `select!` ne s'execute. Tests verts : `cargo test --workspace` 80 suites 0 echec, dont `dropped_ask_future_frees_busy_target`, `second_delegation_delivered_after_dropped_ask`, `cancelled_ask_marks_target_idle` (tests/orchestrator_service.rs) + 2 tests du garde dans le `mod tests` de service.rs. +VERDICT `sweep_stalled` (infra/input/mod.rs) : **purement advisory** — bascule `Alive→Stalled` et emet `AgentLivenessChanged` mais **n'appelle JAMAIS `mark_idle`** (par conception : « la FIFO et le tour continuent »). Ce n'est donc PAS un filet pour ce bug ; le garde RAII est la seule correction. Non recable (changement de semantique hors perimetre). +**Pas encore actif live : rebuild AppImage requis** (`npm --prefix frontend run build` puis depuis `crates/app-tauri/` `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage`, remplacer l'AppImage, relancer IdeA). Le relancement d'IdeA debloque aussi DevFrontend/QA actuellement coinces `Busy` (etat en memoire). Methodo (rappel Bug 4) : NE PAS reparer le systeme inter-agent via `idea_ask_agent` (casse) — utiliser les subagents natifs (outil Agent). + +See also [[remaining-work-idea-agent-control-ide]], [[agent-context-memory-and-profile-handoff]]. diff --git a/.ideai/memory/permissions-sandbox-system-state.md b/.ideai/memory/permissions-sandbox-system-state.md new file mode 100644 index 0000000..9d01832 --- /dev/null +++ b/.ideai/memory/permissions-sandbox-system-state.md @@ -0,0 +1,26 @@ +--- +name: permissions-sandbox-system-state +description: Etat du systeme de permissions/sandbox IdeA (domaine pur -> projection advisory -> enforcement OS Landlock sur PTY ET structure) et l'unique risque residuel. +metadata: + type: project +--- +# Systeme de permissions / sandbox IdeA + +Chantier "permissions des agents" complet bout-en-bout au 2026-06-16, sur la branche `wip/p8c-checkpoint-before-codex`. Lots committes : LP4-0 (`b05d04a`), LP4-1/LP4-2 (`17ca65e`), LP4-3 (`7e01ac6`), LP4-4 (`6236cd7`). + +## Acquis (tout vert, ~80 suites) + +- **Domaine pur** (`crates/domain/src/permission.rs`, `sandbox.rs`) : modele declaratif (`Capability`, `Effect`, `Posture` Allow `None` => CLI garde son prompting natif), `compile_sandbox_plan(eff, ctx) -> Option`, port `SandboxEnforcer`, `render_permission_summary` (honnetete : fichiers = OS-enforced, commandes = advisory). +- **Store** : `.ideai/permissions.json` (separe d'`agents.json` : securite projet, pas identite). +- **Projection advisory LP3** : projecteurs Claude (`settings.json`) + Codex (sandbox/`config.toml`). +- **Enforcement OS LP4** : `LandlockSandbox` (Linux) / `NoopSandbox` / `default_enforcer()`. Actif sur **les deux chemins** : PTY brut (`pty/mod.rs` `spawn_command_sandboxed`) ET structure Claude/Codex JSON (`session/process.rs` `run_turn_sandboxed`). + +## Technique d'enforcement (load-bearing, ne pas casser) + +`enforce(plan)` tourne sur un **thread jetable AVANT le spawn**, puis le child est spawne depuis ce thread => il herite le domaine Landlock via les credentials de la tache (garanti par le noyau a travers fork/clone/execve, y compris posix_spawn). **Pas de `pre_exec`** : `infrastructure` est `#![forbid(unsafe_code)]` (intact), et le pre_exec n'aurait ete qu'une assurance de determinisme, sans valeur de securite ; allouer dans `landlock` post-fork (pre_exec) serait au contraire dangereux (deadlock malloc en process multithreade). Fail-closed : `Err` d'enforce => aucun child. Le garde-fou anti-regression est le test e2e **"ecriture hors-grant bloquee"** (present pour PTY et structure) : tant qu'il est vert, l'heritage tient. + +## Risque residuel a traiter (signale par l'Architecte, NON couvert) + +Les CLI structurees (claude/codex = binaires Node) ont des besoins FS ambiants lourds : `~/.claude`/`~/.codex` (credentials + cache de session pour le **resume**), `node_modules`, libs systeme, caches temp. `compile_sandbox_plan` ne fence que les classes explicitement posees et garde les lectures ouvertes — mais une policy posant un `Deny`/posture restrictive touchant `$HOME` **peut casser le resume**. Le mecanisme est valide au fake CLI (zero token), mais **avant d'activer un plan restrictif en prod sur le chemin structure**, valider e2e manuellement qu'un vrai tour claude/codex sous plan representatif garde run_dir + home de la CLI atteignables. C'est un sujet de **composition du plan** (run_dir reachability ; `SandboxContext.run_dir` existe mais n'est pas encore consomme par la traduction pure), pas du mecanisme — lot suivant si besoin. + +Voir [[remaining-work-idea-agent-control-ide]] pour le reste des chantiers produit. diff --git a/.ideai/permissions.json b/.ideai/permissions.json new file mode 100644 index 0000000..d631d6a --- /dev/null +++ b/.ideai/permissions.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "projectDefaults": { + "rules": [ + { + "capability": "read", + "effect": "allow", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "write", + "effect": "allow", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "delete", + "effect": "allow", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "executeBash", + "effect": "allow", + "paths": [], + "commands": [] + } + ], + "fallback": "allow" + } +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 90f2c2f..5ffdf76 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2027,4 +2027,137 @@ Avant correction, `launch_structured` persistait l'**id moteur (2)** sur `LeafCe --- +## 20. Terminal natif + portail d'écriture unique (cadrage — remplace la barre `MediatedInput`) + +> **Cadrage architecture** d'une feature **déjà décidée** (cf. décision produit « terminal natif »). Produit la frontière, les contrats (ports/events/DTO/profil), le découpage en lots testables et les risques. **Aucun code applicatif ici.** + +### 20.1 Problème & décision produit (rappel, ne pas rediscuter) +- **Bug.** En mode agent, l'humain tape dans une barre IdeA séparée (`MediatedInput.tsx`) ; la livraison d'une délégation écrit dans le PTY un texte terminé par `\n` (`MediatedInbox::enqueue`, `infrastructure/src/input/mod.rs:307-311`). Résultat : le texte se dépose dans le prompt de la TUI mais **n'est jamais soumis** (`\n` ≠ Entrée en raw-mode ; la détection de paste absorbe le `\n`). De plus barre IdeA + prompt natif = « double chat ». +- **Décision.** La cellule agent héberge la CLI comme **vrai terminal** : **toutes** les frappes (Entrée comprise) vont à la CLI ; on garde le chrome natif. On **supprime** `MediatedInput`. **Pas d'interception d'Entrée** (ambiguë en TUI). IdeA n'**observe** qu'un compteur « ligne humaine en cours » (+1 sur imprimable, reset sur Entrée/Ctrl-C). **Sérialisation par un portail d'écriture unique** vers le PTY : deux écrivains (frappes humaines natives + médiateur IdeA pour les délégations). Une délégation n'est livrée qu'à une **frontière propre = prompt-ready ET ligne humaine vide** ; sinon elle **patiente** (jamais de refus). Invariant inchangé : **1 agent = 1 employé = 1 session CLI** (la « conversation par paire » reste un cloisonnement **logique**, pas des sessions séparées). + +### 20.2 Décision d'architecture — où vit le portail, qui écrit + +**Le portail d'écriture unique vit côté FRONTEND** (le détenteur du terminal). Le backend **décide quand** une délégation est prête et **publie l'intention** ; le frontend, seul détenteur de xterm + des frappes + du compteur de ligne + de l'overlay, **exécute le handshake** (b→e) et **écrit le texte + `\r`** via la **même** `TerminalHandle.write` que les frappes humaines. Ainsi il n'existe **qu'un seul écrivain effectif du PTY** (le front) : pas de course entre deux écrivains physiques, le portail est un mutex **logique** dans la cellule. Le backend conserve son rôle d'**autorité de file/busy** (mailbox FIFO, prompt-ready watcher, `AgentBusyChanged`) mais **n'écrit plus le tour dans le PTY** — c'est le point dur tranché. + +Justification hexagonale : la frontière reste nette. L'**application** (`OrchestratorService`) reste l'autorité métier de l'orchestration (file, corrélation par ticket, cycle, timeout) ; elle parle **ports** (`InputMediator`, `EventBus`) sans connaître le terminal. La **livraison physique** (octets vers le PTY) est un **détail d'I/O** qui appartient à l'adapter sortant — ici l'adapter **frontend** (la cellule), exactement comme les frappes humaines y vivent déjà. On **retire** au `MediatedInbox` la responsabilité d'écrire le PTY (violation SRP : il était à la fois moteur de file ET écrivain d'I/O brut) ; il redevient pur moteur de file/busy/corrélation. Le « double signal OR » prompt-ready/`idea_reply` et le `wait_for`/timeout restent **inchangés**. + +``` + ask_agent / idea_ask_agent (MCP) idea_reply (MCP) + │ │ + ▼ ▼ + ┌──────────────────────────── OrchestratorService (application) ─────────────┐ + │ enqueue(ticket) → mailbox FIFO (corrélation) resolve_ticket → réveille ask │ + │ busy: Idle→Busy → AgentBusyChanged mark_idle (OR signal) │ + │ PLUS: publie DelegationReady{agent, ticket, text} ◄── NOUVEAU (ne PTY-écrit │ + └───────────────────────────────┬─────────────────────────────── plus le tour)┘ + │ EventBus → relais Tauri (event) + ▼ + ┌──────────────────────── Cellule agent (frontend, détient le terminal) ───────┐ + │ TerminalView (agent natif): term.onData → handle.write [frappes humaines] │ + │ writePortal (mutex logique de la cellule): │ + │ • frappes humaines: write direct + maj compteur ligne (imprimable / reset) │ + │ • DelegationReady reçue → si prompt-ready & ligne vide → HANDSHAKE b→e: │ + │ (b) couper le relais frappes + overlay grisé « un agent parle » │ + │ (c) revérif compteur K ; si K>0 → \x7f ×K (backspaces) │ + │ (d) write(texte) puis write(submitSequence) après submitDelayMs │ + │ (e) réactiver + retirer overlay, PLANCHER 2 s depuis (b) │ + │ • sinon (busy / ligne non vide) → la délégation PATIENTE (file native CLI │ + │ empile ; la prochaine frontière propre la libère) │ + │ ack: input.deliveredDelegation(ticket) ── confirme la livraison au backend │ + └──────────────────────────────────────────────────────────────────────────────┘ +``` + +**Pourquoi pas le backend ?** Le backend ne connaît ni l'état « ligne humaine en cours » (détenu par xterm côté front) ni l'overlay. Lui faire écrire le PTY impose un round-trip fragile (front→back « ligne vide ? », back→front « j'écris ») et **réintroduit deux écrivains physiques** du même PTY (le back via `PtyPort.write` + le front via `handle.write`) → exactement la classe de course que `terminal-input-accents-ordering` a déjà coûtée. Centraliser l'écriture côté front supprime la race par construction. + +### 20.3 Contrats + +**Profil (`crates/domain/src/profile.rs`) — fix Bug 1, déclaratif & model-agnostic.** Deux champs additifs sur `AgentProfile`, à côté de `prompt_ready_pattern`, sérialisés camelCase, `skip_serializing_if` ⇒ zéro régression : +```rust +/// Séquence de soumission écrite APRÈS le texte d'une délégation pour la +/// faire valider par la CLI (esquive la détection de paste : texte sans `\n`, +/// puis cette séquence seule après un court délai). Défaut `"\r"`. +#[serde(default, skip_serializing_if = "Option::is_none")] +pub submit_sequence: Option, // None ⇒ défaut "\r" appliqué au point d'usage +/// Délai (ms) entre l'écriture du texte et celle de `submit_sequence`. +/// Défaut ~50–80 ms. Évite que la TUI absorbe la soumission comme un paste. +#[serde(default, skip_serializing_if = "Option::is_none")] +pub submit_delay_ms: Option, // None ⇒ défaut (p.ex. 60) au point d'usage +``` +Withers additifs `with_submit_sequence`/`with_submit_delay_ms` (comme `with_prompt_ready_pattern`). Le **défaut** (`"\r"`, ~60 ms) est appliqué côté front (DTO `Option` → valeur effective), jamais codé en dur dans le domaine. + +**Port domaine `InputMediator` (`crates/domain/src/input.rs`) — recentrage.** On **retire la sémantique d'écriture PTY** de `enqueue`/`bind_handle*` (la doc de `enqueue` ne promet plus la livraison physique) ; le médiateur reste autorité **file + busy + corrélation + prompt-watcher**. Pas de nouvelle méthode obligatoire : la livraison passe désormais par un **event** (ci-dessous). `bind_handle_with_prompt` **reste** (le prompt-ready watcher observe toujours le flux de sortie côté infra). `delivers_turn` devient inutile (toujours « le front délivre ») → marqué déprécié/`false`. + +**Adapter infra `MediatedInbox` (`crates/infrastructure/src/input/mod.rs`).** `enqueue` **ne fait plus** le `pty.write(line)` (suppression du bloc 305-313, donc du `\n` band-aid). À la place, sur la transition qui **démarre le tour** (Idle→Busy) il publie un **nouvel event** `DelegationReady` portant le **texte de la tâche** + ticket + agent (le `BusyTracker`/`enqueue` a déjà l'`EventBus`). Le `preempt` (ESC) **reste** (interruption = octet de contrôle, légitime côté back ; il ne concourt pas avec une écriture de ligne). Le prompt-ready watcher **reste** identique. + +**Nouvel event domaine `DomainEvent` (`crates/domain/src/events.rs`).** +```rust +/// Une délégation est prête à être injectée dans le terminal natif de l'agent +/// (le front exécute le handshake b→e et écrit texte + submit_sequence). Le +/// backend reste l'autorité de file/busy ; il NE PTY-écrit PLUS le tour. +DelegationReady { + agent_id: AgentId, + ticket: TicketId, + text: String, + /// Profil de la cible : la cellule applique submit_sequence/submit_delay_ms. + submit_sequence: Option, + submit_delay_ms: Option, +}, +``` +Relayé au front comme les autres `DomainEvent` (mapping DTO camelCase déjà en place). Discret, basse fréquence (1/délégation). + +**Commande Tauri (ack de livraison) — `crates/app-tauri/src/commands.rs` + `dto.rs`.** Le front confirme qu'il a **effectivement écrit** la délégation (clôt la boucle « le tour est parti »), pour distinguer « en file car ligne occupée » d'« écrit » côté observabilité/persistance : +```rust +// DTO +#[serde(rename_all = "camelCase")] +pub struct DeliveredDelegationRequestDto { pub project_id: String, pub agent_id: String, pub ticket: String } +// commande +#[tauri::command] pub async fn delegation_delivered(request: DeliveredDelegationRequestDto, state: …) -> Result<(), ErrorDto> +``` +Mappé vers une méthode applicative best-effort (`OrchestratorService::note_delegation_delivered`) — **ne change pas** la corrélation (le réveil de l'`ask` reste `idea_reply`→`resolve_ticket`) ; sert l'observabilité/log. Les commandes existantes `submit_agent_input`/`reattach_agent_chat` deviennent **mortes** pour les agents (retirées en L5) ; `interrupt_agent` **reste** (Échap → `preempt`), mais l'UI l'invoque désormais depuis le terminal (raccourci), plus depuis la barre. + +**Ports/adapter frontend (`frontend/src/ports/index.ts`, `adapters/input.ts`).** `InputGateway` perd `submit` (plus de barre) et **gagne** : +```ts +export interface InputGateway { + /** Interrompre = preempt (Échap/stop). Reste. */ + interrupt(projectId: string, agentId: string): Promise; + /** Ack : la cellule a écrit la délégation `ticket` dans le PTY natif. */ + delegationDelivered(projectId: string, agentId: string, ticket: string): Promise; +} +``` +Un nouveau port d'**abonnement** aux `DelegationReady` n'est **pas** nécessaire : `SystemGateway.onDomainEvent` les relaie déjà (filtrer `event.type === "delegationReady"`), à l'image de `useAgentBusy`. Côté domaine front, ajouter la variante `DelegationReady` au type `DomainEvent`. + +**Frontend — le portail.** Un hook `useWritePortal(handle, agentId)` détient : (1) le **compteur de ligne** (incrément/`reset` branchés sur `term.onData` dans `TerminalView`), (2) la **file locale** des `DelegationReady` reçues, (3) l'exécution du **handshake** (b→e) avec **plancher 2 s** et **revérif K + backspaces**, (4) l'**overlay** (état booléen rendu par la cellule). `TerminalView` (agent) écrit les frappes **et** notifie le portail (imprimable/Entrée/Ctrl-C) ; quand le portail injecte, il **coupe** le relais des frappes (flag `suspended`) et écrit via `handle.write`. + +### 20.4 Comment le backend connaît « ligne humaine vide » — il ne la connaît PAS +La décision frontière l'évite : **la frontière propre est jugée côté front** (seul détenteur du compteur). Le backend publie `DelegationReady` **dès** que le tour démarre ; c'est le **front** qui retient l'injection jusqu'à `prompt-ready ET ligne vide`. Le « prompt-ready » est connu des **deux** : le watcher backend le détecte sur le flux de sortie pour le **busy** ; le front peut soit ré-utiliser un signal (un futur `AgentPromptReady` event, optionnel) soit, plus simplement, considérer la **ligne vide** comme condition front suffisante et s'appuyer sur le fait que la CLI **empile** nativement les soumissions (robustesse : même injectée « tôt », la CLI met en file). **Choix retenu (sobre)** : le front conditionne sur **ligne humaine vide** uniquement ; la nativité de la CLI gère le reste ; aucun round-trip. Si l'expérience montre des injections trop précoces, on ajoute l'event `AgentPromptReady` (additif, sans changer le portail). Aucune des deux variantes ne crée deux écrivains. + +### 20.5 Découpage en lots (dev/test séquencés) + +| Lot | Couche | Contenu | Fichiers | Testable (vert) | +|---|---|---|---|---| +| **L1** | domaine+infra | Profil `submit_sequence`/`submit_delay_ms` (+ withers) ; `MediatedInbox::enqueue` **cesse** d'écrire le PTY (suppr. bloc `\n`) et **publie** `DelegationReady` ; `DomainEvent::DelegationReady` | `domain/src/profile.rs`, `domain/src/events.rs`, `infrastructure/src/input/mod.rs` | round-trip serde (clés omises si `None`, legacy→`None`) ; `enqueue` ne fait **aucun** `pty.write` ; publie 1 `DelegationReady{text,ticket}` sur Idle→Busy, **0** sur 2ᵉ enqueue busy ; `preempt` inchangé ; prompt-watcher tests intacts | +| **L2** | app+app-tauri | `OrchestratorService` : `ask_agent`/`submit_human_input` n'attendent plus du médiateur l'écriture (déjà le cas) ; ajout `note_delegation_delivered` ; commande `delegation_delivered` + DTO ; relais `DelegationReady` au front | `application/src/orchestrator/service.rs`, `app-tauri/src/commands.rs`, `dto.rs`, `lib.rs` (register) | `ask_agent` toujours réveillé par `idea_reply` (timeout/cycle inchangés) ; `delegation_delivered` best-effort (no-op si non câblé) ne casse pas la corrélation ; event mappé camelCase `delegationReady` | +| **L3** | frontend | `TerminalView` agent **natif** : frappes → PTY **inconditionnellement** (retrait du drop `agentMode`) ; compteur de ligne (imprimable +1 / Entrée|Ctrl-C reset) exposé au portail ; `InputGateway` (retrait `submit`, ajout `delegationDelivered`) + adapter ; type front `DomainEvent.DelegationReady` | `frontend/src/features/terminals/TerminalView.tsx`, `ports/index.ts`, `adapters/input.ts`, `domain/*`, `app/di.tsx`, mocks | Vitest : en agent, une frappe écrit le PTY ; compteur +1 sur `a`, reset sur `\r` et `\x03` ; multiligne natif n'altère pas le compteur de façon erronée ; adapter appelle `delegation_delivered` avec `{projectId,agentId,ticket}` | +| **L4** | frontend | `useWritePortal` + overlay : réception `DelegationReady` → file ; injection **ssi ligne vide** ; handshake (b) couper relais+overlay, (c) revérif K→`\x7f`×K, (d) `write(text)` puis `write(submitSequence??"\r")` après `submitDelayMs??60`, (e) réactiver+overlay off **plancher 2 s** ; ack `delegationDelivered` | `frontend/src/features/terminals/useWritePortal.ts` (nouveau), `LayoutGrid.tsx` (overlay + montage), `features/terminals/index.ts` | Vitest (faux timers + faux handle) : injecte **rien** tant que ligne non vide ; à ligne vide → écrit `text` **sans** `\n` puis `\r` après le délai ; course « K=2 lettres dans le micro-intervalle » → 2 `\x7f` avant le texte ; **plancher 2 s** : overlay maintenu si (e) < 2 s après (b) ; relais frappes coupé pendant l'overlay ; `delegationDelivered` appelé une fois après (d) | +| **L5** | frontend+app-tauri | **Retrait** de `MediatedInput` (suppr. composant + montage `LayoutGrid`), de `useAgentBusy` si plus utilisé (ou conservé pour un badge), des commandes mortes `submit_agent_input`/`reattach_agent_chat`/DTO afférents ; `interrupt` rebranché sur un raccourci terminal | `LayoutGrid.tsx`, `MediatedInput.tsx` (suppr.), `useAgentBusy.ts`, `app-tauri/src/commands.rs`/`dto.rs`/`lib.rs`, tests | suite front verte sans `MediatedInput` ; aucune cellule **plain** modifiée (régression nulle) ; `cargo build` sans les commandes retirées ; `interrupt_agent` toujours appelable | + +**Ordre** : L1→L2 (back prêt) ∥ L3 (front natif) → L4 (portail, dépend L1/L3) → L5 (nettoyage). L1 est le pivot (supprime le `\n`, source du bug, et bascule la livraison sur event). + +### 20.6 Plan de tests — cas limites explicites +- **Course 1–2 lettres** (étape c) : entre (a) « ligne vide constatée » et (b) « relais coupé », l'humain tape K∈{1,2} ⇒ le portail écrit **exactement** K `\x7f` puis le texte ; **jamais** Ctrl-U. *(Vitest, faux handle enregistrant les writes.)* +- **Plancher 2 s** (étape e) : (d) se termine à t<2 s ⇒ overlay + relais coupés **maintenus** jusqu'à t=2 s ; (e) à t≥2 s ⇒ pas d'attente résiduelle. *(faux timers.)* +- **Multiligne natif** : l'humain compose une commande multiligne (la CLI gère ses propres `\n` internes) ⇒ le compteur **n'injecte pas** au milieu (ligne « non vide » tant que la frappe est en cours) ; on ne réécrit jamais le contenu humain. +- **Ligne non vide à la frontière** : `DelegationReady` reçue alors que compteur>0 ⇒ **mise en file**, **aucune** écriture ; libérée à la prochaine ligne vide. *(pas de refus, pas de perte.)* +- **Préemption pendant overlay** : Échap (`interrupt`) pendant l'overlay ⇒ `preempt` (ESC) côté back inchangé ; le portail lève l'overlay au plancher ; la délégation en cours d'écriture n'est pas dupliquée (ack idempotent). +- **Back (Rust)** : `enqueue` ne PTY-écrit plus ; 1 `DelegationReady` sur démarrage de tour, 0 en re-enqueue busy ; `preempt`/prompt-watcher/`mark_idle` inchangés ; `ask_agent` réveillé par `idea_reply`, timeout/cycle intacts ; profil serde zéro régression. + +### 20.7 Risques résiduels & vigilance +- **Frontière « tôt »** : conditionner sur « ligne vide » seule peut injecter avant le tout premier prompt-ready. Mitigation : la CLI empile nativement ; si insuffisant, event additif `AgentPromptReady` (déjà détecté côté back) sans toucher le portail. +- **`submit_sequence` par CLI** : `"\r"` + délai esquive la paste-detection de Claude Code ; d'autres TUI pourraient exiger une autre séquence/délai → c'est précisément pourquoi c'est **déclaratif** (profil), pas codé en dur. +- **Compteur de ligne vs séquences ANSI** : ne compter que les **imprimables** issus de `term.onData` (frappes), pas la sortie ; ignorer les séquences de contrôle (flèches/échap) pour éviter un faux « ligne non vide » qui bloquerait toute injection. +- **Reattach** : à la réouverture d'une cellule, le portail repart d'un **compteur=0** et d'une file vide ; une `DelegationReady` perdue pendant la navigation est rejouée par le `ask` en attente (le back retient le ticket jusqu'au reply/timeout) → pas de perte de corrélation. +- **Plain shell strictement inchangé** : tout le mécanisme est gardé par `agentMode`/présence d'agent ; aucune cellule plain ne voit overlay, portail, ni compteur. + +--- + *Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.* diff --git a/CLAUDE.md b/CLAUDE.md index d0c97f4..bba1d08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,21 @@ Je suis responsable de : - Produisent un **rapport d'erreurs** clair quand un test échoue. - Re-testent après chaque correction. +### 2.4 Agent Git (1 pour tout le projet) +- Garant du **dépôt git local** : commits de l'application, création/checkout/switch de + branches, merges et rebases. Contexte : `.ideai/agents/git.md`. +- **C'est lui qui décide** de la topologie des branches, pas moi. Je le sollicite, il tranche. +- Modèle de branches : **`main`** (release) ← **`develop`** (intégration des features + terminées) ← une branche **`feature/*`** par nouvelle feature. +- **Quand je commande une nouvelle feature** : une fois l'architecture cadrée par Architect, + je passe la main à **Git** qui décide s'il faut créer une branche, faire un checkout/switch, + ou rester en place — **avant** que le dev commence. +- **Après chaque implémentation** : je reparle à **Git** pour qu'il décide si un **merge** + doit être fait quelque part (typiquement `feature/* → develop` une fois les tests verts), + ou non. +- Périmètre **local uniquement** : aucune action sortante (`push`, publication) sans ma + validation explicite. + --- ## 3. Le cycle de développement (boucle obligatoire) @@ -47,11 +62,14 @@ Pour **chaque** feature implémentée ou modifiée : ``` 1. Agent Architecture → valide le découpage et les contrats (ports/interfaces) -2. Agent Développement → écrit le code -3. Agent Test → écrit les tests unitaires + les exécute -4a. Tests OK → feature validée, on passe à la suite -4b. Tests KO → rapport d'erreurs → retour à l'agent Développement - → correction → retour à l'étape 3 (boucle jusqu'au vert) +2. Agent Git → décide de la branche (créer feature/*, switch, ou rester) +3. Agent Développement → écrit le code +4. Agent Test → écrit les tests unitaires + les exécute +5a. Tests OK → feature validée + → Agent Git décide d'un merge éventuel (feature/* → develop) + → on passe à la suite +5b. Tests KO → rapport d'erreurs → retour à l'agent Développement + → correction → retour à l'étape 4 (boucle jusqu'au vert) ``` **Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas. diff --git a/Cargo.lock b/Cargo.lock index 59f9326..c0985bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -979,6 +979,26 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1923,6 +1943,7 @@ dependencies = [ "domain", "fastembed", "git2", + "landlock", "notify", "portable-pty", "reqwest 0.12.28", @@ -2131,6 +2152,17 @@ dependencies = [ "libc", ] +[[package]] +name = "landlock" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.18", +] + [[package]] name = "lazy_static" version = "1.5.0" diff --git a/crates/app-tauri/src/chat.rs b/crates/app-tauri/src/chat.rs index b272593..dfcdecd 100644 --- a/crates/app-tauri/src/chat.rs +++ b/crates/app-tauri/src/chat.rs @@ -175,11 +175,17 @@ impl ChatBridge { /// Maps a domain [`ReplyEvent`] to its wire [`ReplyChunk`]. Pure translation, no /// I/O — the single point where the typed turn event becomes a serialisable chunk /// (kept here so the pump and any test share one mapping). +/// +/// Returns `None` for [`ReplyEvent::Heartbeat`]: a heartbeat is a non-terminal +/// liveness proof (readiness/heartbeat lot 1) with **no chat content**, so it maps +/// to no wire chunk — the pump simply skips it. Every content-bearing event still +/// maps to exactly one chunk. #[must_use] -pub fn chunk_from_event(event: ReplyEvent) -> ReplyChunk { +pub fn chunk_from_event(event: ReplyEvent) -> Option { match event { - ReplyEvent::TextDelta { text } => ReplyChunk::TextDelta { text }, - ReplyEvent::ToolActivity { label } => ReplyChunk::ToolActivity { label }, - ReplyEvent::Final { content } => ReplyChunk::Final { content }, + ReplyEvent::TextDelta { text } => Some(ReplyChunk::TextDelta { text }), + ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }), + ReplyEvent::Final { content } => Some(ReplyChunk::Final { content }), + ReplyEvent::Heartbeat => None, } } diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index d3c9fcf..38c8b90 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -9,48 +9,49 @@ use tauri::State; use crate::dto::DismissEmbedderSuggestionRequestDto; use application::{ - AppError, AssignSkillToAgentInput, ChangeAgentProfileInput, CloseProjectInput, CreateAgentInput, - CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput, - DeleteEmbedderProfileInput, - DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput, - DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, - GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, - InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LiveSessions, - McpRuntime, - ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput, - OpenProjectInput, - ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, - ReconcileLayoutsInput, RenameLayoutInput, ResolveMemoryLinksInput, SetActiveLayoutInput, - SnapshotRunningAgentsInput, - SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, - UpdateMemoryInput, UpdateProjectContextInput, UpdateSkillInput, + AppError, AssignSkillToAgentInput, ChangeAgentProfileInput, CloseProjectInput, + CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput, + DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, + DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, + GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, + InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, + ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, + McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadMemoryIndexInput, + ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, RenameLayoutInput, + ResolveAgentPermissionsInput, ResolveMemoryLinksInput, SetActiveLayoutInput, + SnapshotRunningAgentsInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, + UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput, + UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput, }; use domain::ports::PtyHandle; use crate::dto::{ parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug, parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id, - parse_template_id, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, - AttachLiveAgentRequestDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, - ConfigureProfilesRequestDto, ConversationDetailsDto, + parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto, + AssignSkillRequestDto, AttachLiveAgentRequestDto, ChangeAgentProfileDto, + ChangeAgentProfileRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, - DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, - EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, - GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, + 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, SubmitAgentInputRequestDto, - MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, - ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachChatDto, - ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, - ResizeTerminalRequestDto, ResumableAgentListDto, - SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, - SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, + LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, + OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, + ProjectPermissionsDto, ReadAgentContextResponseDto, ReattachChatDto, ReattachResultDto, + RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, + ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, + SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, SkillListDto, + SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, - UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateSkillRequestDto, - UpdateTemplateRequestDto, WriteTerminalRequestDto, + UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, + WriteTerminalRequestDto, }; use crate::pty::{PtyBridge, PtyChunk}; use crate::state::AppState; @@ -122,6 +123,7 @@ pub async fn open_project( .await; // (Re)start the orchestrator watcher for this project (idempotent, §14.3). state.ensure_orchestrator_watch(&output.project); + state.reconcile_claude_run_dirs(&output.project).await; Ok(ProjectDto::from(output)) } @@ -210,6 +212,87 @@ pub async fn update_project_context( .map_err(ErrorDto::from) } +/// `get_project_permissions` — read `.ideai/permissions.json`. +/// +/// # Errors +/// Returns an [`ErrorDto`] on invalid project id or store failure. +#[tauri::command] +pub async fn get_project_permissions( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + state + .get_project_permissions + .execute(application::GetProjectPermissionsInput { project }) + .await + .map(|out| ProjectPermissionsDto(out.permissions)) + .map_err(ErrorDto::from) +} + +/// `update_project_permissions` — replace project default permissions. +/// +/// # Errors +/// Returns an [`ErrorDto`] on invalid project id or store failure. +#[tauri::command] +pub async fn update_project_permissions( + request: UpdateProjectPermissionsRequestDto, + state: State<'_, AppState>, +) -> Result { + let project = resolve_project(&request.project_id, &state).await?; + state + .update_project_permissions + .execute(UpdateProjectPermissionsInput { + project, + permissions: request.permissions, + }) + .await + .map(|out| ProjectPermissionsDto(out.permissions)) + .map_err(ErrorDto::from) +} + +/// `update_agent_permissions` — replace or remove one agent override. +/// +/// # Errors +/// Returns an [`ErrorDto`] on invalid ids or store failure. +#[tauri::command] +pub async fn update_agent_permissions( + request: UpdateAgentPermissionsRequestDto, + 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_permissions + .execute(UpdateAgentPermissionsInput { + project, + agent_id, + permissions: request.permissions, + }) + .await + .map(|out| ProjectPermissionsDto(out.permissions)) + .map_err(ErrorDto::from) +} + +/// `resolve_agent_permissions` — resolve project defaults plus agent override. +/// +/// # Errors +/// Returns an [`ErrorDto`] on invalid ids or store failure. +#[tauri::command] +pub async fn resolve_agent_permissions( + request: ResolveAgentPermissionsRequestDto, + state: State<'_, AppState>, +) -> Result, ErrorDto> { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + state + .resolve_agent_permissions + .execute(ResolveAgentPermissionsInput { project, agent_id }) + .await + .map(|out| out.effective.map(EffectivePermissionsDto)) + .map_err(ErrorDto::from) +} + // --------------------------------------------------------------------------- // Terminals (L3) // --------------------------------------------------------------------------- @@ -1049,6 +1132,11 @@ pub async fn launch_agent( requester: agent_id.to_string(), }); + // Functional migration seam: before any launch/reattach/idempotent early-return, + // repair the target Claude run dir so stale `.mcp.json` / `.claude/settings.local.json` + // artefacts from previous AppImages do not survive into this activation. + state.reconcile_claude_run_dirs(&project).await; + let output = state .launch_agent .execute(LaunchAgentInput { @@ -1193,7 +1281,11 @@ pub async fn agent_send( let bridge = std::sync::Arc::clone(&state.chat_bridge); std::thread::spawn(move || { for event in stream { - let chunk = crate::chat::chunk_from_event(event); + // Heartbeats carry no chat content (readiness/heartbeat lot 1) ⇒ no wire + // chunk; skip them while still draining so the turn runs to its `Final`. + let Some(chunk) = crate::chat::chunk_from_event(event) else { + continue; + }; // `send_output` always records into the conversation scrollback; the // boolean only reflects live delivery. If the view navigated away // (no channel at this generation) we keep draining so the turn still @@ -1206,31 +1298,6 @@ pub async fn agent_send( Ok(()) } -/// `submit_agent_input` — the human **Envoyer** path (cadrage C4 §4.2). -/// -/// Routes the operator's text to [`OrchestratorService::submit_human_input`], which -/// enqueues a `from_human` ticket in the **same FIFO** the inter-agent delegations -/// use (serialised per agent). Fire-and-forget: the human watches the terminal for -/// the answer. The mediator emits `AgentBusyChanged` at the source. -/// -/// # Errors -/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or unwired mediator, -/// `NOT_FOUND` if the project or agent is unknown, `PROCESS` on a launch/PTY failure). -#[tauri::command] -pub async fn submit_agent_input( - request: SubmitAgentInputRequestDto, - state: State<'_, AppState>, -) -> Result<(), ErrorDto> { - let project = resolve_project(&request.project_id, &state).await?; - let agent_id = parse_agent_id(&request.agent_id)?; - state - .orchestrator_service - .submit_human_input(&project, agent_id, request.text) - .await - .map(|_| ()) - .map_err(ErrorDto::from) -} - /// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2). /// /// Routes to [`OrchestratorService::interrupt_agent`], which `preempt`s the agent's @@ -1255,6 +1322,55 @@ pub async fn interrupt_agent( .map_err(ErrorDto::from) } +/// `delegation_delivered` — the frontend write-portal's **ack** (ARCHITECTURE §20.3). +/// +/// Called once the cell has physically written a delegation `ticket` into the agent's +/// native PTY (text + submit sequence). Routes to the best-effort +/// [`OrchestratorService::note_delegation_delivered`] (observability/log only): it does +/// **not** change correlation — the requester's `ask` is still woken by `idea_reply`, +/// and timeouts/cycle guards are untouched. Infallible on the application side; only a +/// malformed id (project/agent/ticket) yields an `INVALID` error here. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the project is +/// unknown). +#[tauri::command] +pub async fn delegation_delivered( + request: DeliveredDelegationRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + let ticket = parse_ticket_id(&request.ticket)?; + state + .orchestrator_service + .note_delegation_delivered(&project, agent_id, ticket); + Ok(()) +} + +/// `set_front_attached` — the write-portal reports whether a **frontend terminal cell** +/// is mounted for an agent (mount ⇒ `true`, unmount ⇒ `false`). +/// +/// Routes to [`OrchestratorService::set_agent_front_attached`]. This is what lets the +/// mediator deliver a turn to a **headless** (background-delegated, cell-less) agent by +/// writing its PTY itself: with no mounted cell nobody consumes `DelegationReady`, so +/// the task would otherwise be lost (a delegated agent that never receives — and never +/// answers — its task). An agent **with** a cell keeps the frontend write-portal path. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID`) for a malformed agent id. +#[tauri::command] +pub async fn set_front_attached( + request: FrontAttachedRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let agent_id = parse_agent_id(&request.agent_id)?; + state + .orchestrator_service + .set_agent_front_attached(agent_id, request.attached); + Ok(()) +} + /// `reattach_agent_chat` — re-bind a view to a **still-living** structured session /// without re-sending or re-spawning it (ARCHITECTURE §17.6/§17.7). /// diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index de74a2b..e6665a5 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -1066,7 +1066,7 @@ use application::{ ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput, }; -use domain::{Agent, TerminalSession}; +use domain::{Agent, EffectivePermissions, PermissionSet, ProjectPermissions, TerminalSession}; /// An agent crossing the wire. [`Agent`] already serialises camelCase /// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`), @@ -1135,6 +1135,52 @@ pub struct UpdateAgentContextRequestDto { pub content: String, } +// --------------------------------------------------------------------------- +// Permissions (LP1) +// --------------------------------------------------------------------------- + +/// Full project permission document crossing the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProjectPermissionsDto(pub ProjectPermissions); + +/// Effective permissions crossing the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct EffectivePermissionsDto(pub EffectivePermissions); + +/// Request DTO for updating project default permissions. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProjectPermissionsRequestDto { + /// Id of the owning project. + pub project_id: String, + /// New project defaults. `null` removes defaults. + pub permissions: Option, +} + +/// Request DTO for updating one agent permission override. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAgentPermissionsRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Target agent id. + pub agent_id: String, + /// New override. `null` removes the override. + pub permissions: Option, +} + +/// Request DTO for resolving one agent's effective permissions. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveAgentPermissionsRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Target agent id. + pub agent_id: String, +} + /// Request DTO for `update_project_context`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1197,20 +1243,6 @@ impl From for TerminalSessionDto { } } -/// Request DTO for `submit_agent_input` (cadrage C4 §4.2): the human Envoyer path. -/// The frontend's [`InputGateway`](../../../frontend/src/ports) sends -/// `{ request: { projectId, agentId, text } }`. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SubmitAgentInputRequestDto { - /// Id of the owning project. - pub project_id: String, - /// Id of the agent to send the human input to. - pub agent_id: String, - /// The text the operator typed (enqueued as a `from_human` ticket). - pub text: String, -} - /// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The /// frontend sends `{ request: { projectId, agentId } }`. #[derive(Debug, Clone, Deserialize)] @@ -1222,6 +1254,37 @@ pub struct InterruptAgentRequestDto { pub agent_id: String, } +/// Request DTO for `delegation_delivered` (ARCHITECTURE §20.3): the frontend write- +/// portal acks that it **physically wrote** a delegation `ticket` into the agent's +/// native PTY. Best-effort observability — it never changes correlation (the `ask` is +/// still woken by `idea_reply`). The frontend sends `{ request: { projectId, agentId, +/// ticket } }`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeliveredDelegationRequestDto { + /// Id of the owning project. + pub project_id: String, + /// Id of the agent whose terminal received the delegation. + pub agent_id: String, + /// Id of the delivered mailbox ticket. + pub ticket: String, +} + +/// Request DTO for `set_front_attached`: the write-portal of an agent cell reports +/// whether a **frontend terminal cell is mounted** for `agentId` (`true` on mount, +/// `false` on unmount). The mediator uses it to choose, at delivery time, between +/// publishing `DelegationReady` (a cell will write it) and writing the turn into the +/// PTY itself (headless/background-delegated agent with no cell). The frontend sends +/// `{ request: { agentId, attached } }`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrontAttachedRequestDto { + /// Id of the agent whose terminal cell mounted/unmounted. + pub agent_id: String, + /// `true` when the cell's write-portal is now active, `false` on teardown. + pub attached: bool, +} + /// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime /// profile, optionally relaunching its live session in place. #[derive(Debug, Clone, Deserialize)] @@ -1435,6 +1498,19 @@ pub fn parse_agent_id(raw: &str) -> Result { }) } +/// Parses a ticket-id string (UUID) coming from the frontend (`delegation_delivered`). +/// +/// # Errors +/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. +pub fn parse_ticket_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(domain::TicketId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid ticket id: {raw}"), + }) +} + // --------------------------------------------------------------------------- // Resumable agents (§15.2 — Chantier B2) // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index c8d2c28..26c512d 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -13,6 +13,7 @@ use serde::Serialize; use tauri::{AppHandle, Emitter}; use domain::events::{DomainEvent, OrchestrationSource}; +use domain::input::AgentLiveness; use infrastructure::TokioBroadcastEventBus; /// Name of the Tauri event carrying relayed [`DomainEvent`]s. @@ -56,6 +57,26 @@ pub enum DomainEventDto { /// `true` when a turn is in flight, `false` when idle. busy: bool, }, + /// A delegation is ready to be injected into the agent's **native terminal** + /// (ARCHITECTURE §20). The frontend write-portal writes `text` + `submitSequence` + /// (default `"\r"`) after `submitDelayMs` (default ~60) once the human line is empty, + /// then acks via the `delegation_delivered` command. The backend no longer PTY-writes + /// the turn. + #[serde(rename_all = "camelCase")] + DelegationReady { + /// Target agent id (UUID string). + agent_id: String, + /// Mailbox ticket id (UUID string) to ack back via `delegation_delivered`. + ticket: String, + /// Task text to inject (written without a trailing newline by the portal). + text: String, + /// Profile's submit sequence; `null` ⇒ the front applies its default (`"\r"`). + #[serde(skip_serializing_if = "Option::is_none")] + submit_sequence: Option, + /// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms). + #[serde(skip_serializing_if = "Option::is_none")] + submit_delay_ms: Option, + }, /// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4). #[serde(rename_all = "camelCase")] AgentReplied { @@ -64,6 +85,17 @@ pub enum DomainEventDto { /// Reply length in bytes (metric, not the payload). reply_len: usize, }, + /// An agent's liveness (alive/stalled) changed (lot 2, readiness/heartbeat). The + /// frontend can badge a frozen agent; `"stalled"` while no proof of liveness arrived + /// for longer than the profile's `stallAfterMs`, back to `"alive"` on a late + /// battement or when the turn ends. + #[serde(rename_all = "camelCase")] + AgentLivenessChanged { + /// Agent id (UUID string). + agent_id: String, + /// New liveness, as a lowercase string (`"alive"` / `"stalled"`). + liveness: AgentLivenessDto, + }, /// An agent's runtime profile was changed (hot-swap of the AI engine). #[serde(rename_all = "camelCase")] AgentProfileChanged { @@ -195,6 +227,26 @@ pub enum OrchestrationSourceDto { Mcp, } +/// Wire mirror of [`AgentLiveness`]: the alive/stalled liveness of an agent (lot 2), +/// serialised as a lowercase string (`"alive"` / `"stalled"`) the frontend badges. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum AgentLivenessDto { + /// The agent shows proof of liveness (or is idle). + Alive, + /// No proof of liveness past the profile's `stallAfterMs` threshold. + Stalled, +} + +impl From for AgentLivenessDto { + fn from(liveness: AgentLiveness) -> Self { + match liveness { + AgentLiveness::Alive => Self::Alive, + AgentLiveness::Stalled => Self::Stalled, + } + } +} + impl From for OrchestrationSourceDto { fn from(source: OrchestrationSource) -> Self { match source { @@ -225,6 +277,19 @@ impl From<&DomainEvent> for DomainEventDto { agent_id: agent_id.to_string(), busy: *busy, }, + DomainEvent::DelegationReady { + agent_id, + ticket, + text, + submit_sequence, + submit_delay_ms, + } => Self::DelegationReady { + agent_id: agent_id.to_string(), + ticket: ticket.to_string(), + text: text.clone(), + submit_sequence: submit_sequence.clone(), + submit_delay_ms: *submit_delay_ms, + }, DomainEvent::AgentReplied { agent_id, reply_len, @@ -232,6 +297,12 @@ impl From<&DomainEvent> for DomainEventDto { agent_id: agent_id.to_string(), reply_len: *reply_len, }, + DomainEvent::AgentLivenessChanged { agent_id, liveness } => { + Self::AgentLivenessChanged { + agent_id: agent_id.to_string(), + liveness: (*liveness).into(), + } + } DomainEvent::AgentProfileChanged { agent_id, profile_id, @@ -343,3 +414,40 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) { } }); } + +#[cfg(test)] +mod tests { + use super::*; + use domain::ids::AgentId; + + fn agent(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) + } + + /// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO + /// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que + /// le front badge). Garantit le câblage présentation de la détection de stall. + #[test] + fn liveness_changed_stalled_relays_to_dto_and_wire() { + let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged { + agent_id: agent(7), + liveness: AgentLiveness::Stalled, + }); + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "agentLivenessChanged"); + assert_eq!(json["agentId"], agent(7).to_string()); + assert_eq!(json["liveness"], "stalled"); + } + + /// La reprise `Stalled→Alive` se relaie en DTO `Alive` ⇒ wire `"alive"`. + #[test] + fn liveness_changed_alive_relays_to_dto_and_wire() { + let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged { + agent_id: agent(3), + liveness: AgentLiveness::Alive, + }); + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "agentLivenessChanged"); + assert_eq!(json["liveness"], "alive"); + } +} diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 43c35cb..59fd150 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -44,15 +44,10 @@ pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server"; #[must_use] pub fn dispatch() -> ExitCode { let mut args = std::env::args_os().skip(1); - if args - .next() - .is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) - { + if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) { // Headless bridge: bypass Tauri entirely. Forward the remaining args // (`--endpoint`, `--project`, `--requester`) to the bridge parser. - let rest: Vec = args - .map(|a| a.to_string_lossy().into_owned()) - .collect(); + let rest: Vec = args.map(|a| a.to_string_lossy().into_owned()).collect(); return mcp_bridge::run_mcp_bridge(rest); } @@ -132,6 +127,10 @@ pub fn run() { commands::list_projects, commands::read_project_context, commands::update_project_context, + commands::get_project_permissions, + commands::update_project_permissions, + commands::update_agent_permissions, + commands::resolve_agent_permissions, commands::open_terminal, commands::write_terminal, commands::resize_terminal, @@ -166,8 +165,9 @@ pub fn run() { commands::launch_agent, commands::change_agent_profile, commands::agent_send, - commands::submit_agent_input, commands::interrupt_agent, + commands::delegation_delivered, + commands::set_front_attached, commands::reattach_agent_chat, commands::close_agent_session, commands::list_resumable_agents, diff --git a/crates/app-tauri/src/mcp_bridge.rs b/crates/app-tauri/src/mcp_bridge.rs index 639830d..ccc3742 100644 --- a/crates/app-tauri/src/mcp_bridge.rs +++ b/crates/app-tauri/src/mcp_bridge.rs @@ -49,14 +49,18 @@ use std::time::Duration; use interprocess::local_socket::tokio::Stream as LocalSocketStream; use interprocess::local_socket::traits::tokio::Stream as _; use interprocess::local_socket::{GenericFilePath, ToFsName as _}; -use tokio::io::{ - AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader, -}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; /// How long the bridge waits to connect to the project loopback before giving up. /// Bounded so an absent/unreachable endpoint fails fast instead of hanging. const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +/// After the CLI closes stdin, how long to keep draining the loopback so an +/// in-flight response still reaches the CLI. Bounded so the bridge never blocks +/// waiting on the server: when stdin closes the CLI is gone, and the OS closes the +/// loopback fd at process exit anyway. +const DRAIN_GRACE: Duration = Duration::from_secs(1); + /// Parsed `mcp-server` invocation arguments. /// /// `--endpoint` is **required** (without it the bridge has nowhere to relay). @@ -222,18 +226,26 @@ async fn connect_loopback(endpoint: &str) -> Result { /// /// Sequence: /// 1. Write the **handshake line** (`project`+`requester`) to the loopback. -/// 2. Loop: read one **line** from the CLI (`cli_in`) → write it to the loopback; -/// read one **line** from the loopback → write it to the CLI (`cli_out`). -/// 3. **CLI stdin EOF** ⇒ stop, return `Ok(())` (clean code 0). Dropping the -/// loopback writer closes that direction of the connection. +/// 2. Run **two independent directional pumps concurrently** (full duplex): +/// - `CLI → loopback`: every line from `cli_in` is forwarded to the loopback; +/// - `loopback → CLI`: every line from the loopback is forwarded to `cli_out`. +/// 3. **CLI stdin EOF** ⇒ half-close the loopback write side (so the server sees +/// EOF), **drain** any responses still in flight, then return `Ok(())`. /// -/// The loop is request/response paced (one CLI line in, one loopback line back), -/// matching the JSON Lines `StdioTransport` the server speaks. A loopback that -/// closes mid-exchange surfaces as an error (non-zero exit). +/// ## Why full duplex (and not lockstep) +/// +/// JSON-RPC over MCP is **not** one-response-per-request. **Notifications** (e.g. +/// `notifications/initialized` sent right after `initialize`) carry no `id` and get +/// **no response**, and the server may push messages unsolicited. A lockstep pump +/// that reads one CLI line then *blocks* for exactly one loopback line **deadlocks** +/// on the first notification: it waits forever for a reply that never comes, and +/// never reads the client's next request (e.g. `tools/list`) — so the CLI never +/// receives its tool list. Two decoupled pumps let notifications and asynchronous +/// server messages flow freely in both directions. async fn relay( args: &BridgeArgs, cli_in: CIn, - mut cli_out: COut, + cli_out: COut, lp_read: LIn, mut lp_write: LOut, ) -> Result<(), String> @@ -253,48 +265,66 @@ where .await .map_err(|e| format!("handshake flush failed: {e}"))?; - let mut cli_reader = BufReader::new(cli_in); - let mut lp_reader = BufReader::new(lp_read); - let mut cli_line = String::new(); - let mut lp_line = String::new(); + // 2. Two decoupled pumps. Each owns its streams so neither blocks the other. + let cli_to_lp = pump_lines(BufReader::new(cli_in), lp_write, "stdin", "loopback"); + let lp_to_cli = pump_lines(BufReader::new(lp_read), cli_out, "loopback", "stdout"); + tokio::pin!(cli_to_lp); + tokio::pin!(lp_to_cli); + tokio::select! { + // CLI closed stdin (or errored): half-close the loopback writer to nudge + // the server, then drain briefly so an in-flight response still reaches the + // CLI — but never block on the server (bounded by DRAIN_GRACE). + r = &mut cli_to_lp => { + let mut lp_write = r?; + let _ = lp_write.shutdown().await; + let _ = tokio::time::timeout(DRAIN_GRACE, &mut lp_to_cli).await; + Ok(()) + } + // Loopback closed first (server hangup): nothing left to relay. The CLI's + // stdin EOF is no longer needed to exit. + r = &mut lp_to_cli => r.map(|_| ()), + } +} + +/// Forwards every newline-delimited line from `reader` to `writer` until `reader` +/// hits EOF, flushing after each line so a peer blocked on a read sees it promptly. +/// +/// On clean EOF it returns the (now-drained) `writer` so the caller can half-close +/// it. `src`/`dst` name the two ends for error messages only. +async fn pump_lines( + mut reader: BufReader, + mut writer: W, + src: &str, + dst: &str, +) -> Result +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + let mut line = String::new(); loop { - // 2a. CLI → loopback (one line). EOF ⇒ clean shutdown. - cli_line.clear(); - let n = cli_reader - .read_line(&mut cli_line) + line.clear(); + let n = reader + .read_line(&mut line) .await - .map_err(|e| format!("stdin read failed: {e}"))?; + .map_err(|e| format!("{src} read failed: {e}"))?; if n == 0 { - // CLI closed stdin: nothing more to forward. Clean exit. - return Ok(()); + // Source closed: drain and hand the writer back for half-close. + writer + .flush() + .await + .map_err(|e| format!("{dst} flush failed: {e}"))?; + return Ok(writer); } - lp_write - .write_all(cli_line.as_bytes()) + writer + .write_all(line.as_bytes()) .await - .map_err(|e| format!("loopback write failed: {e}"))?; - lp_write + .map_err(|e| format!("{dst} write failed: {e}"))?; + writer .flush() .await - .map_err(|e| format!("loopback flush failed: {e}"))?; - - // 2b. loopback → CLI (one line). EOF here is a server hangup mid-exchange. - lp_line.clear(); - let m = lp_reader - .read_line(&mut lp_line) - .await - .map_err(|e| format!("loopback read failed: {e}"))?; - if m == 0 { - return Err("loopback closed before answering".to_string()); - } - cli_out - .write_all(lp_line.as_bytes()) - .await - .map_err(|e| format!("stdout write failed: {e}"))?; - cli_out - .flush() - .await - .map_err(|e| format!("stdout flush failed: {e}"))?; + .map_err(|e| format!("{dst} flush failed: {e}"))?; } } @@ -313,7 +343,12 @@ mod tests { #[test] fn parses_all_three_flags() { let a = BridgeArgs::parse([ - "--endpoint", "/tmp/x.sock", "--project", "p1", "--requester", "agent-7", + "--endpoint", + "/tmp/x.sock", + "--project", + "p1", + "--requester", + "agent-7", ]) .unwrap(); assert_eq!(a.endpoint, "/tmp/x.sock"); @@ -355,8 +390,7 @@ mod tests { }; let line = a.handshake_line(); assert_eq!(*line.last().unwrap(), b'\n'); - let v: serde_json::Value = - serde_json::from_slice(&line[..line.len() - 1]).unwrap(); + let v: serde_json::Value = serde_json::from_slice(&line[..line.len() - 1]).unwrap(); assert_eq!(v["project"], "proj"); assert_eq!(v["requester"], "rq"); } @@ -393,24 +427,19 @@ mod tests { reader.read_line(&mut request).await.unwrap(); let mut w = srv_write; - w.write_all(b"{\"result\":\"ok\",\"id\":1}\n").await.unwrap(); + w.write_all(b"{\"result\":\"ok\",\"id\":1}\n") + .await + .unwrap(); w.flush().await.unwrap(); (handshake, request) }); - relay( - &args, - &cli_in[..], - &mut cli_out, - lp_read, - lp_write, - ) - .await - .unwrap(); + relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write) + .await + .unwrap(); let (handshake, request) = server.await.unwrap(); - let hs: serde_json::Value = - serde_json::from_str(handshake.trim_end()).unwrap(); + let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap(); assert_eq!(hs["project"], "proj-1"); assert_eq!(hs["requester"], "agent-9"); assert_eq!(request.trim_end(), "{\"method\":\"tools/call\",\"id\":1}"); @@ -420,6 +449,69 @@ mod tests { ); } + /// Regression (the bug that hid the tool list for days): a **notification** + /// (no `id`, no response) sent between two requests must NOT stall the relay. + /// A lockstep pump deadlocks here — after forwarding the notification it blocks + /// waiting for a reply that never comes, and never reads `tools/list`. The + /// full-duplex relay forwards all three lines and delivers the one response. + #[tokio::test] + async fn relay_does_not_block_on_notification_without_response() { + // initialize result, then an unanswered notification, then tools/list. + let cli_in = b"{\"method\":\"initialize\",\"id\":1}\n\ + {\"method\":\"notifications/initialized\"}\n\ + {\"method\":\"tools/list\",\"id\":2}\n" + .to_vec(); + let mut cli_out: Vec = Vec::new(); + + let (lp_read, srv_write) = tokio::io::duplex(4096); // server → bridge + let (srv_read, lp_write) = tokio::io::duplex(4096); // bridge → server + + let args = BridgeArgs { + endpoint: "unused".into(), + project: "p".into(), + requester: "agent".into(), + }; + + // Server: handshake, then read all three forwarded lines, answering only + // the two that carry an `id`. The notification gets no reply — exactly the + // case that used to wedge the relay. + let server = tokio::spawn(async move { + let mut reader = BufReader::new(srv_read); + let mut w = srv_write; + for _ in 0..4 { + let mut line = String::new(); + if reader.read_line(&mut line).await.unwrap() == 0 { + break; + } + let v: serde_json::Value = match serde_json::from_str(line.trim_end()) { + Ok(v) => v, + Err(_) => continue, // handshake line + }; + if let Some(id) = v.get("id") { + w.write_all(format!("{{\"result\":\"ok\",\"id\":{id}}}\n").as_bytes()) + .await + .unwrap(); + w.flush().await.unwrap(); + } + } + }); + + tokio::time::timeout( + Duration::from_secs(5), + relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write), + ) + .await + .expect("relay must not deadlock on a notification") + .expect("relay ok"); + + server.await.unwrap(); + + let out = String::from_utf8(cli_out).unwrap(); + // Both request responses arrived; the notification produced none. + assert!(out.contains("\"id\":1"), "missing initialize response: {out}"); + assert!(out.contains("\"id\":2"), "missing tools/list response: {out}"); + } + /// stdin EOF with no request ⇒ relay returns `Ok` (code 0), having still sent /// the handshake. #[tokio::test] @@ -485,12 +577,9 @@ mod tests { #[tokio::test] async fn connect_to_absent_endpoint_errors_fast() { let endpoint = temp_endpoint("absent"); - let res = tokio::time::timeout( - Duration::from_secs(10), - connect_loopback(&endpoint), - ) - .await - .expect("connect_loopback must not hang"); + let res = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint)) + .await + .expect("connect_loopback must not hang"); assert!(res.is_err(), "absent endpoint must yield an error"); } @@ -545,13 +634,10 @@ mod tests { // Drive the whole bridge_over_loopback path (real connect + split + relay), // but feed our own stdio streams via `relay` to avoid touching process std. - let conn = tokio::time::timeout( - Duration::from_secs(10), - connect_loopback(&endpoint), - ) - .await - .expect("no hang") - .expect("connect to live endpoint"); + let conn = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint)) + .await + .expect("no hang") + .expect("connect to live endpoint"); let (lp_read, lp_write) = tokio::io::split(conn); tokio::time::timeout( @@ -565,8 +651,7 @@ mod tests { server.await.unwrap(); let (handshake, request) = captured.lock().await.clone(); - let hs: serde_json::Value = - serde_json::from_str(handshake.trim_end()).unwrap(); + let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap(); assert_eq!(hs["project"], "proj-e2e"); assert_eq!(hs["requester"], "agent-e2e"); assert_eq!(request.trim_end(), "{\"method\":\"ping\",\"id\":42}"); diff --git a/crates/app-tauri/src/mcp_endpoint.rs b/crates/app-tauri/src/mcp_endpoint.rs index 25c9ab8..b4dc728 100644 --- a/crates/app-tauri/src/mcp_endpoint.rs +++ b/crates/app-tauri/src/mcp_endpoint.rs @@ -136,9 +136,11 @@ pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint { /// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si /// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale. pub(crate) fn idea_exe_path() -> Option { - std::env::var("APPIMAGE") - .ok() - .or_else(|| std::env::current_exe().ok().map(|p| p.to_string_lossy().into_owned())) + std::env::var("APPIMAGE").ok().or_else(|| { + std::env::current_exe() + .ok() + .map(|p| p.to_string_lossy().into_owned()) + }) } /// Implémentation app-tauri du port [`McpRuntimeProvider`] : fournit à @@ -209,7 +211,9 @@ mod tests { fn unix_endpoint_is_a_sock_path_under_the_runtime_dir() { let p = pid("11111111-1111-1111-1111-111111111111"); let ep = mcp_endpoint(&p); - let path = ep.socket_path().expect("unix endpoint exposes a socket path"); + let path = ep + .socket_path() + .expect("unix endpoint exposes a socket path"); assert!(path.extension().is_some_and(|e| e == "sock")); assert_eq!(path.parent().unwrap(), unix_runtime_dir()); } @@ -252,9 +256,9 @@ mod tests { /// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`. #[test] fn app_provider_runtime_for_matches_sources_of_truth() { - use domain::{AgentId, ProjectId, Project}; use domain::project::ProjectPath; use domain::remote::RemoteRef; + use domain::{AgentId, Project, ProjectId}; let project = Project::new( ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()), diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index fb58f1f..93ac7a2 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -5,51 +5,58 @@ //! `Arc` into the use cases (ARCHITECTURE §1.1, §10). The use cases //! are then exposed through `tauri::State` to the command handlers. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::ffi::OsString; +use std::path::Path; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use application::{ AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, - CloseTerminal, - ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, CreateMemory, - CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, - DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines, - DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory, - GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, - GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, ListAgents, ListEmbedderProfiles, - ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills, - ListTemplates, - LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, + CloseTerminal, ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate, + CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, + DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, + DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, + FirstRunState, GetMemory, GetProjectPermissions, GitBranches, GitCheckout, GitCommit, GitGraph, + GitInit, GitLog, GitStage, GitStatus, GitUnstage, HealthUseCase, InspectConversation, + LaunchAgent, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, + ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, + LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, + PermissionProjectorRegistry, OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext, RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, - RenameLayout, ResizeTerminal, - ResolveMemoryLinks, - SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions, - SuggestedThisSession, + RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, + SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, - UpdateMemory, UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal, - AGENT_MEMORY_RECALL_BUDGET, + UpdateAgentPermissions, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, + UpdateSkill, UpdateTemplate, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, - MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, - TemplateStore, + MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, ProjectStore, + PtyPort, SkillStore, TemplateStore, }; -use domain::{DomainEvent, EmbedderProfile, Project, ProjectId}; +use domain::profile::{ + AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, +}; +use domain::remote::RemoteKind; +use domain::{AgentId, DomainEvent, EmbedderProfile, Project, ProjectId}; +use serde_json::{json, Map, Value}; +use uuid::Uuid; use infrastructure::{ - embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime, + embedder_from_profile, AdaptiveMemoryRecall, ClaudePermissionProjector, + ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, EmbedderEnvProbe, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, - FsHandoffStore, FsMemoryStore, FsProviderSessionStore, HeuristicHandoffSummarizer, - FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, - Git2Repository, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, - LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, SystemMillisClock, - NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, - SystemClock, TokioBroadcastEventBus, - UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, - RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + FsHandoffStore, FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, + FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository, + HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, + LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, + OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock, + SystemMillisClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall, + DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, + VECTOR_ONNX_ENABLED, }; use crate::chat::ChatBridge; @@ -226,6 +233,14 @@ pub struct AppState { pub inspect_conversation: Arc, /// Project registry — used by agent commands to resolve a `Project` from an id. pub project_store: Arc, + /// Read the project permission document. + pub get_project_permissions: Arc, + /// Update project-level default permissions. + pub update_project_permissions: Arc, + /// Update one agent permission override. + pub update_agent_permissions: Arc, + /// Resolve effective permissions for one agent. + pub resolve_agent_permissions: Arc, // --- Windows (L10) --- /// Detach a tab into a new OS window (persists the workspace topology). pub move_tab: Arc, @@ -378,7 +393,9 @@ impl AppState { let update_project_context = Arc::new(UpdateProjectContext::new(Arc::clone(&fs_port))); // --- PTY adapter + terminal use cases (L3) --- - let pty = Arc::new(PortablePtyAdapter::new()); + let pty = Arc::new( + PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer()), + ); let pty_port = Arc::clone(&pty) as Arc; let terminal_sessions = Arc::new(TerminalSessions::new()); @@ -387,8 +404,10 @@ impl AppState { // `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et // ChangeAgentProfile (shutdown polymorphe au hot-swap). let structured_sessions = Arc::new(StructuredSessions::new()); - let session_factory = - Arc::new(StructuredSessionFactory::new()) as Arc; + let session_factory = Arc::new( + StructuredSessionFactory::new() + .with_sandbox_enforcer(infrastructure::default_enforcer()), + ) as Arc; let open_terminal = Arc::new(OpenTerminal::new( Arc::clone(&pty_port), @@ -494,6 +513,10 @@ impl AppState { let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port))); let contexts_port = Arc::clone(&contexts) as Arc; + // --- Project permissions (LP1) --- + let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port))); + let permission_store_port = Arc::clone(&permission_store) as Arc; + // --- Skill store (L12) --- // Global skills live in the machine-local app-data dir; project skills are // resolved per call from each project's `.ideai/` (so one store serves all @@ -628,6 +651,20 @@ impl AppState { // PTH : tout profil (Claude/Codex inclus) ouvre une cellule terminal. Le code // `launch_structured` reste en place (mort-code retiré au lot de nettoyage B-6). let _session_factory = session_factory; // décâblé en B-2 (nettoyage B-6) + + // --- Permission projectors (lot LP3-5) --- + // UN seul registre, source unique de vérité, injecté à l'identique dans + // `LaunchAgent` (projection au lancement) ET `ChangeAgentProfile` (nettoyage des + // fichiers orphelins au swap). Les deux projecteurs concrets vivent dans + // l'infrastructure, keyés par leur `ProjectorKey` via `with(...)`. + let permission_projectors = Arc::new( + PermissionProjectorRegistry::new() + .with(Arc::new(ClaudePermissionProjector) + as Arc) + .with(Arc::new(CodexPermissionProjector) + as Arc), + ); + let launch_agent = Arc::new( LaunchAgent::new( Arc::clone(&contexts_port), @@ -642,18 +679,24 @@ impl AppState { Arc::clone(&memory_recall_port), Some(Arc::clone(&check_embedder_suggestion)), ) + .with_permission_store(Arc::clone(&permission_store_port)) // Reprise conversationnelle (lot P7) : à chaque (re)lancement, si la cellule // porte une conversation et qu'un handoff existe (`/.ideai/conversations/`), // son résumé est réinjecté dans le convention file. Best-effort, additif : // un handoff absent/illisible ⇒ lancement normal sans section. - .with_handoff_provider(Arc::new(AppHandoffProvider) as Arc) + .with_handoff_provider( + Arc::new(AppHandoffProvider) as Arc + ) // Resumable moteur par provider (lot P8b) : après un lancement structuré // exposant un id de session moteur, rangé sous la clé de paire dans // `/.ideai/conversations/providers.json`. Best-effort, additif : une // écriture en échec / pas d'id moteur ⇒ lancement normal, aucune écriture. - .with_provider_session_provider( - Arc::new(AppProviderSessionProvider) as Arc, - ), + .with_provider_session_provider(Arc::new(AppProviderSessionProvider) + as Arc) + // Projection des permissions au (re)lancement (lot LP3-5) : avec le + // permission store câblé ci-dessus, `resolve` a une source ⇒ le projecteur + // du profil matérialise la config de permission de la CLI dans le run dir. + .with_permission_projectors(Arc::clone(&permission_projectors)), ); // Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/ @@ -671,7 +714,10 @@ impl AppState { Arc::clone(&launch_agent), Arc::clone(&events_port), ) - .with_structured(Arc::clone(&structured_sessions)), + .with_structured(Arc::clone(&structured_sessions)) + // Même registre que `LaunchAgent` (lot LP3-5) : au swap cross-profile, on + // nettoie les fichiers `Replace` orphelins de l'ancien profil avant relance. + .with_permission_projectors(Arc::clone(&permission_projectors)), ); // Read-only inventory of resumable agent cells (§15.2). Reuses the shared @@ -702,6 +748,18 @@ impl AppState { )); let project_store = Arc::clone(&store_port); + let get_project_permissions = Arc::new(GetProjectPermissions::new(Arc::clone( + &permission_store_port, + ))); + let update_project_permissions = Arc::new(UpdateProjectPermissions::new(Arc::clone( + &permission_store_port, + ))); + let update_agent_permissions = Arc::new(UpdateAgentPermissions::new(Arc::clone( + &permission_store_port, + ))); + let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone( + &permission_store_port, + ))); // --- Template store + use cases (L7) --- let template_store = Arc::new(FsTemplateStore::new( @@ -821,9 +879,8 @@ impl AppState { // cible (écriture sérialisée, une seule voie d'entrée). Le mailbox concret est // partagé pour `resolve`/`resolve_ticket`/`cancel_head` côté orchestrateur. let inmemory_mailbox = Arc::new(InMemoryMailbox::new()); - let mailbox = - Arc::clone(&inmemory_mailbox) as Arc; - let input_mediator = Arc::new( + let mailbox = Arc::clone(&inmemory_mailbox) as Arc; + let mediated_inbox = Arc::new( MediatedInbox::with_pty( Arc::clone(&inmemory_mailbox), Arc::new(SystemMillisClock), @@ -832,11 +889,31 @@ impl AppState { // Émet `AgentBusyChanged` à la source (Busy à l'enqueue qui démarre un // tour, Idle au mark_idle) ⇒ relayé au front en event Tauri (cadrage C4). .with_events(Arc::clone(&events_port)), - ) as Arc; + ); + // Détection de stagnation (lot 2, readiness/heartbeat) : une tâche périodique + // détenue au composition root appelle `sweep_stalled` (logique de décision pure, + // `now` fourni par l'horloge injectée). Bascule `Alive→Stalled` les agents `Busy` + // sans battement depuis `stall_after_ms` (profil) et émet `AgentLivenessChanged` + // une fois par transition. Tick d'1 s : largement assez fin pour des seuils en + // dizaines de secondes, négligeable en charge. Détaché ⇒ vit autant que l'app. + { + let sweeper = Arc::clone(&mediated_inbox); + // `build` runs in Tauri's `setup` hook (main thread, *no* ambient Tokio + // runtime) — `tokio::spawn` would panic with "there is no reactor running". + // Use Tauri's global async runtime, like `events::spawn_relay` does. + tauri::async_runtime::spawn(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(1)); + loop { + tick.tick().await; + sweeper.sweep_stalled(); + } + }); + } + let input_mediator = Arc::clone(&mediated_inbox) as Arc; // Registre des conversations par paire (cadrage C3) : un fil par paire, session // vivante keyée par conversation (lève l'ambiguïté session/agent). - let conversation_registry = - Arc::new(InMemoryConversationRegistry::new()) as Arc; + let conversation_registry = Arc::new(InMemoryConversationRegistry::new()) + as Arc; let orchestrator_service = Arc::new( OrchestratorService::new( Arc::clone(&create_agent), @@ -867,6 +944,18 @@ impl AppState { Arc::new(AppRecordTurnProvider) as Arc, Arc::clone(&clock) as Arc, ), + // NB (régression corrigée) : on ne câble PAS `.with_structured(...)` ici. + // Décision produit lot B-2 (« Option 1 Terminal + MCP », cf. construction + // de `LaunchAgent` plus haut) : la fabrique structurée est décâblée, donc + // AUCUN agent n'a de session structurée vivante — tous tournent en PTY brut + // et la délégation passe par les outils MCP (`idea_ask_agent`/`idea_reply`). + // Si l'orchestrateur recevait `with_structured`, `ask_agent` emprunterait la + // branche structurée (`ensure_structured_session`) qui ne peut jamais aboutir + // (le launcher ne crée plus de session structurée) ⇒ erreur systématique + // « aucune session structurée vivante après lancement ». On laisse donc + // `self.structured = None` pour que `ask_agent` retombe sur le chemin PTY+MCP + // fonctionnel. `drain_with_readiness` (readiness/heartbeat lot 1) reste dormant + // tant que la voie structurée n'est pas réactivée au composition root. ); // --- Windows (L10) --- @@ -920,6 +1009,10 @@ impl AppState { list_resumable_agents, inspect_conversation, project_store, + get_project_permissions, + update_project_permissions, + update_agent_permissions, + resolve_agent_permissions, create_template, update_template, list_templates, @@ -1032,9 +1125,20 @@ impl AppState { // The project-id string the handshake guard compares against: the same // hyphen-free hex form the endpoint encodes, which M5d's `--project` reuses. let project_id = project.id.as_uuid().simple().to_string(); + // Readiness de démarrage : quand le pont MCP d'un agent se connecte (initialize), + // libère son éventuel 1er tour différé (fix race cold-launch via signal MCP). + // L'id arrive en hex (handshake `requester`) ⇒ on le parse en AgentId ici (la + // composition root est la seule à connaître la frontière infra↔domaine). + let service_for_ready = Arc::clone(&self.orchestrator_service); + let ready_sink: Arc = Arc::new(move |requester: &str| { + if let Ok(uuid) = Uuid::parse_str(requester) { + service_for_ready.release_agent_cold_start(AgentId::from_uuid(uuid)); + } + }); let handle = McpServerHandle::start( McpServer::new(Arc::clone(&self.orchestrator_service), project.clone()) - .with_events(events), + .with_events(events) + .with_ready_sink(ready_sink), endpoint, listener, project_id, @@ -1056,6 +1160,16 @@ impl AppState { .unwrap_or_default() } + /// Repairs the persisted Claude run-dir artefacts of `project` so a freshly + /// launched AppImage does not inherit stale MCP/settings state from older runs. + /// + /// Best-effort and local-only: remote SSH/WSL projects are skipped because this + /// migration rewrites the local run-dir files the AppImage owns. Launch-time + /// repair still remains available on the normal activation path. + pub async fn reconcile_claude_run_dirs(&self, project: &Project) { + self.migrate_claude_run_dirs(project).await; + } + /// Stops and removes the orchestrator watcher for `project_id`, if any. /// Called from `close_project` so a closed project stops consuming requests. /// Symmetrically stops the project's MCP server (its twin) so both entry doors @@ -1078,6 +1192,671 @@ impl AppState { handle.stop(); } } + + /// Best-effort migration of persisted Claude run dirs at project-open time: + /// repairs stale `.mcp.json` declarations and missing + /// `enabledMcpjsonServers` entries in `.claude/settings.local.json`. + pub async fn migrate_claude_run_dirs(&self, project: &Project) { + if project.remote.kind() != RemoteKind::Local { + return; + } + let agents = match self + .list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await + { + Ok(output) => output.agents, + Err(_) => return, + }; + let profiles = match self.list_profiles.execute().await { + Ok(output) => output.profiles, + Err(_) => return, + }; + let profile_by_id: HashMap<_, _> = profiles.into_iter().map(|p| (p.id, p)).collect(); + for agent in agents { + let Some(profile) = profile_by_id.get(&agent.profile_id) else { + continue; + }; + // Dispatch par profil : Claude (`.mcp.json` + settings) ou Codex + // (`config.toml` isolé via `CODEX_HOME`). Tout autre profil ⇒ rien à migrer. + if is_claude_mcp_profile(profile) { + let _ = migrate_claude_run_dir(project, &agent.id, profile).await; + } else if is_codex_mcp_profile(profile) { + let _ = migrate_codex_run_dir(project, &agent.id, profile).await; + } + } + } +} + +async fn migrate_claude_run_dir( + project: &Project, + agent_id: &AgentId, + profile: &AgentProfile, +) -> Result<(), std::io::Error> { + let run_dir = project + .root + .as_str() + .trim_end_matches(['/', '\\']) + .to_owned() + + &format!("/.ideai/run/{agent_id}"); + let run_dir_path = Path::new(&run_dir); + if tokio::fs::metadata(run_dir_path).await.is_err() { + return Ok(()); + } + + migrate_claude_settings(run_dir_path, project.root.as_str()).await?; + + let runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { + exe, + endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) + .as_cli_arg() + .to_owned(), + project_id: project.id.as_uuid().simple().to_string(), + requester: agent_id.to_string(), + }); + migrate_claude_mcp_config(run_dir_path, profile, runtime.as_ref()).await?; + Ok(()) +} + +async fn migrate_claude_settings(run_dir: &Path, project_root: &str) -> Result<(), std::io::Error> { + let claude_dir = run_dir.join(".claude"); + let settings_path = claude_dir.join("settings.local.json"); + let next = match tokio::fs::read_to_string(&settings_path).await { + Ok(existing) => merge_claude_settings_json(&existing, project_root), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Some(claude_settings_seed_value(project_root)) + } + Err(err) => return Err(err), + }; + let Some(next) = next else { + return Ok(()); + }; + tokio::fs::create_dir_all(&claude_dir).await?; + let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?; + body.push('\n'); + write_atomically(&settings_path, &body) +} + +async fn migrate_claude_mcp_config( + run_dir: &Path, + profile: &AgentProfile, + runtime: Option<&McpRuntime>, +) -> Result<(), std::io::Error> { + let Some(target) = claude_mcp_config_target(profile) else { + return Ok(()); + }; + let Some(runtime) = runtime else { + return Ok(()); + }; + let mcp_path = run_dir.join(target); + let desired_server = mcp_server_entry(profile, Some(runtime)); + + let next = match tokio::fs::read_to_string(&mcp_path).await { + Ok(existing) => merge_mcp_json(&existing, desired_server), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Some(wrap_idea_mcp_server(desired_server)) + } + Err(err) => return Err(err), + }; + let Some(next) = next else { + return Ok(()); + }; + if let Some(parent) = mcp_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let mut body = serde_json::to_string_pretty(&next).map_err(std::io::Error::other)?; + body.push('\n'); + write_atomically(&mcp_path, &body) +} + +fn is_claude_mcp_profile(profile: &AgentProfile) -> bool { + let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude) + || matches!( + &profile.context_injection, + ContextInjection::ConventionFile { target } + if target + .rsplit(['/', '\\']) + .next() + .unwrap_or(target) + .eq_ignore_ascii_case("CLAUDE.md") + ); + is_claude && claude_mcp_config_target(profile).is_some() +} + +fn claude_mcp_config_target(profile: &AgentProfile) -> Option<&str> { + match profile.mcp.as_ref().map(|mcp| &mcp.config) { + Some(McpConfigStrategy::ConfigFile { target }) => Some(target.as_str()), + _ => None, + } +} + +/// Pendant Codex de [`migrate_claude_run_dir`] : répare le `config.toml` MCP isolé +/// du run dir (table `[mcp_servers.idea]`) pour que Codex, qui lit ses serveurs MCP +/// dans `$CODEX_HOME/config.toml`, voie les outils `idea_*` après un redémarrage. +/// `CODEX_HOME` est isolé au run dir au lancement (jamais le `~/.codex` global), donc +/// ce fichier appartient entièrement à IdeA : il est régénéré (clobber) dès qu'un +/// runtime réel est disponible. Best-effort, idempotent. +async fn migrate_codex_run_dir( + project: &Project, + agent_id: &AgentId, + profile: &AgentProfile, +) -> Result<(), std::io::Error> { + let run_dir = project + .root + .as_str() + .trim_end_matches(['/', '\\']) + .to_owned() + + &format!("/.ideai/run/{agent_id}"); + let run_dir_path = Path::new(&run_dir); + if tokio::fs::metadata(run_dir_path).await.is_err() { + return Ok(()); + } + + let runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { + exe, + endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) + .as_cli_arg() + .to_owned(), + project_id: project.id.as_uuid().simple().to_string(), + requester: agent_id.to_string(), + }); + migrate_codex_mcp_config(run_dir_path, profile, runtime.as_ref()).await +} + +async fn migrate_codex_mcp_config( + run_dir: &Path, + profile: &AgentProfile, + runtime: Option<&McpRuntime>, +) -> Result<(), std::io::Error> { + let Some((target, _home_env)) = codex_mcp_config_target(profile) else { + return Ok(()); + }; + // Sans runtime réel, seule une déclaration minimale est disponible : on ne + // régénère pas (mirror de `migrate_claude_mcp_config`). + let Some(runtime) = runtime else { + return Ok(()); + }; + let toml_path = run_dir.join(target); + let desired = mcp_server_entry_toml(profile, Some(runtime)); + + // `config.toml` isolé = entièrement géré par IdeA ⇒ régénération (clobber) ; + // idempotent (no-op si le contenu est déjà à jour). + match tokio::fs::read_to_string(&toml_path).await { + Ok(existing) if existing == desired => return Ok(()), + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + if let Some(parent) = toml_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + write_atomically(&toml_path, &desired) +} + +fn is_codex_mcp_profile(profile: &AgentProfile) -> bool { + let is_codex = profile.structured_adapter == Some(StructuredAdapter::Codex) + || matches!( + &profile.context_injection, + ContextInjection::ConventionFile { target } + if target + .rsplit(['/', '\\']) + .next() + .unwrap_or(target) + .eq_ignore_ascii_case("AGENTS.md") + ); + is_codex && codex_mcp_config_target(profile).is_some() +} + +fn codex_mcp_config_target(profile: &AgentProfile) -> Option<(&str, &str)> { + match profile.mcp.as_ref().map(|mcp| &mcp.config) { + Some(McpConfigStrategy::TomlConfigHome { target, home_env }) => { + Some((target.as_str(), home_env.as_str())) + } + _ => None, + } +} + +/// Rend le contenu du `config.toml` Codex (table `[mcp_servers.idea]`) en réutilisant +/// l'encodeur TOML partagé [`domain::McpServerWiring::to_config_toml`] (D2) — même +/// source de wiring que la déclaration `.mcp.json` Claude, donc zéro dérive. +fn mcp_server_entry_toml(profile: &AgentProfile, runtime: Option<&McpRuntime>) -> String { + let transport = profile + .mcp + .as_ref() + .map_or(McpTransport::Stdio, |m| m.transport); + let (command, args) = match runtime { + Some(rt) => ( + rt.exe.clone(), + vec![ + "mcp-server".to_owned(), + "--endpoint".to_owned(), + rt.endpoint.clone(), + "--project".to_owned(), + rt.project_id.clone(), + "--requester".to_owned(), + rt.requester.clone(), + ], + ), + None => ("idea".to_owned(), vec!["mcp-server".to_owned()]), + }; + domain::McpServerWiring::new(command, args, transport).to_config_toml() +} + +fn merge_claude_settings_json(existing: &str, project_root: &str) -> Option { + let mut doc = match serde_json::from_str::(existing) { + Ok(Value::Object(map)) => Value::Object(map), + Ok(_) | Err(_) => return Some(claude_settings_seed_value(project_root)), + }; + let before = doc.clone(); + let root = doc.as_object_mut().expect("object preserved above"); + let permissions = root + .entry("permissions".to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + if !permissions.is_object() { + *permissions = Value::Object(Map::new()); + } + let permissions = permissions + .as_object_mut() + .expect("permissions forced to object"); + permissions.insert( + "defaultMode".to_owned(), + Value::String("bypassPermissions".to_owned()), + ); + permissions.insert( + "additionalDirectories".to_owned(), + Value::Array(merge_string_array( + permissions.get("additionalDirectories"), + [project_root], + )), + ); + permissions.insert( + "allow".to_owned(), + Value::Array(merge_string_array( + permissions.get("allow"), + ["Read", "Edit", "Write", "Bash"], + )), + ); + permissions.insert( + "deny".to_owned(), + Value::Array(merge_string_array( + permissions.get("deny"), + [ + "Bash(sudo *)", + "Bash(rm -rf /)", + "Bash(rm -rf /*)", + "Bash(rm -rf ~)", + "Bash(rm -rf ~/)", + "Bash(rm -rf ~/*)", + "Bash(rm -rf $HOME*)", + "Bash(mkfs*)", + "Bash(dd if=*)", + "Bash(shutdown*)", + "Bash(reboot*)", + ], + )), + ); + root.insert( + "skipDangerousModePermissionPrompt".to_owned(), + Value::Bool(true), + ); + root.insert( + "enabledMcpjsonServers".to_owned(), + Value::Array(merge_string_array( + root.get("enabledMcpjsonServers"), + ["idea"], + )), + ); + let sandbox = root + .entry("sandbox".to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + if !sandbox.is_object() { + *sandbox = Value::Object(Map::new()); + } + let sandbox = sandbox.as_object_mut().expect("sandbox forced to object"); + sandbox.insert("enabled".to_owned(), Value::Bool(false)); + + if doc != before { + Some(doc) + } else { + None + } +} + +fn merge_mcp_json(existing: &str, desired_server: Value) -> Option { + let mut doc = match serde_json::from_str::(existing) { + Ok(Value::Object(map)) => map, + Ok(_) | Err(_) => return Some(wrap_idea_mcp_server(desired_server)), + }; + let mcp_servers = doc + .entry("mcpServers".to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + if !mcp_servers.is_object() { + *mcp_servers = Value::Object(Map::new()); + } + let changed = mcp_servers.get("idea") != Some(&desired_server); + if !changed { + return None; + } + match mcp_servers { + Value::Object(servers) => { + servers.insert("idea".to_owned(), desired_server); + Some(Value::Object(doc)) + } + _ => None, + } +} + +fn wrap_idea_mcp_server(server: Value) -> Value { + let mut servers = Map::new(); + servers.insert("idea".to_owned(), server); + let mut root = Map::new(); + root.insert("mcpServers".to_owned(), Value::Object(servers)); + Value::Object(root) +} + +fn mcp_server_entry(profile: &AgentProfile, runtime: Option<&McpRuntime>) -> Value { + let transport = match profile.mcp.as_ref().map(|mcp| mcp.transport) { + Some(McpTransport::Socket) => "socket", + Some(McpTransport::Stdio) | None => "stdio", + }; + let (command, args) = match runtime { + Some(rt) => ( + rt.exe.clone(), + vec![ + Value::String("mcp-server".to_owned()), + Value::String("--endpoint".to_owned()), + Value::String(rt.endpoint.clone()), + Value::String("--project".to_owned()), + Value::String(rt.project_id.clone()), + Value::String("--requester".to_owned()), + Value::String(rt.requester.clone()), + ], + ), + None => ( + "idea".to_owned(), + vec![Value::String("mcp-server".to_owned())], + ), + }; + let mut server = Map::new(); + server.insert("command".to_owned(), Value::String(command)); + server.insert("args".to_owned(), Value::Array(args)); + server.insert("transport".to_owned(), Value::String(transport.to_owned())); + Value::Object(server) +} + +fn claude_settings_seed_value(project_root: &str) -> Value { + json!({ + "permissions": { + "defaultMode": "bypassPermissions", + "additionalDirectories": [project_root], + "allow": ["Read", "Edit", "Write", "Bash"], + "deny": [ + "Bash(sudo *)", + "Bash(rm -rf /)", + "Bash(rm -rf /*)", + "Bash(rm -rf ~)", + "Bash(rm -rf ~/)", + "Bash(rm -rf ~/*)", + "Bash(rm -rf $HOME*)", + "Bash(mkfs*)", + "Bash(dd if=*)", + "Bash(shutdown*)", + "Bash(reboot*)" + ] + }, + "skipDangerousModePermissionPrompt": true, + "enabledMcpjsonServers": ["idea"], + "sandbox": { "enabled": false } + }) +} + +fn merge_string_array<'a>( + existing: Option<&Value>, + required: impl IntoIterator, +) -> Vec { + let mut seen = HashSet::::new(); + let mut out = Vec::new(); + + if let Some(existing) = existing.and_then(Value::as_array) { + for item in existing.iter().filter_map(Value::as_str) { + if seen.insert(item.to_owned()) { + out.push(Value::String(item.to_owned())); + } + } + } + + for item in required { + if seen.insert(item.to_owned()) { + out.push(Value::String(item.to_owned())); + } + } + + out +} + +fn write_atomically(path: &Path, content: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let file_name = path + .file_name() + .map(OsString::from) + .unwrap_or_else(|| OsString::from("tmp")); + let tmp_name = format!(".{}.{}.tmp", file_name.to_string_lossy(), Uuid::new_v4()); + let tmp_path = path.with_file_name(tmp_name); + + std::fs::write(&tmp_path, content.as_bytes())?; + #[cfg(windows)] + if path.exists() { + let _ = std::fs::remove_file(path); + } + std::fs::rename(&tmp_path, path)?; + Ok(()) +} + +#[cfg(test)] +mod run_dir_migration_tests { + use super::{ + claude_settings_seed_value, is_claude_mcp_profile, mcp_server_entry, + merge_claude_settings_json, merge_mcp_json, migrate_claude_run_dir, + }; + use application::McpRuntimeProvider; + use domain::ids::{AgentId, ProfileId, ProjectId}; + use domain::profile::{ + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, + StructuredAdapter, + }; + use serde_json::json; + use uuid::Uuid; + + fn claude_profile() -> AgentProfile { + AgentProfile::new( + ProfileId::from_uuid(Uuid::from_u128(9)), + "Claude Code", + "claude", + Vec::new(), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + None, + "{agentRunDir}", + None, + ) + .unwrap() + .with_structured_adapter(StructuredAdapter::Claude) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").unwrap(), + McpTransport::Stdio, + )) + } + + fn runtime(agent_id: AgentId) -> application::McpRuntime { + application::McpRuntime { + exe: "/opt/IdeA.AppImage".to_owned(), + endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(), + project_id: ProjectId::from_uuid(Uuid::from_u128(1234)) + .as_uuid() + .simple() + .to_string(), + requester: agent_id.to_string(), + } + } + + #[test] + fn merge_claude_settings_adds_idea_and_preserves_existing_entries() { + let merged = merge_claude_settings_json( + r#"{ + "permissions": { + "additionalDirectories": ["/tmp/custom"], + "allow": ["Read"], + "deny": ["Bash(custom)"] + }, + "enabledMcpjsonServers": ["other"], + "extra": true +}"#, + "/home/me/proj", + ) + .unwrap(); + + let parsed = merged; + assert_eq!(parsed["extra"], json!(true)); + assert_eq!( + parsed["permissions"]["defaultMode"], + json!("bypassPermissions") + ); + assert!(parsed["permissions"]["additionalDirectories"] + .as_array() + .unwrap() + .contains(&json!("/tmp/custom"))); + assert!(parsed["permissions"]["additionalDirectories"] + .as_array() + .unwrap() + .contains(&json!("/home/me/proj"))); + assert!(parsed["enabledMcpjsonServers"] + .as_array() + .unwrap() + .contains(&json!("other"))); + assert!(parsed["enabledMcpjsonServers"] + .as_array() + .unwrap() + .contains(&json!("idea"))); + } + + #[test] + fn merge_idea_mcp_json_rewrites_idea_and_preserves_other_servers() { + let agent_id = AgentId::from_uuid(Uuid::from_u128(77)); + let desired = mcp_server_entry(&claude_profile(), Some(&runtime(agent_id))); + let merged = merge_mcp_json( + r#"{ + "mcpServers": { + "idea": { "command": "idea", "args": ["mcp-server"], "transport": "stdio" }, + "other": { "command": "keep-me", "args": [] } + } +}"#, + desired.clone(), + ) + .unwrap(); + + let parsed = merged; + assert_eq!(parsed["mcpServers"]["other"]["command"], json!("keep-me")); + assert_eq!(parsed["mcpServers"]["idea"], desired); + } + + #[test] + fn reconcile_claude_run_dir_repairs_legacy_files_on_disk() { + let agent_id = AgentId::from_uuid(Uuid::from_u128(88)); + let temp = std::env::temp_dir().join(format!("idea-run-migrate-{}", Uuid::new_v4())); + let project_root = temp.join("project"); + let run_dir = project_root.join(".ideai/run").join(agent_id.to_string()); + std::fs::create_dir_all(run_dir.join(".claude")).unwrap(); + std::fs::write( + run_dir.join(".claude/settings.local.json"), + r#"{"permissions":{"additionalDirectories":["/tmp/old"]}}"#, + ) + .unwrap(); + std::fs::write( + run_dir.join(".mcp.json"), + r#"{"mcpServers":{"idea":{"command":"idea","args":["mcp-server"],"transport":"stdio"}}}"#, + ) + .unwrap(); + + let project = domain::Project::new( + ProjectId::from_uuid(Uuid::from_u128(1234)), + "demo", + domain::project::ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(), + domain::remote::RemoteRef::local(), + 1, + ) + .unwrap(); + let profile = claude_profile(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + migrate_claude_run_dir(&project, &agent_id, &profile) + .await + .unwrap(); + }); + + let settings: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(run_dir.join(".claude/settings.local.json")).unwrap(), + ) + .unwrap(); + assert!(settings["enabledMcpjsonServers"] + .as_array() + .unwrap() + .contains(&json!("idea"))); + assert!(settings["permissions"]["additionalDirectories"] + .as_array() + .unwrap() + .contains(&json!(project.root.as_str()))); + + let mcp: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(run_dir.join(".mcp.json")).unwrap()) + .unwrap(); + let expected_runtime = crate::mcp_endpoint::AppMcpRuntimeProvider + .runtime_for(&project, agent_id) + .unwrap(); + assert_eq!( + mcp["mcpServers"]["idea"], + mcp_server_entry(&claude_profile(), Some(&expected_runtime)) + ); + + let _ = std::fs::remove_dir_all(temp); + } + + #[test] + fn claude_profile_guard_matches_only_claude_mcp_profiles() { + assert!(is_claude_mcp_profile(&claude_profile())); + } + + #[test] + fn invalid_settings_fall_back_to_seed() { + let merged = merge_claude_settings_json("{not-json", "/home/me/proj").unwrap(); + assert_eq!(merged, claude_settings_seed_value("/home/me/proj")); + } + + #[test] + fn malformed_typed_settings_are_repaired_without_panicking() { + let merged = merge_claude_settings_json( + r#"{ + "permissions": [], + "sandbox": false, + "enabledMcpjsonServers": "idea" +}"#, + "/home/me/proj", + ) + .unwrap(); + + assert_eq!( + merged["permissions"]["defaultMode"], + json!("bypassPermissions") + ); + assert_eq!(merged["sandbox"]["enabled"], json!(false)); + assert_eq!(merged["enabledMcpjsonServers"], json!(["idea"])); + } } /// Binds the project's loopback endpoint listener (M5a). Best-effort: returns the @@ -1113,10 +1892,7 @@ fn bind_endpoint(endpoint: &McpEndpoint) -> Option { } } } - let name = endpoint - .as_cli_arg() - .to_fs_name::() - .ok()?; + let name = endpoint.as_cli_arg().to_fs_name::().ok()?; ListenerOptions::new() .name(name) // Reclaim (unlink) on drop so a clean close leaves no socket behind. @@ -1559,6 +2335,7 @@ mod mcp_serve_peer_tests { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } @@ -1808,7 +2585,10 @@ mod mcp_serve_peer_tests { .write_all(handshake_line(&project_id_arg(&proj), "agent-1").as_bytes()) .await .unwrap(); - client.write_all(tools_list_line(1).as_bytes()).await.unwrap(); + client + .write_all(tools_list_line(1).as_bytes()) + .await + .unwrap(); client.flush().await.unwrap(); let resp = tokio::time::timeout(TIMEOUT, read_one_response(&mut client)) @@ -1833,7 +2613,10 @@ mod mcp_serve_peer_tests { "idea_memory_read", "idea_memory_write", ] { - assert!(names.contains(&expected), "missing tool {expected}; got {names:?}"); + assert!( + names.contains(&expected), + "missing tool {expected}; got {names:?}" + ); } assert_eq!( tools.len(), @@ -1888,7 +2671,11 @@ mod mcp_serve_peer_tests { .iter() .filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. })) .collect(); - assert_eq!(processed.len(), 1, "exactly one processed event; got {events:?}"); + assert_eq!( + processed.len(), + 1, + "exactly one processed event; got {events:?}" + ); match processed[0] { DomainEvent::OrchestratorRequestProcessed { requester_id, @@ -1976,7 +2763,11 @@ mod mcp_serve_peer_tests { let resp = read_one_response(&mut client) .await .expect("the glued tools/list must still be served"); - assert_eq!(resp["id"], json!(7), "the buffered request id must come through"); + assert_eq!( + resp["id"], + json!(7), + "the buffered request id must come through" + ); assert!( resp["result"]["tools"].is_array(), "buffered request produced a real tools/list result; got {resp}" @@ -2053,7 +2844,10 @@ mod mcp_serve_peer_tests { .write_all(handshake_line("", "agent-1").as_bytes()) .await .unwrap(); - client.write_all(tools_list_line(1).as_bytes()).await.unwrap(); + client + .write_all(tools_list_line(1).as_bytes()) + .await + .unwrap(); client.flush().await.unwrap(); let resp = read_one_response(&mut client) @@ -2091,7 +2885,10 @@ mod mcp_serve_peer_tests { .write_all(handshake_line(&project_id_arg(&proj), "agent-b").as_bytes()) .await .unwrap(); - client_b.write_all(tools_list_line(2).as_bytes()).await.unwrap(); + client_b + .write_all(tools_list_line(2).as_bytes()) + .await + .unwrap(); client_b.flush().await.unwrap(); let resp = read_one_response(&mut client_b) @@ -2304,7 +3101,7 @@ mod mcp_e2e_loopback_tests { use async_trait::async_trait; use interprocess::local_socket::tokio::Stream as LocalSocketStream; use interprocess::local_socket::traits::tokio::Stream as _; - use tokio::io::{AsyncWriteExt, BufReader, AsyncBufReadExt}; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use uuid::Uuid; use application::{ @@ -2519,6 +3316,7 @@ mod mcp_e2e_loopback_tests { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } @@ -2683,8 +3481,90 @@ mod mcp_e2e_loopback_tests { Arc::new(SystemMillisClock), Arc::new(FakePty) as Arc, )) as Arc; - let conversations = - Arc::new(InMemoryConversationRegistry::new()) as Arc; + let conversations = Arc::new(InMemoryConversationRegistry::new()) + as Arc; + let service = OrchestratorService::new( + create, + launch, + list, + close, + update, + create_skill, + Arc::clone(&profiles) as Arc, + Arc::clone(&sessions), + ) + .with_input_mediator( + input, + Arc::clone(&mailbox) as Arc, + ) + .with_conversations(conversations); + (Arc::new(service), sessions, mailbox) + } + + /// Codex twin of [`build_service`]: identical wiring, but the single profile + /// targets the **Codex** structured adapter with a `TomlConfigHome` MCP strategy + /// (`$CODEX_HOME/config.toml`). This is the couple the bridge guard + /// (`materializes_idea_bridge`) must now let through — proving Codex is eligible + /// for `idea_ask_agent`, exactly like Claude. No real `codex` binary is ever + /// spawned: the runtime/PTY are the same in-memory fakes. + fn build_service_codex( + contexts: FakeContexts, + ) -> ( + Arc, + Arc, + Arc, + ) { + let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new( + ProfileId::from_uuid(Uuid::from_u128(9)), + "Codex CLI", + "codex", + Vec::new(), + ContextInjection::stdin(), + None, + "{agentRunDir}", + None, + ) + .unwrap() + .with_structured_adapter(StructuredAdapter::Codex) + .with_mcp(McpCapability::new( + McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME").unwrap(), + McpTransport::Stdio, + ))]))); + let sessions = Arc::new(TerminalSessions::new()); + let mailbox = Arc::new(InMemoryMailbox::new()); + let bus = Arc::new(NoopBus); + let create = Arc::new(CreateAgentFromScratch::new( + Arc::new(contexts.clone()), + Arc::new(SeqIds(Mutex::new(1))), + bus.clone(), + )); + let launch = Arc::new(LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::clone(&profiles) as Arc, + Arc::new(FakeRuntime), + Arc::new(FakeFs), + Arc::new(FakePty), + Arc::new(FakeSkills), + Arc::clone(&sessions), + bus.clone(), + Arc::new(SeqIds(Mutex::new(1))), + Arc::new(FakeRecall), + None, + )); + let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); + let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions))); + let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts))); + let create_skill = Arc::new(CreateSkill::new( + Arc::new(FakeSkills) as Arc, + Arc::new(SeqIds(Mutex::new(1))), + )); + let input = Arc::new(MediatedInbox::with_pty( + Arc::clone(&mailbox), + Arc::new(SystemMillisClock), + Arc::new(FakePty) as Arc, + )) as Arc; + let conversations = Arc::new(InMemoryConversationRegistry::new()) + as Arc; let service = OrchestratorService::new( create, launch, @@ -2743,12 +3623,8 @@ mod mcp_e2e_loopback_tests { if let Some(publish) = events { server = server.with_events(publish); } - let handle = McpServerHandle::start( - server, - endpoint.clone(), - listener, - project_id_arg(project), - ); + let handle = + McpServerHandle::start(server, endpoint.clone(), listener, project_id_arg(project)); (handle, endpoint) } @@ -2790,7 +3666,7 @@ mod mcp_e2e_loopback_tests { { let mut line = String::new(); match tokio::time::timeout(TIMEOUT, reader.read_line(&mut line)).await { - Ok(Ok(0)) => None, // EOF before a full line + Ok(Ok(0)) => None, // EOF before a full line Ok(Ok(_)) => serde_json::from_str(line.trim_end()).ok(), Ok(Err(_)) => None, Err(_) => None, // GARDE-FOU: timed out waiting for a reply @@ -2859,7 +3735,11 @@ mod mcp_e2e_loopback_tests { let agent_id = contexts.seed_agent("architect"); let (service, sessions, mailbox) = build_service(contexts); // Target already live in the PTY registry, so the ask reuses its terminal. - seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242))); + seed_live_pty( + &sessions, + agent_id, + SessionId::from_uuid(Uuid::from_u128(4242)), + ); let proj = project(); let (handle, endpoint) = start_real_server(service, &proj, None); @@ -2913,7 +3793,11 @@ mod mcp_e2e_loopback_tests { let reply_resp = read_one_response(&mut reader_b) .await .expect("a reply ack line"); - assert_eq!(reply_resp["result"]["isError"], json!(false), "got {reply_resp}"); + assert_eq!( + reply_resp["result"]["isError"], + json!(false), + "got {reply_resp}" + ); // The asker now receives its inline result. let resp = read_one_response(&mut reader_a) @@ -2934,6 +3818,101 @@ mod mcp_e2e_loopback_tests { handle.stop(); } + // ----------------------------------------------------------------------- + // 2b. Codex twin of the round-trip above: the *only* difference is the target + // profile (Codex + TomlConfigHome MCP). It proves the bridge guard now lets + // a Codex target through AND that the inline ask→reply loop still completes. + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn ask_then_reply_round_trips_inline_over_real_loopback_codex() { + let contexts = FakeContexts::new(); + let agent_id = contexts.seed_agent("architect"); + let (service, sessions, mailbox) = build_service_codex(contexts); + // Target already live in the PTY registry, so the ask reuses its terminal. + seed_live_pty( + &sessions, + agent_id, + SessionId::from_uuid(Uuid::from_u128(4242)), + ); + let proj = project(); + let (handle, endpoint) = start_real_server(service, &proj, None); + + // Connection A: the asker. + let conn_a = connect_client(&endpoint).await; + let (read_a, mut write_a) = tokio::io::split(conn_a); + let mut reader_a = BufReader::new(read_a); + write_a + .write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes()) + .await + .unwrap(); + write_a + .write_all( + tools_call_line( + 7, + "idea_ask_agent", + json!({ "target": "architect", "task": "What is the answer?" }), + ) + .as_bytes(), + ) + .await + .unwrap(); + write_a.flush().await.unwrap(); + + // The ask is now blocked awaiting the reply — observe the pending ticket. + tokio::time::timeout(TIMEOUT, async { + while mailbox.pending(&agent_id) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("ask must enqueue a ticket"); + + // Connection B: the target rendering its result. Its handshake requester is + // the target agent's id, which the server injects as the Reply `from`. + let conn_b = connect_client(&endpoint).await; + let (read_b, mut write_b) = tokio::io::split(conn_b); + let mut reader_b = BufReader::new(read_b); + write_b + .write_all(handshake_line(&project_id_arg(&proj), &agent_id.to_string()).as_bytes()) + .await + .unwrap(); + write_b + .write_all( + tools_call_line(8, "idea_reply", json!({ "result": "the answer is 42" })) + .as_bytes(), + ) + .await + .unwrap(); + write_b.flush().await.unwrap(); + let reply_resp = read_one_response(&mut reader_b) + .await + .expect("a reply ack line"); + assert_eq!( + reply_resp["result"]["isError"], + json!(false), + "got {reply_resp}" + ); + + // The asker now receives its inline result. + let resp = read_one_response(&mut reader_a) + .await + .expect("an ask response line"); + assert_eq!(resp["id"], json!(7)); + assert!(resp["error"].is_null(), "transport error: {resp}"); + let result = &resp["result"]; + assert_eq!(result["isError"], json!(false), "got {result}"); + assert_eq!( + result["content"][0]["text"].as_str().expect("text block"), + "the answer is 42", + "ask reply must be returned inline over the real loopback (Codex target); got {result}" + ); + + drop(write_a); + drop(write_b); + handle.stop(); + } + // ----------------------------------------------------------------------- // 3. idea_reply with no in-flight ask ⇒ typed tool error (isError:true), no // panic, connection stays healthy for a follow-up call. @@ -2958,9 +3937,7 @@ mod mcp_e2e_loopback_tests { .await .unwrap(); write_half - .write_all( - tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes(), - ) + .write_all(tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes()) .await .unwrap(); write_half.flush().await.unwrap(); @@ -3031,20 +4008,23 @@ mod mcp_e2e_loopback_tests { .await .unwrap(); // A malformed JSON-RPC line (not valid JSON) — must NOT crash the serve loop. - write_half - .write_all(b"{ this is not json\n") - .await - .unwrap(); + write_half.write_all(b"{ this is not json\n").await.unwrap(); write_half.flush().await.unwrap(); let resp = read_one_response(&mut reader) .await .expect("a parse error still owes a response"); let error = &resp["error"]; - assert!(!error.is_null(), "malformed input must yield a JSON-RPC error: {resp}"); + assert!( + !error.is_null(), + "malformed input must yield a JSON-RPC error: {resp}" + ); // JSON-RPC mandates a null id when the request could not be correlated. assert_eq!(resp["id"], Value::Null, "got {resp}"); - assert!(resp["result"].is_null(), "no result on parse error; got {resp}"); + assert!( + resp["result"].is_null(), + "no result on parse error; got {resp}" + ); // The server survived: a subsequent valid call still answers. write_half @@ -3200,7 +4180,9 @@ mod bind_endpoint_d1_tests { use std::os::unix::net::UnixListener; let ep = mcp_endpoint(&ProjectId::from_uuid(Uuid::from_u128(0xD1_0001))); - let path = ep.socket_path().expect("unix endpoint exposes a socket path"); + let path = ep + .socket_path() + .expect("unix endpoint exposes a socket path"); // Clean any leftover from a previous run of this very test. let _ = std::fs::remove_file(&path); @@ -3214,7 +4196,10 @@ mod bind_endpoint_d1_tests { let corpse = UnixListener::bind(&path).expect("lay corpse socket"); drop(corpse); } - assert!(path.exists(), "corpse socket inode remains (no live listener)"); + assert!( + path.exists(), + "corpse socket inode remains (no live listener)" + ); // 2) Rebind over the corpse: must succeed (reclaim unlinks then binds), NOT // fail with EADDRINUSE. @@ -3239,4 +4224,3 @@ mod bind_endpoint_d1_tests { ); } } - diff --git a/crates/app-tauri/tests/chat_bridge.rs b/crates/app-tauri/tests/chat_bridge.rs index ecdc4df..88f8667 100644 --- a/crates/app-tauri/tests/chat_bridge.rs +++ b/crates/app-tauri/tests/chat_bridge.rs @@ -57,7 +57,7 @@ fn final_chunk(s: &str) -> ReplyChunk { fn chunk_from_event_maps_text_delta() { assert_eq!( chunk_from_event(ReplyEvent::TextDelta { text: "hi".into() }), - ReplyChunk::TextDelta { text: "hi".into() } + Some(ReplyChunk::TextDelta { text: "hi".into() }) ); } @@ -67,9 +67,9 @@ fn chunk_from_event_maps_tool_activity() { chunk_from_event(ReplyEvent::ToolActivity { label: "reads file".into() }), - ReplyChunk::ToolActivity { + Some(ReplyChunk::ToolActivity { label: "reads file".into() - } + }) ); } @@ -79,12 +79,19 @@ fn chunk_from_event_maps_final() { chunk_from_event(ReplyEvent::Final { content: "done".into() }), - ReplyChunk::Final { + Some(ReplyChunk::Final { content: "done".into() - } + }) ); } +#[test] +fn chunk_from_event_drops_heartbeat() { + // A heartbeat is a non-terminal liveness proof with no chat content (lot 1) ⇒ no + // wire chunk; the pump skips it while still draining to the Final. + assert_eq!(chunk_from_event(ReplyEvent::Heartbeat), None); +} + // --------------------------------------------------------------------------- // agent_send pump behaviour: deltas* then exactly one Final (zone 2) // --------------------------------------------------------------------------- @@ -94,7 +101,10 @@ fn chunk_from_event_maps_final() { /// end. This is the body of the spawned pump thread, run inline. fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec) { for event in events { - let _ = bridge.send_output(session, chunk_from_event(event)); + // Mirror the real pump: heartbeats map to no chunk and are skipped. + if let Some(chunk) = chunk_from_event(event) { + let _ = bridge.send_output(session, chunk); + } } bridge.detach_if(session, gen); } @@ -126,7 +136,10 @@ fn pump_delivers_deltas_then_exactly_one_final_in_order() { "deltas in order, then the single Final" ); // Exactly one Final, and it is last (deterministic terminal chunk). - let finals = got.iter().filter(|c| matches!(c, ReplyChunk::Final { .. })).count(); + let finals = got + .iter() + .filter(|c| matches!(c, ReplyChunk::Final { .. })) + .count(); assert_eq!(finals, 1, "exactly one Final per turn"); assert!(matches!(got.last(), Some(ReplyChunk::Final { .. }))); } @@ -227,8 +240,12 @@ fn scrollback_accumulates_every_routed_chunk_in_order() { gen, vec![ ReplyEvent::TextDelta { text: "x".into() }, - ReplyEvent::ToolActivity { label: "runs".into() }, - ReplyEvent::Final { content: "x".into() }, + ReplyEvent::ToolActivity { + label: "runs".into(), + }, + ReplyEvent::Final { + content: "x".into(), + }, ], ); @@ -236,7 +253,9 @@ fn scrollback_accumulates_every_routed_chunk_in_order() { bridge.scrollback(&session), vec![ delta("x"), - ReplyChunk::ToolActivity { label: "runs".into() }, + ReplyChunk::ToolActivity { + label: "runs".into() + }, final_chunk("x"), ], "scrollback retains the full conversation, in order" diff --git a/crates/app-tauri/tests/dto.rs b/crates/app-tauri/tests/dto.rs index c1cb705..d925037 100644 --- a/crates/app-tauri/tests/dto.rs +++ b/crates/app-tauri/tests/dto.rs @@ -148,7 +148,8 @@ fn orchestration_source_dto_serialises_lowercase() { use domain::events::OrchestrationSource; // File → "file"; Mcp → "mcp" (camelCase rename on a unit enum = lowercase). - let file = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::File)).unwrap(); + let file = + serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::File)).unwrap(); assert_eq!(file, json!("file")); let mcp = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::Mcp)).unwrap(); diff --git a/crates/app-tauri/tests/dto_chat.rs b/crates/app-tauri/tests/dto_chat.rs index 6650238..4297333 100644 --- a/crates/app-tauri/tests/dto_chat.rs +++ b/crates/app-tauri/tests/dto_chat.rs @@ -8,9 +8,9 @@ use app_tauri_lib::dto::{CellKind, ReattachChatDto, ReplyChunk, TerminalSessionDto}; use application::{LaunchAgentOutput, StructuredSessionDescriptor}; -use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession}; use domain::project::ProjectPath; use domain::SessionId; +use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession}; use serde_json::json; use uuid::Uuid; @@ -49,8 +49,12 @@ fn reply_chunk_final_serialises_exact_camel_case() { fn reply_chunk_round_trips_through_json_for_every_variant() { for chunk in [ ReplyChunk::TextDelta { text: "x".into() }, - ReplyChunk::ToolActivity { label: "runs".into() }, - ReplyChunk::Final { content: "y".into() }, + ReplyChunk::ToolActivity { + label: "runs".into(), + }, + ReplyChunk::Final { + content: "y".into(), + }, ] { let v = serde_json::to_value(&chunk).unwrap(); let back: ReplyChunk = serde_json::from_value(v).unwrap(); @@ -84,7 +88,9 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() { session_id: "sess-1".into(), scrollback: vec![ ReplyChunk::TextDelta { text: "Hi".into() }, - ReplyChunk::Final { content: "Hi".into() }, + ReplyChunk::Final { + content: "Hi".into(), + }, ], }; let v = serde_json::to_value(&dto).unwrap(); diff --git a/crates/app-tauri/tests/list_live_agents_r0b.rs b/crates/app-tauri/tests/list_live_agents_r0b.rs index aa95222..57dc070 100644 --- a/crates/app-tauri/tests/list_live_agents_r0b.rs +++ b/crates/app-tauri/tests/list_live_agents_r0b.rs @@ -16,9 +16,7 @@ use async_trait::async_trait; use app_tauri_lib::dto::LiveAgentListDto; use application::{LiveSessions, StructuredSessions, TerminalSessions}; use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream}; -use domain::{ - AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession, -}; +use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession}; use uuid::Uuid; // --- petits constructeurs déterministes ------------------------------------ @@ -160,7 +158,11 @@ fn same_agent_in_both_registries_is_deduplicated() { structured.insert(fake(sid(2)), a, nid(200)); let dto = dto_for(&pty, &structured); - assert_eq!(dto.0.len(), 1, "un même agent ne doit apparaître qu'une fois"); + assert_eq!( + dto.0.len(), + 1, + "un même agent ne doit apparaître qu'une fois" + ); assert_eq!(dto.0[0].agent_id, a.to_string()); // On garde la première occurrence (PTY, listé en premier par l'agrégateur). assert_eq!(dto.0[0].node_id, nid(100).to_string()); diff --git a/crates/app-tauri/tests/orchestrator_wiring.rs b/crates/app-tauri/tests/orchestrator_wiring.rs index bbc871a..4bcf510 100644 --- a/crates/app-tauri/tests/orchestrator_wiring.rs +++ b/crates/app-tauri/tests/orchestrator_wiring.rs @@ -164,10 +164,7 @@ async fn stop_watch_unregisters_the_mcp_server() { assert!(has_mcp(&state, &project.id)); state.stop_orchestrator_watch(&project.id); - assert!( - !has_mcp(&state, &project.id), - "MCP server removed on close" - ); + assert!(!has_mcp(&state, &project.id), "MCP server removed on close"); assert_eq!(mcp_count(&state), 0); // Stopping an unknown project is a no-op (does not panic) for the MCP twin too. @@ -274,7 +271,10 @@ async fn double_open_keeps_a_single_endpoint_no_address_in_use() { // socket) — it returns early. One endpoint, still bound, no panic. state.ensure_orchestrator_watch(&project); assert_eq!(mcp_count(&state), 1, "one endpoint per project"); - assert!(socket_exists(&project), "endpoint still bound after re-open"); + assert!( + socket_exists(&project), + "endpoint still bound after re-open" + ); state.stop_orchestrator_watch(&project.id); } diff --git a/crates/application/src/agent/catalogue.rs b/crates/application/src/agent/catalogue.rs index 84b5e53..f01d6e3 100644 --- a/crates/application/src/agent/catalogue.rs +++ b/crates/application/src/agent/catalogue.rs @@ -18,8 +18,10 @@ //! making the reference profiles addressable across runs without a registry. use domain::ids::ProfileId; +use domain::permission::ProjectorKey; use domain::profile::{ - AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, StructuredAdapter, + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, + StructuredAdapter, }; /// A fixed UUID namespace used to derive stable ids for reference profiles. @@ -60,6 +62,7 @@ pub fn reference_profiles() -> Vec { ) .expect("claude reference profile is valid") .with_structured_adapter(StructuredAdapter::Claude) + .with_projector(ProjectorKey::Claude) .with_mcp(McpCapability::new( McpConfigStrategy::config_file(".mcp.json") .expect(".mcp.json is a valid relative MCP config target"), @@ -78,9 +81,13 @@ pub fn reference_profiles() -> Vec { ) .expect("codex reference profile is valid") .with_structured_adapter(StructuredAdapter::Codex) + .with_projector(ProjectorKey::Codex) .with_mcp(McpCapability::new( - McpConfigStrategy::config_file(".mcp.json") - .expect(".mcp.json is a valid relative MCP config target"), + // Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` : + // IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour + // isoler l'agent du `~/.codex` global (miroir du `.mcp.json` de Claude). + McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME") + .expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"), McpTransport::Stdio, )), AgentProfile::new( @@ -156,16 +163,36 @@ mod mcp_tests { } #[test] - fn claude_and_codex_mcp_use_config_file_mcp_json() { - for slug in ["claude", "codex"] { - let mcp = profile(slug).mcp.expect("mcp present"); - assert_eq!( - mcp.config, - McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() }, - "profile `{slug}` should declare `.mcp.json`" - ); - assert_eq!(mcp.transport, McpTransport::Stdio); - } + fn claude_mcp_uses_config_file_mcp_json() { + let mcp = profile("claude").mcp.expect("mcp present"); + assert_eq!( + mcp.config, + McpConfigStrategy::ConfigFile { + target: ".mcp.json".to_owned() + }, + "Claude should declare `.mcp.json`" + ); + assert_eq!(mcp.transport, McpTransport::Stdio); + } + + #[test] + fn codex_mcp_uses_toml_config_home_codex() { + // Codex lit `$CODEX_HOME/config.toml`, pas `.mcp.json` : le seed doit déclarer + // la stratégie TOML isolée par `CODEX_HOME` (pendant Codex de Claude). + let mcp = profile("codex").mcp.expect("mcp present"); + assert_eq!( + mcp.config, + McpConfigStrategy::TomlConfigHome { + target: ".codex/config.toml".to_owned(), + home_env: "CODEX_HOME".to_owned(), + }, + "Codex should declare `.codex/config.toml` + CODEX_HOME" + ); + assert_eq!(mcp.transport, McpTransport::Stdio); + assert!( + profile("codex").materializes_idea_bridge(), + "the Codex seed must materialise the idea bridge" + ); } #[test] @@ -177,4 +204,30 @@ mod mcp_tests { ); } } + + // -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ---------- + + #[test] + fn claude_and_codex_seed_their_projector_key() { + assert_eq!( + profile("claude").projector, + Some(ProjectorKey::Claude), + "the Claude seed must pose the Claude projector" + ); + assert_eq!( + profile("codex").projector, + Some(ProjectorKey::Codex), + "the Codex seed must pose the Codex projector" + ); + } + + #[test] + fn gemini_and_aider_have_no_projector() { + for slug in ["gemini", "aider"] { + assert!( + profile(slug).projector.is_none(), + "non-structured profile `{slug}` must NOT carry a projector (native prompting)" + ); + } + } } diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index c81a64b..4e8bf29 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -11,19 +11,24 @@ //! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about //! a concrete adapter or Tauri. +use std::collections::HashMap; use std::sync::Arc; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus, - FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, - ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError, + FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext, + ProfileStore, ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, + StoreError, }; +use domain::profile::{McpConfigStrategy, StructuredAdapter}; use domain::{ Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId, - ConversationParty, DomainEvent, Handoff, HandoffStore, ManifestEntry, MarkdownDoc, - MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, ProjectPath, ProviderSessionStore, + ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry, + MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId, + ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession, }; +use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan}; use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; @@ -367,6 +372,13 @@ pub struct ChangeAgentProfile { /// lieu de tuer un PTY. Injecté au câblage via [`Self::with_structured`] ; `None` /// ⇒ seul le registre PTY est consulté (mode legacy / tests existants). structured: Option>, + /// Registre des **projecteurs de permissions** (lot LP3-4), pour nettoyer au swap + /// les fichiers `Replace` orphelins possédés par l'ANCIEN profil que le nouveau ne + /// réécrira pas. Injecté via [`Self::with_permission_projectors`] (le câblage passe + /// le **même** `Arc` que celui donné au `LaunchAgent` interne). `None` ⇒ aucun + /// nettoyage (comportement legacy ; la re-projection du nouveau profil, elle, reste + /// assurée par le `LaunchAgent` composé). + projectors: Option>, } impl ChangeAgentProfile { @@ -393,6 +405,7 @@ impl ChangeAgentProfile { launch, events, structured: None, + projectors: None, } } @@ -406,6 +419,20 @@ impl ChangeAgentProfile { self } + /// Branche le **registre de projecteurs de permissions** (lot LP3-4) pour le + /// nettoyage des fichiers orphelins au swap cross-profile. Le câblage passe le + /// **même** `Arc` que celui injecté au `LaunchAgent` + /// interne (source unique de vérité). Builder additif : signature de [`Self::new`] + /// inchangée ; `None` ⇒ pas de nettoyage (legacy). + #[must_use] + pub fn with_permission_projectors( + mut self, + projectors: Arc, + ) -> Self { + self.projectors = Some(projectors); + self + } + /// Executes the hot-swap, following the 7-step algorithm of §15.1. /// /// # Errors @@ -425,6 +452,10 @@ impl ChangeAgentProfile { .find(|e| e.agent_id == input.agent_id) .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; + // Capture the PREVIOUS profile id BEFORE mutation (lot LP3-4): it identifies + // the projector whose now-orphan `Replace` files must be cleaned up at the swap. + let previous_profile_id = entry.profile_id; + // 2. Same profile ⇒ no-op: return the agent unchanged, no kill/relaunch, // no event. if entry.profile_id == input.profile_id { @@ -438,15 +469,16 @@ impl ChangeAgentProfile { } // 3. Validate that the target profile is a known one (ProfileStore.list). - let known = self - .profiles - .list() - .await? - .into_iter() - .any(|p| p.id == input.profile_id); - if !known { + // Capture both the NEW profile (validation) and the PREVIOUS profile (for + // the LP3-4 cleanup; absent if it was deleted ⇒ cleanup is skipped). + let profiles = self.profiles.list().await?; + let Some(new_profile) = profiles.iter().find(|p| p.id == input.profile_id).cloned() else { return Err(AppError::NotFound(format!("profile {}", input.profile_id))); - } + }; + let previous_profile = profiles + .iter() + .find(|p| p.id == previous_profile_id) + .cloned(); // 4. Mutate the entry (new profile), re-validate (to_agent + manifest) // and persist. @@ -461,8 +493,8 @@ impl ChangeAgentProfile { mutated_agent = Some(agent); } } - let agent = mutated_agent - .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; + let agent = + mutated_agent.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; let manifest = AgentManifest::new(manifest.version, entries) .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts @@ -478,6 +510,20 @@ impl ChangeAgentProfile { .invalidate_engine_link(&input.project, &input.agent_id) .await?; + // 5b. (lot LP3-4) Clean up the PREVIOUS profile's now-orphan permission files + // in the agent's (stable) run dir, BEFORE the relaunch re-projects the new + // profile — so we never delete a file the new profile is about to write. + // Only `Replace`-owned paths of the old projector that the new projector + // does NOT also own are removed (best-effort). `MergeToml` files are never + // touched. No-op without a projector registry. + self.cleanup_swapped_out_files( + &input.project.root, + &input.agent_id, + previous_profile.as_ref(), + &new_profile, + ) + .await; + // 6. A live session? Kill its PTY then relaunch in the same cell with the // new profile, carrying the **preserved** pair id (so the handoff is // re-injected and resume routes via providers.json[new provider]). @@ -524,8 +570,10 @@ impl ChangeAgentProfile { // Capture the preserved pair id (stable across all leaves of this // agent) — used to relaunch with handoff re-injection (P7). if pair_id.is_none() { - if let Some(cid) = - named.tree.leaf(leaf_id).and_then(|l| l.conversation_id.clone()) + if let Some(cid) = named + .tree + .leaf(leaf_id) + .and_then(|l| l.conversation_id.clone()) { pair_id = Some(cid); } @@ -553,6 +601,66 @@ impl ChangeAgentProfile { Ok(pair_id) } + /// Removes the **previous** profile's now-orphan permission files at the swap + /// (lot LP3-4), best-effort. Because the run dir is **stable per agent id** + /// (`agent_run_dir`), the old CLI's config (e.g. `.claude/settings.local.json`) + /// survives a swap in the very same directory; left in place it is stale. + /// + /// Cleanup rule (Architect-validated): delete exactly + /// `owned_replace_paths(old) − owned_replace_paths(new)` — only the + /// **`Replace`-owned** files of the old projector that the new projector will not + /// re-own (and thus re-write). `MergeToml` (co-owned, e.g. `.codex/config.toml`) + /// files are **never** deleted. Examples: Claude→Codex removes + /// `.claude/settings.local.json`; Claude→Claude removes nothing (empty diff); + /// Codex→Claude removes nothing on the Replace side (Codex owns no Replace file). + /// + /// No-op when: no registry is wired, the previous profile is unknown (deleted), + /// or it maps to no projector. Every delete is best-effort (`remove_file` is + /// idempotent), so a missing file never fails the swap. This does **not** touch + /// the stable pair id or the handoff (P8d) — it only removes engine config files. + async fn cleanup_swapped_out_files( + &self, + project_root: &ProjectPath, + agent_id: &AgentId, + previous_profile: Option<&AgentProfile>, + new_profile: &AgentProfile, + ) { + let Some(registry) = &self.projectors else { + return; + }; + let Some(previous_profile) = previous_profile else { + return; + }; + let Some(old_key) = select_projector_key(previous_profile) else { + return; + }; + let Some(old_projector) = registry.get(old_key) else { + return; + }; + let old_owned = old_projector.owned_replace_paths(); + if old_owned.is_empty() { + return; + } + // Paths the NEW projector will re-own (and re-write) ⇒ never delete these. + let kept: Vec = select_projector_key(new_profile) + .and_then(|key| registry.get(key)) + .map(|p| p.owned_replace_paths()) + .unwrap_or_default(); + + let run_dir = match agent_run_dir(project_root, agent_id) { + Ok(dir) => dir, + Err(_) => return, + }; + for rel in old_owned { + if kept.iter().any(|k| k == &rel) { + continue; + } + let path = RemotePath::new(join(&run_dir, &rel)); + // Best-effort: a missing file / delete failure must never fail the swap. + let _ = self.fs.remove_file(&path).await; + } + } + /// Kills the agent's live PTY (if any) and relaunches it in the same cell with /// the new profile (step 6), composing [`LaunchAgent::execute`]. Returns the /// relaunched session, or `None` when the agent had no live session. @@ -830,6 +938,81 @@ pub struct LaunchAgentOutput { pub structured: Option, } +/// Registry mapping each [`ProjectorKey`] to its concrete +/// [`PermissionProjector`] (lot LP3-3). Injected into [`LaunchAgent`] via the +/// optional builder [`LaunchAgent::with_permission_projectors`]; the concrete +/// projectors (Claude / Codex) live in `infrastructure` and are inserted at the +/// composition root (lot LP3-5). Absent registry ⇒ no projection (legacy +/// behaviour preserved). +#[derive(Default, Clone)] +pub struct PermissionProjectorRegistry { + by_key: HashMap>, +} + +impl PermissionProjectorRegistry { + /// An empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Registers a projector under its own [`PermissionProjector::key`] and returns + /// `self` (builder style), so a registry can be assembled fluently. + #[must_use] + pub fn with(mut self, projector: Arc) -> Self { + self.insert(projector); + self + } + + /// Registers (or replaces) a projector under its own key. + pub fn insert(&mut self, projector: Arc) { + self.by_key.insert(projector.key(), projector); + } + + /// Returns the projector registered for `key`, if any. + #[must_use] + pub fn get(&self, key: ProjectorKey) -> Option<&Arc> { + self.by_key.get(&key) + } +} + +/// Selects the [`ProjectorKey`] for a launched agent's profile (lot LP3-3). +/// +/// Primary source: the profile's explicit `projector` field. **Migration +/// fallback** for legacy profiles (e.g. an old `profiles.json` written before the +/// field existed): the historical heuristic — a `CLAUDE.md` convention file ⇒ +/// Claude; a `StructuredAdapter::Codex` or a `TomlConfigHome` MCP strategy ⇒ +/// Codex. Anything else ⇒ `None` (no projection). +fn select_projector_key(profile: &AgentProfile) -> Option { + if let Some(key) = profile.projector { + return Some(key); + } + // Legacy fallback: Claude is recognised by its `CLAUDE.md` convention file. + let is_claude = matches!( + &profile.context_injection, + ContextInjection::ConventionFile { target } + if target + .rsplit(['/', '\\']) + .next() + .unwrap_or(target) + .eq_ignore_ascii_case("CLAUDE.md") + ); + if is_claude { + return Some(ProjectorKey::Claude); + } + // Legacy fallback: Codex is recognised by its structured adapter or its + // CODEX_HOME-isolated TOML MCP strategy. + if matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) { + return Some(ProjectorKey::Codex); + } + if let Some(mcp) = &profile.mcp { + if matches!(mcp.config, McpConfigStrategy::TomlConfigHome { .. }) { + return Some(ProjectorKey::Codex); + } + } + None +} + /// Launches an agent: resolve profile + context, prepare the invocation, apply /// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI. /// @@ -857,6 +1040,10 @@ pub struct LaunchAgent { /// reads the project memory. `None` keeps the launcher independent of it (legacy /// wiring / tests). A failure here never affects the launch. embedder_suggestion: Option>, + /// Optional permissions store (LP1). When absent, the launcher keeps its + /// historical hardcoded CLI permission seeds. When present and a project or + /// agent policy is configured, the resolved policy is projected to the CLI. + permissions: Option>, /// Fabrique des sessions **structurées** (IA, §17). Injectée au câblage /// (composition root) via [`Self::with_structured`]. `None` ⇒ le routage §17.4 /// est désactivé et **tout** profil suit le chemin PTY historique (mode legacy / @@ -880,6 +1067,10 @@ pub struct LaunchAgent { /// régression pour les call sites/tests legacy). Une écriture en échec ⇒ lancement /// normal, jamais d'échec. provider_sessions: Option>, + /// Registre des **projecteurs de permissions** par CLI (lot LP3-3). Injecté au + /// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection + /// (comportement historique préservé pour les call sites/tests legacy). + projectors: Option>, } impl LaunchAgent { @@ -911,13 +1102,36 @@ impl LaunchAgent { ids, recall, embedder_suggestion, + permissions: None, session_factory: None, structured: None, handoffs: None, provider_sessions: None, + projectors: None, } } + /// Injects the per-CLI permission **projector registry** (lot LP3-3) used to + /// translate the resolved [`EffectivePermissions`] into the launched CLI's + /// concrete permission config. Without this call (legacy call sites / tests), + /// no projection happens — signature of [`Self::new`] unchanged. + #[must_use] + pub fn with_permission_projectors( + mut self, + projectors: Arc, + ) -> Self { + self.projectors = Some(projectors); + self + } + + /// Injects the project permission store used to resolve effective agent + /// permissions at launch time. + #[must_use] + pub fn with_permission_store(mut self, permissions: Arc) -> Self { + self.permissions = Some(permissions); + self + } + /// Branche le provider de **store de sessions provider (lot P8b)** sur ce launcher : /// après un lancement structuré exposant un id de session moteur, range /// `(pair_conversation_id, provider_key) → engine_session_id` dans `providers.json` @@ -1203,16 +1417,14 @@ impl LaunchAgent { .create_dir_all(&RemotePath::new(run_dir.as_str().to_owned())) .await?; - // 3b. Seed the CLI's permission config in the run dir so the agent runs - // with the project's full autonomy and never blocks on per-command - // permission prompts. The agent's cwd is the run dir, so the CLI - // writes/reads its permission file there; without a seed, the CLI - // accumulates narrow per-command approvals and keeps prompting. - // Pragmatic per-CLI seed pending the universal `.ideai/permissions.json` - // + OS-sandbox model. Non-clobbering and best-effort. - self.seed_cli_permissions(&profile, &run_dir, &input.project.root) + let effective_permissions = self + .resolve_effective_permissions(&input.project, agent.id) .await?; + // 3b. (Permission projection moved to step 5c, after the convention file and + // the MCP config, so both the structured and PTY paths inherit it — see + // `apply_permission_projection`.) + // 4. Prepare the invocation (pure): command + args + injection plan + cwd. // The run dir is passed as the cwd base; the profile's `{agentRunDir}` // placeholder resolves against it. @@ -1275,8 +1487,45 @@ impl LaunchAgent { // IdeA matérialise SA config MCP au format de CETTE CLI, dans le **même** // run dir isolé que le convention file et le seed de permissions. `None` // ⇒ aucun write/flag/env (chemin actuel inchangé, zéro régression). - self.apply_mcp_config(&profile, &run_dir, input.mcp_runtime.as_ref(), &mut spec) - .await; + self.apply_mcp_config( + &profile, + &run_dir, + &input.project.root, + input.mcp_runtime.as_ref(), + &mut spec, + ) + .await; + + // 5c. ── PROJECTION DES PERMISSIONS (lot LP3-3) ── + // Strictement APRÈS le convention file (5) ET la conf MCP (5a), donc + // AVANT le split structuré/PTY (5b) et le spawn : les DEUX chemins + // héritent de la projection (fichiers du plan écrits dans le run dir, + // `args`/`env` foldés dans `spec`). `None` registre / profil non + // projetable / `eff == None` ⇒ no-op (prompting natif conservé). + self.apply_permission_projection( + &profile, + &run_dir, + &input.project.root, + effective_permissions.as_ref(), + &mut spec, + ) + .await?; + + // 5d. ── PLAN DE SANDBOX OS (lot LP4-3) ── + // Strictement APRÈS la résolution des permissions (3) et la projection + // advisory (5c), AVANT le split structuré/PTY : `compile_sandbox_plan` + // est **pur** (domaine) — le launch path ne fait qu'orchestrer + // `EffectivePermissions` (déjà lue du store en 3) → `SandboxPlan` et + // remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan, + // comportement natif conservé (invariant produit). L'enforcer concret + // (Landlock/Noop) est injecté côté PTY au composition root. + spec.sandbox = compile_sandbox_plan( + effective_permissions.as_ref(), + &SandboxContext { + project_root: input.project.root.as_str(), + run_dir: run_dir.as_str(), + }, + ); // 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ── // Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le @@ -1300,14 +1549,13 @@ impl LaunchAgent { // pure partagée avec `resolve_conversation` (aucun couplage à // l'orchestrateur). C'est cet id — et **non** l'id de session moteur — // qui retrouve log + handoff au (re)lancement (P7) et survit au swap. - let pair_conversation_id = - input.conversation_id.clone().unwrap_or_else(|| { - ConversationId::for_pair( - ConversationParty::User, - ConversationParty::agent(agent.id), - ) - .to_string() - }); + let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| { + ConversationId::for_pair( + ConversationParty::User, + ConversationParty::agent(agent.id), + ) + .to_string() + }); return self .launch_structured( factory.as_ref(), @@ -1321,6 +1569,7 @@ impl LaunchAgent { &input.project.root, input.node_id, size, + spec.sandbox.as_ref(), ) .await; } @@ -1382,9 +1631,12 @@ impl LaunchAgent { root: &ProjectPath, node_id: Option, size: PtySize, + sandbox: Option<&SandboxPlan>, ) -> Result { + // Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`, + // déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée. let session = factory - .start(profile, prepared, run_dir, session_plan) + .start(profile, prepared, run_dir, session_plan, sandbox) .await .map_err(|e| AppError::Process(e.to_string()))?; @@ -1574,53 +1826,114 @@ impl LaunchAgent { } } - /// Seeds the agent's run dir with the CLI permission config matching its - /// context-injection convention, so the agent inherits the project's autonomy - /// instead of prompting per command. + /// Projects the resolved [`EffectivePermissions`] into the launched CLI's + /// concrete permission config (lot LP3-3), replacing the historical per-CLI + /// seeds. Selects the profile's [`PermissionProjector`] (cf. + /// [`select_projector_key`]), asks it for a pure [`domain::permission::PermissionProjection`] + /// (a plan), then **applies** that plan: materialises its files in the run dir + /// and folds its `args`/`env` into `spec`. /// - /// Conditioned on the CLI convention (only Claude Code — convention file - /// `CLAUDE.md` — has a known seed today); a no-op for any other CLI. - /// Best-effort and **non-clobbering**: an existing file (possibly user-edited) - /// is left untouched. + /// File ownership drives the write regime: + /// - [`ProjectedFile::Replace`] ⇒ **clobber** (always overwrite). Unlike the old + /// non-clobbering seed, this lets a re-projection (e.g. after a profile swap) + /// refresh an IdeA-owned file. + /// - [`ProjectedFile::MergeToml`] ⇒ merge only the **managed** tables/keys into + /// any existing file (via the shared TOML helpers), preserving everything else. + /// + /// No-op when: no registry is wired, the profile has no matching projector, or + /// the resolved permissions are `None` (the projector returns an empty + /// projection — native prompting preserved). /// /// # Errors - /// [`AppError::FileSystem`] if the directory/file cannot be written. - async fn seed_cli_permissions( + /// [`AppError::FileSystem`] if a plan file cannot be written. + async fn apply_permission_projection( &self, profile: &AgentProfile, run_dir: &ProjectPath, project_root: &ProjectPath, + permissions: Option<&EffectivePermissions>, + spec: &mut SpawnSpec, ) -> Result<(), AppError> { - let is_claude = matches!( - &profile.context_injection, - ContextInjection::ConventionFile { target } - if target - .rsplit(['/', '\\']) - .next() - .unwrap_or(target) - .eq_ignore_ascii_case("CLAUDE.md") - ); - if !is_claude { + let Some(registry) = &self.projectors else { return Ok(()); + }; + let Some(key) = select_projector_key(profile) else { + return Ok(()); + }; + let Some(projector) = registry.get(key) else { + return Ok(()); + }; + + let ctx = ProjectionContext { + project_root: project_root.as_str(), + run_dir: run_dir.as_str(), + }; + let projection = projector.project(permissions, &ctx); + + for file in &projection.files { + match file { + ProjectedFile::Replace { rel_path, contents } => { + self.ensure_run_dir_parent(run_dir, rel_path).await?; + let path = RemotePath::new(join(run_dir, rel_path)); + // Clobber: IdeA owns this file; overwriting refreshes it on re-projection. + self.fs.write(&path, contents.as_bytes()).await?; + } + ProjectedFile::MergeToml { + rel_path, + managed_tables, + managed_keys, + contents, + } => { + self.ensure_run_dir_parent(run_dir, rel_path).await?; + let path = RemotePath::new(join(run_dir, rel_path)); + let existing = match self.fs.read(&path).await { + Ok(bytes) => String::from_utf8(bytes).unwrap_or_default(), + Err(_) => String::new(), + }; + let merged = + merge_managed_toml(&existing, managed_tables, managed_keys, contents); + self.fs.write(&path, merged.as_bytes()).await?; + } + } } - let settings_path = - RemotePath::new(format!("{}/.claude/settings.local.json", run_dir.as_str())); - if self.fs.exists(&settings_path).await? { - return Ok(()); - } - self.fs - .create_dir_all(&RemotePath::new(format!("{}/.claude", run_dir.as_str()))) - .await?; - self.fs - .write( - &settings_path, - claude_settings_seed(project_root.as_str()).as_bytes(), - ) - .await?; + // Fold the plan's launch args/env into the spec, before the structured/PTY + // split so both inherit them. + spec.args.extend(projection.args.iter().cloned()); + spec.env.extend(projection.env.iter().cloned()); Ok(()) } + /// Ensures the parent directory of `/` exists (e.g. the + /// `.claude/` or `.codex/` subdir), so a projected file write never fails on a + /// missing directory. A `rel_path` with no separator needs no extra dir. + async fn ensure_run_dir_parent( + &self, + run_dir: &ProjectPath, + rel_path: &str, + ) -> Result<(), AppError> { + if let Some((parent, _)) = rel_path.rsplit_once(['/', '\\']) { + if !parent.is_empty() { + self.fs + .create_dir_all(&RemotePath::new(format!("{}/{parent}", run_dir.as_str()))) + .await?; + } + } + Ok(()) + } + + async fn resolve_effective_permissions( + &self, + project: &Project, + agent_id: AgentId, + ) -> Result, AppError> { + let Some(store) = &self.permissions else { + return Ok(None); + }; + let doc = store.load_permissions(project).await?; + Ok(doc.resolve_for(agent_id)) + } + /// Applies the context-injection plan that must happen *before* spawn: /// materialising a `conventionFile` context (write the `.md` to `/target`) /// or attaching the on-disk context path to an environment variable. `Args` is @@ -1679,10 +1992,18 @@ impl LaunchAgent { /// /// Dispatch by [`McpConfigStrategy`]: /// - `ConfigFile { target }` → write `/` (e.g. `.mcp.json`) with - /// the IdeA MCP server declaration. **Non-clobbering and best-effort**, exactly - /// like [`Self::seed_cli_permissions`]: an existing file (possibly user-edited) - /// is left untouched, and any write/exists failure is swallowed so it **never** - /// fails the launch. + /// the IdeA MCP server declaration. **Best-effort** (any write/exists failure is + /// swallowed so it **never** fails the launch), with two clobber regimes: + /// * with an injected [`McpRuntime`] (real app-tauri launch) the file is + /// **regenerated and clobbered on every (re)launch**, exactly like the + /// convention file. The declaration's `command` carries the **stable** IdeA + /// exe path (`$APPIMAGE`) and the project's live endpoint — both drift between + /// runs (the AppImage mount path changes at each remount/reboot), so a stale + /// `.mcp.json` would point the bridge at a dead binary/socket. `.mcp.json` is + /// IdeA-managed (not user-edited), so clobbering is safe; + /// * without a runtime (orchestrator / hot-swap / tests) only a degraded minimal + /// declaration is available, so the write stays **non-clobbering** (mirrors + /// [`Self::seed_cli_permissions`]) — never overwriting a real declaration. /// - `Flag { flag }` → append the flag + run-dir config path to [`SpawnSpec::args`]. /// - `Env { var }` → append the variable (run-dir config path) to [`SpawnSpec::env`]. /// @@ -1700,6 +2021,7 @@ impl LaunchAgent { &self, profile: &AgentProfile, run_dir: &ProjectPath, + project_root: &ProjectPath, runtime: Option<&McpRuntime>, spec: &mut SpawnSpec, ) { @@ -1708,19 +2030,86 @@ impl LaunchAgent { }; match &mcp.config { domain::profile::McpConfigStrategy::ConfigFile { target } => { - // Non-clobbering, best-effort — mirrors `seed_cli_permissions`. let path = RemotePath::new(join(run_dir, target)); - match self.fs.exists(&path).await { - Ok(true) => {} - Ok(false) => { - let declaration = mcp_server_declaration(mcp.transport, runtime); - // Best-effort: a write failure must not fail the launch. + let declaration = mcp_server_declaration(mcp.transport, runtime); + match runtime { + // Real launch (app-tauri injected the runtime): the declaration + // carries the **stable** IdeA executable path (`$APPIMAGE`, resolved + // by `idea_exe_path`) and the project's live loopback endpoint. Both + // can drift between runs — the AppImage internal mount path changes + // at every remount/reboot — so the file MUST be regenerated and + // **clobbered** on every (re)launch, exactly like the convention file + // (`apply_injection`). `.mcp.json` is IdeA-managed, not user-edited, + // so there is nothing to preserve. Best-effort: a write failure must + // never fail the launch. + Some(_) => { let _ = self.fs.write(&path, declaration.as_bytes()).await; } - // An exists() probe failure is swallowed too (best-effort). - Err(_) => {} + // Internal launch (orchestrator / hot-swap / tests): we only have a + // degraded **minimal** declaration (no endpoint/project/requester). + // Stay non-clobbering — mirrors `seed_cli_permissions` — so we never + // overwrite a real declaration previously written by an app-tauri + // launch with the minimal one. + None => match self.fs.exists(&path).await { + Ok(true) => {} + Ok(false) => { + let _ = self.fs.write(&path, declaration.as_bytes()).await; + } + Err(_) => {} + }, } } + domain::profile::McpConfigStrategy::TomlConfigHome { target, home_env } => { + // Pendant Codex de `ConfigFile` : on écrit un `config.toml` (table + // `[mcp_servers.idea]`, via l'encodeur TOML partagé D2) au chemin + // `/`, et on pousse `home_env` (ex. `CODEX_HOME`) vers + // le **dossier parent** de ce fichier. Codex lit alors ses serveurs MCP + // dans ce `config.toml` ISOLÉ au run dir, jamais le `~/.codex` global. + let path = RemotePath::new(join(run_dir, target)); + let declaration = mcp_server_wiring(mcp.transport, runtime).to_config_toml(); + // Même régime clobber/non-clobber que `.mcp.json` : avec un runtime réel + // (lancement app-tauri) on régénère/clobber à chaque (re)lancement (l'exe + // `$APPIMAGE` et l'endpoint dérivent entre runs) ; sans runtime + // (orchestrateur / hot-swap / tests) on reste non-clobbering pour ne pas + // écraser une déclaration réelle par la minimale. + match runtime { + Some(_) => { + let existing = match self.fs.read(&path).await { + Ok(bytes) => String::from_utf8(bytes).ok(), + Err(_) => None, + }; + let rendered = codex_config_toml( + existing.as_deref(), + &declaration, + run_dir.as_str(), + project_root.as_str(), + ); + let _ = self.fs.write(&path, rendered.as_bytes()).await; + } + None => match self.fs.exists(&path).await { + Ok(true) => {} + Ok(false) => { + let rendered = codex_config_toml( + None, + &declaration, + run_dir.as_str(), + project_root.as_str(), + ); + let _ = self.fs.write(&path, rendered.as_bytes()).await; + } + Err(_) => {} + }, + } + // Permission projection (sandbox_mode/approval_policy + --sandbox/ + // --ask-for-approval) is NO LONGER done here — it is decoupled into + // `apply_permission_projection` (lot LP3-3), so a Codex profile gets its + // sandbox even without an MCP capability. `apply_mcp_config` is now + // MCP-only (mcp_servers.idea table + projects trust). + // `home_env` pointe sur le DOSSIER PARENT de `target` (ex. + // `{runDir}/.codex`), pas sur le fichier — Codex y cherche `config.toml`. + let home_dir = parent_dir(run_dir, target); + spec.env.push((home_env.clone(), home_dir)); + } domain::profile::McpConfigStrategy::Flag { flag } => { // Pass the server via a launch flag (e.g. `--mcp-config {path}`). The // config path is the run dir itself (the CLI's cwd), where the server @@ -1833,58 +2222,40 @@ fn mcp_server_declaration( transport: domain::profile::McpTransport, runtime: Option<&McpRuntime>, ) -> String { - // Common `.mcp.json`-style shape (Claude Code et CLIs apparentées). The transport - // is surfaced so a socket-based CLI can be wired later without changing this seam. - let transport_label = match transport { - domain::profile::McpTransport::Stdio => "stdio", - domain::profile::McpTransport::Socket => "socket", - }; - - // `command` + extra args depend on whether OS/runtime facts were injected. - // Each `args` entry is emitted as an escaped JSON string so an exe path or - // endpoint with spaces/backslashes/quotes stays valid JSON. - let (command, extra_args) = match runtime { - Some(rt) => { - let arg = |label: &str, value: &str| { - format!( - ",\n {},\n {}", - json_string(label), - json_string(value) - ) - }; - let extra = format!( - "{}{}{}", - arg("--endpoint", &rt.endpoint), - arg("--project", &rt.project_id), - arg("--requester", &rt.requester), - ); - (rt.exe.as_str(), extra) - } - None => ("idea", String::new()), - }; - let command = json_string(command); - - format!( - r#"{{ - "mcpServers": {{ - "idea": {{ - "command": {command}, - "args": [ - "mcp-server"{extra_args} - ], - "transport": "{transport_label}" - }} - }} -}} -"# - ) + mcp_server_wiring(transport, runtime).to_mcp_json() } -/// Wraps a string as a JSON string literal (quotes + escaping), reusing the path -/// escaper so an exe path / endpoint with spaces, backslashes or quotes stays valid -/// JSON in the generated `.mcp.json`. -fn json_string(s: &str) -> String { - format!("\"{}\"", json_escape(s)) +/// Builds the IdeA MCP server **wiring** (`command` + `args` + transport) shared by +/// every materialisation format (cadrage Codex D2). It is the **single source of +/// truth** for *what* the bridge is launched as; the concrete bytes (`.mcp.json` +/// JSON for Claude, `config.toml` TOML for Codex) are produced by the domain +/// encoders [`domain::McpServerWiring::to_mcp_json`] / +/// [`domain::McpServerWiring::to_config_toml`], so the two sites can never drift. +/// +/// `Some(runtime)` ⇒ the **real** wiring (this IdeA exe + the project's loopback +/// endpoint/project/requester); `None` ⇒ the coherent **minimal** `idea mcp-server` +/// fallback (orchestrator / hot-swap / tests). +#[must_use] +fn mcp_server_wiring( + transport: domain::profile::McpTransport, + runtime: Option<&McpRuntime>, +) -> domain::McpServerWiring { + let (command, args) = match runtime { + Some(rt) => ( + rt.exe.clone(), + vec![ + "mcp-server".to_owned(), + "--endpoint".to_owned(), + rt.endpoint.clone(), + "--project".to_owned(), + rt.project_id.clone(), + "--requester".to_owned(), + rt.requester.clone(), + ], + ), + None => ("idea".to_owned(), vec!["mcp-server".to_owned()]), + }; + domain::McpServerWiring::new(command, args, transport) } /// Builds an absolute path string by joining a [`ProjectPath`] with a relative @@ -1894,6 +2265,18 @@ fn join(base: &ProjectPath, rel: &str) -> String { format!("{b}/{rel}") } +/// Resolves the **parent directory** (absolute) of `/` — used to point a +/// CLI's `home_env` (e.g. `CODEX_HOME`) at the directory *containing* the materialised +/// config file (`config.toml`), not the file itself. When `rel` has no separator +/// (file directly in the run dir), the parent is the run dir. +fn parent_dir(base: &ProjectPath, rel: &str) -> String { + let full = join(base, rel); + match full.rsplit_once(['/', '\\']) { + Some((parent, _)) if !parent.is_empty() => parent.to_owned(), + _ => base.as_str().trim_end_matches(['/', '\\']).to_owned(), + } +} + /// Computes an agent's isolated run directory `/.ideai/run//` /// (ARCHITECTURE §14.1). This is the PTY cwd for the agent — never the project /// root — guaranteeing that two distinct agents on the same project root get two @@ -1937,56 +2320,9 @@ fn structured_snapshot( snapshot } -/// Builds the Claude Code permission seed (`.claude/settings.local.json`) written -/// into an agent's run dir: full project autonomy (`bypassPermissions` + broad -/// Read/Edit/Write/Bash) with the project root granted as an additional working -/// directory (the cwd is the run dir, the agent works on the root above it), while -/// keeping destructive/out-of-project commands denied. `project_root` is embedded -/// verbatim; it is JSON-escaped to stay valid for unusual paths. -/// -/// Pure (no I/O), so it is unit-testable in isolation. -#[must_use] -fn claude_settings_seed(project_root: &str) -> String { - let root = json_escape(project_root); - format!( - r#"{{ - "permissions": {{ - "defaultMode": "bypassPermissions", - "additionalDirectories": [ - "{root}" - ], - "allow": [ - "Read", - "Edit", - "Write", - "Bash" - ], - "deny": [ - "Bash(sudo *)", - "Bash(rm -rf /)", - "Bash(rm -rf /*)", - "Bash(rm -rf ~)", - "Bash(rm -rf ~/)", - "Bash(rm -rf ~/*)", - "Bash(rm -rf $HOME*)", - "Bash(mkfs*)", - "Bash(dd if=*)", - "Bash(shutdown*)", - "Bash(reboot*)" - ] - }}, - "skipDangerousModePermissionPrompt": true, - "sandbox": {{ - "enabled": false - }} -}} -"# - ) -} - -/// Minimal JSON string escaper for embedding a filesystem path in the settings -/// seed (handles the characters that actually occur in paths: backslash, quote, -/// and control chars). +/// Minimal JSON string escaper used by [`toml_string`] (and historically by the +/// Claude seed, now extracted to the infra projector). Handles the characters that +/// actually occur in paths: backslash, quote, control chars. fn json_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { @@ -2003,6 +2339,180 @@ fn json_escape(s: &str) -> String { out } +fn toml_string(s: &str) -> String { + format!("\"{}\"", json_escape(s)) +} + +/// Renders Codex's `config.toml` **MCP part only** (lot LP3-3 decoupling): merges +/// the `[mcp_servers.idea]` table and ensures the run-dir + project-root trust +/// entries. The permission part (`sandbox_mode` / `approval_policy` + the +/// `--sandbox` / `--ask-for-approval` args) now lives in the Codex permission +/// projector (`apply_permission_projection`), so this function is MCP-only and a +/// Codex profile receives its sandbox even without an MCP capability. +fn codex_config_toml( + existing: Option<&str>, + mcp_declaration: &str, + run_dir: &str, + project_root: &str, +) -> String { + let mut text = existing.unwrap_or_default().to_owned(); + text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end()); + text = ensure_codex_trust(&text, run_dir); + text = ensure_codex_trust(&text, project_root); + if !text.ends_with('\n') { + text.push('\n'); + } + text +} + +/// Merges a projector's **managed** TOML fragment into an existing `config.toml` +/// (lot LP3-3, [`ProjectedFile::MergeToml`]). Only the `managed_tables` and +/// `managed_keys` are touched; every other line of `existing` is preserved. +/// +/// - each managed table is replaced wholesale by its block from `fragment` +/// ([`replace_toml_table`]); +/// - each managed top-level key takes the value found for it in `fragment` +/// ([`set_top_level_toml_line`]). +/// +/// A managed table/key absent from `fragment` leaves `existing` untouched for it. +fn merge_managed_toml( + existing: &str, + managed_tables: &[String], + managed_keys: &[String], + fragment: &str, +) -> String { + let mut text = existing.to_owned(); + for table in managed_tables { + if let Some(block) = extract_toml_table(fragment, table) { + text = replace_toml_table(&text, table, block.trim_end()); + } + } + for key in managed_keys { + if let Some(line) = extract_top_level_toml_line(fragment, key) { + text = set_top_level_toml_line(&text, key, line); + } + } + if !text.ends_with('\n') { + text.push('\n'); + } + text +} + +/// Returns the full `key = …` line for `key` found at top level in `fragment` +/// (before any `[table]` header), or `None` if absent. +fn extract_top_level_toml_line<'a>(fragment: &'a str, key: &str) -> Option<&'a str> { + let needle = format!("{key} ="); + for line in fragment.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with('[') { + break; + } + if trimmed.starts_with(&needle) { + return Some(line); + } + } + None +} + +/// Returns the `[table]` block (header + body up to the next header / EOF) for +/// `table` in `fragment`, or `None` if the table is absent. +fn extract_toml_table(fragment: &str, table: &str) -> Option { + let header = format!("[{table}]"); + let mut block: Vec<&str> = Vec::new(); + let mut capturing = false; + for line in fragment.lines() { + let trimmed = line.trim(); + if trimmed == header { + capturing = true; + block.push(line); + continue; + } + if capturing { + if trimmed.starts_with('[') { + break; + } + block.push(line); + } + } + if capturing { + Some(block.join("\n")) + } else { + None + } +} + +fn ensure_codex_trust(input: &str, path: &str) -> String { + let header = format!("[projects.{}]", toml_string(path)); + if input.lines().any(|line| line.trim() == header) { + return input.to_owned(); + } + append_block(input, &format!("{header}\ntrust_level = \"trusted\"")) +} + +fn replace_toml_table(input: &str, table: &str, block: &str) -> String { + let header = format!("[{table}]"); + let mut out = Vec::new(); + let mut skipping = false; + for line in input.lines() { + let trimmed = line.trim(); + if trimmed == header { + skipping = true; + continue; + } + if skipping && trimmed.starts_with('[') { + skipping = false; + } + if !skipping { + out.push(line.to_owned()); + } + } + append_block(&out.join("\n"), block) +} + +/// Upserts a top-level `key` with a pre-formatted `replacement` line (already +/// `key = `): replaces the existing top-level occurrence if present, +/// otherwise prepends it. The line-level core shared by [`merge_managed_toml`]. +fn set_top_level_toml_line(input: &str, key: &str, replacement: &str) -> String { + let mut out = Vec::new(); + let mut replaced = false; + let mut in_top_level = true; + for line in input.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with('[') { + in_top_level = false; + } + if in_top_level && !replaced && trimmed.starts_with(&format!("{key} =")) { + out.push(replacement.to_owned()); + replaced = true; + } else { + out.push(line.to_owned()); + } + } + let text = out.join("\n"); + if replaced { + text + } else { + prepend_line(&text, replacement) + } +} + +fn prepend_line(input: &str, line: &str) -> String { + if input.trim().is_empty() { + format!("{line}\n") + } else { + format!("{line}\n{}", input.trim_start_matches('\n')) + } +} + +fn append_block(input: &str, block: &str) -> String { + let trimmed = input.trim_end(); + if trimmed.is_empty() { + format!("{}\n", block.trim_end()) + } else { + format!("{trimmed}\n\n{}\n", block.trim_end()) + } +} + /// 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 @@ -2347,11 +2857,7 @@ mod tests { // Exact line format: `- [Title](.ideai/memory/slug.md) — hook (type)`. assert!(doc.contains("- [Alpha](.ideai/memory/alpha-note.md) — the first hook (user)")); - assert!( - doc.contains( - "- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)" - ) - ); + assert!(doc.contains("- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)")); // Deterministic order: first entry precedes the second. let alpha_at = doc.find("[Alpha]").unwrap(); @@ -2533,15 +3039,7 @@ mod tests { MemoryType::Project, )]; let h = handoff("Résumé : on a fini l'étape 2.", Some("Livrer le lot P7")); - let doc = compose_convention_file( - "/root", - "", - "# Persona", - &[], - &memory, - Some(&h), - false, - ); + let doc = compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), false); assert!( doc.contains("# Reprise de la conversation"), @@ -2620,37 +3118,83 @@ mod tests { ); } - #[test] - fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() { - let json = claude_settings_seed("/home/me/proj"); + // NB (lot LP3-3): the former `claude_settings_seed_*` unit tests were removed — + // that translation now lives in `infrastructure::permission::ClaudePermissionProjector` + // and is covered by its own tests (LP3-2). Likewise the Codex sandbox/approval + // derivation moved to `CodexPermissionProjector`; `codex_config_toml` here is now + // **MCP-only**, so the Codex tests below assert only the MCP/trust merge. - // Full autonomy. - assert!(json.contains("\"defaultMode\": \"bypassPermissions\"")); - assert!(json.contains("\"Bash\"")); - // Project root granted as an additional working directory. - assert!(json.contains("\"/home/me/proj\"")); - // Destructive guardrails preserved. - assert!(json.contains("Bash(sudo *)")); - assert!(json.contains("Bash(rm -rf /)")); - assert!(json.contains("Bash(mkfs*)")); - // Valid JSON. - let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed is valid JSON"); - assert_eq!(parsed["permissions"]["defaultMode"], "bypassPermissions"); - assert_eq!( - parsed["permissions"]["additionalDirectories"][0], - "/home/me/proj" + #[test] + fn codex_config_trusts_run_and_project_and_replaces_only_idea_mcp() { + let existing = r#"[projects."/home/me/proj"] +trust_level = "trusted" +approval_policy = "nested" + +[mcp_servers.idea] +command = "old" + +[mcp_servers.other] +command = "other" +"#; + // No `permissions` argument anymore: the permission projection is decoupled + // (lot LP3-3) into `CodexPermissionProjector`. This function only merges MCP. + let rendered = codex_config_toml( + Some(existing), + "[mcp_servers.idea]\ncommand = \"new\"", + "/home/me/proj/.ideai/run/a", + "/home/me/proj", ); + + // MCP table + trust entries are the only things this function touches. + assert!(rendered.contains("[projects.\"/home/me/proj\"]")); + assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]")); + assert!(rendered.contains("approval_policy = \"nested\"")); + assert!(rendered.contains("[mcp_servers.idea]\ncommand = \"new\"")); + assert!(rendered.contains("[mcp_servers.other]\ncommand = \"other\"")); + assert!(!rendered.contains("command = \"old\"")); + assert_eq!(rendered.matches("[mcp_servers.idea]").count(), 1); + assert_eq!(rendered.matches("[projects.\"/home/me/proj\"]").count(), 1); + // Permission keys are NOT added by this MCP-only function. + assert!(!rendered.contains("sandbox_mode =")); } #[test] - fn claude_settings_seed_escapes_paths_for_valid_json() { - // A path with a backslash and a quote must not break the JSON. - let json = claude_settings_seed(r#"/weird\path"x"#); - let parsed: serde_json::Value = - serde_json::from_str(&json).expect("seed with odd path is valid JSON"); - assert_eq!( - parsed["permissions"]["additionalDirectories"][0], - r#"/weird\path"x"# + fn codex_config_is_mcp_only_and_adds_no_permission_keys() { + let rendered = codex_config_toml( + None, + "[mcp_servers.idea]\ncommand = \"idea-mcp\"", + "/home/me/proj/.ideai/run/a", + "/home/me/proj", ); + + assert!(!rendered.contains("approval_policy =")); + assert!(!rendered.contains("sandbox_mode =")); + assert!(rendered.contains("[projects.\"/home/me/proj\"]")); + assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]")); + } + + #[test] + fn merge_managed_toml_upserts_only_managed_keys() { + // Existing config (e.g. written by `apply_mcp_config`): MCP + trust + a user key. + let existing = r#"user_key = "keep-me" + +[mcp_servers.idea] +command = "idea-mcp" +"#; + // Codex projector fragment: the two managed permission keys. + let fragment = "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n"; + let merged = merge_managed_toml( + existing, + &[], + &["sandbox_mode".to_owned(), "approval_policy".to_owned()], + fragment, + ); + + // Managed keys are spliced in… + assert!(merged.contains("sandbox_mode = \"workspace-write\"")); + assert!(merged.contains("approval_policy = \"never\"")); + // …without disturbing the rest of the file. + assert!(merged.contains("user_key = \"keep-me\"")); + assert!(merged.contains("[mcp_servers.idea]\ncommand = \"idea-mcp\"")); } } diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index cda3258..e93f6e9 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -16,22 +16,20 @@ mod usecases; pub(crate) use lifecycle::unique_md_path; pub(crate) use lifecycle::ReattachDecision; -pub use structured::send_blocking; +pub use structured::{drain_with_readiness, send_blocking}; pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles}; pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput}; -pub use resume::{ - ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, -}; pub use lifecycle::{ ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider, - ProviderSessionProvider, - LaunchAgent, - LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime, + LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, + ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor, - UpdateAgentContext, - UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, + UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, +}; +pub use resume::{ + ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, }; pub use usecases::{ ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile, diff --git a/crates/application/src/agent/structured.rs b/crates/application/src/agent/structured.rs index eeaa5ef..b2a452a 100644 --- a/crates/application/src/agent/structured.rs +++ b/crates/application/src/agent/structured.rs @@ -14,7 +14,10 @@ use std::time::Duration; +use domain::input::InputMediator; +use domain::ids::AgentId; use domain::ports::{AgentSession, AgentSessionError, ReplyEvent}; +use domain::readiness::{ReadinessPolicy, ReadinessSignal}; /// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au /// [`ReplyEvent::Final`]**, et retourne son contenu agrégé. @@ -39,14 +42,83 @@ pub async fn send_blocking( session: &dyn AgentSession, prompt: &str, timeout: Option, +) -> Result { + drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await +} + +/// Comme [`send_blocking`], mais **branche la readiness** : à chaque événement du +/// tour, [`ReadinessPolicy::classify`] est consulté et, dès qu'il renvoie +/// [`ReadinessSignal::TurnEnded`] (le `Final`), le médiateur d'entrée est notifié +/// (`mark_idle(agent)`) pour que la FIFO de l'agent avance — **sans** dépendre d'un +/// `idea_reply` explicite ni du sniff de prompt PTY (chantier readiness/heartbeat, +/// lot 1, fix de la cause racine du blocage `Busy`). +/// +/// DRY : **un seul** chemin de lecture du flux (la boucle de [`drain_bounded`]) ; +/// cette fonction n'est que `send_blocking` muni d'un *sink* de readiness. Le `Final` +/// réveille donc à la fois le `pending` (via la valeur de retour) **et** la FIFO (via +/// `mark_idle`). `idea_reply` reste un signal alternatif (premier arrivé gagne) côté +/// orchestrateur. +/// +/// # Errors +/// Identiques à [`send_blocking`] (échec `send`/décodage, flux clos sans `Final`, +/// timeout). +pub async fn drain_with_readiness( + session: &dyn AgentSession, + prompt: &str, + timeout: Option, + mediator: &dyn InputMediator, + agent: AgentId, +) -> Result { + // `on_signal` ne reçoit QUE les événements terminaux (le `Final` ⇒ `TurnEnded`) : + // la readiness ne classe pas les non-terminaux. Pour le **battement** de vivacité + // (lot 2) on a besoin de notifier le médiateur à CHAQUE événement non terminal + // (delta / activité / heartbeat) ⇒ on passe un sink d'événement bruts `on_event`. + drain_bounded_events( + session, + prompt, + timeout, + |event| { + // Tout événement **non terminal** prouve la vivacité ⇒ un battement. + if !matches!(event, ReplyEvent::Final { .. }) { + mediator.mark_alive(agent); + } + }, + |signal| { + if signal == ReadinessSignal::TurnEnded { + mediator.mark_idle(agent); + } + }, + ) + .await +} + +/// Ouvre le flux du tour (`send`) et le **draine jusqu'au `Final`**, en appliquant +/// la borne temporelle `timeout`, en notifiant `on_event` à **chaque** événement brut +/// (pour le battement de vivacité, lot 2) et `on_signal` à chaque [`ReadinessSignal`] +/// dérivé par [`ReadinessPolicy`] (le `Final` ⇒ `TurnEnded`). +/// +/// **Chemin de lecture unique** (DRY) : `send_blocking` et `drain_with_readiness` +/// passent tous deux par ici, en différant seulement par leurs *sinks*. La session +/// **reste vivante** sur timeout (on ne `shutdown` rien ici, §17.1). +async fn drain_bounded_events( + session: &dyn AgentSession, + prompt: &str, + timeout: Option, + on_event: impl FnMut(&ReplyEvent), + on_signal: impl FnMut(ReadinessSignal), ) -> Result { match timeout { - Some(dur) => match tokio::time::timeout(dur, drain_to_final(session, prompt)).await { + Some(dur) => match tokio::time::timeout( + dur, + drain_to_final(session, prompt, on_event, on_signal), + ) + .await + { Ok(result) => result, // La session **reste vivante** : on ne `shutdown` rien ici (§17.1). Err(_elapsed) => Err(AgentSessionError::Timeout), }, - None => drain_to_final(session, prompt).await, + None => drain_to_final(session, prompt, on_event, on_signal).await, } } @@ -56,18 +128,124 @@ pub async fn send_blocking( /// après le `Final` il ne produit plus rien. On le parcourt donc simplement /// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux /// s'épuise avant, c'est un tour interrompu → erreur [`AgentSessionError::Io`]. +/// +/// Chaque événement est classé par [`ReadinessPolicy`] et le signal éventuel est +/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`]). Deltas, +/// activités et heartbeats sont non terminaux ⇒ ignorés par le rendez-vous synchrone. async fn drain_to_final( session: &dyn AgentSession, prompt: &str, + mut on_event: impl FnMut(&ReplyEvent), + mut on_signal: impl FnMut(ReadinessSignal), ) -> Result { let stream = session.send(prompt).await?; for event in stream { + // Battement de vivacité (lot 2) : notifié pour CHAQUE événement brut, avant le + // classement readiness. Le sink décide (les non-terminaux prouvent la vivacité). + on_event(&event); + if let Some(signal) = ReadinessPolicy::classify(&event) { + on_signal(signal); + } if let ReplyEvent::Final { content } = event { return Ok(content); } - // TextDelta / ToolActivity : ignorés par le rendez-vous synchrone. + // TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici. } Err(AgentSessionError::Io( "le flux de réponse s'est terminé sans événement Final".to_string(), )) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + use domain::ids::SessionId; + use domain::input::{AgentBusyState, SubmitConfig}; + use domain::mailbox::{PendingReply, Ticket}; + use domain::ports::PtyHandle; + + fn agent(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) + } + + /// Session factice : `send` rejoue une liste fixe d'événements (terminée par un + /// `Final`). + struct FakeSession { + events: Vec, + } + #[async_trait::async_trait] + impl AgentSession for FakeSession { + fn id(&self) -> SessionId { + SessionId::from_uuid(uuid::Uuid::from_u128(1)) + } + fn conversation_id(&self) -> Option { + None + } + async fn send( + &self, + _prompt: &str, + ) -> Result { + Ok(Box::new(self.events.clone().into_iter())) + } + async fn shutdown(&self) -> Result<(), AgentSessionError> { + Ok(()) + } + } + + /// Médiateur factice qui enregistre l'ordre des `mark_alive` / `mark_idle`. + #[derive(Default)] + struct RecordingMediator { + calls: Mutex>, + } + impl InputMediator for RecordingMediator { + fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply { + unreachable!("non utilisé par drain_with_readiness") + } + fn preempt(&self, _agent: AgentId) {} + fn mark_idle(&self, _agent: AgentId) { + self.calls.lock().unwrap().push("idle"); + } + fn mark_alive(&self, _agent: AgentId) { + self.calls.lock().unwrap().push("alive"); + } + fn busy_state(&self, _agent: AgentId) -> AgentBusyState { + AgentBusyState::Idle + } + fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {} + fn bind_handle_with_prompt( + &self, + _agent: AgentId, + _handle: PtyHandle, + _pattern: Option, + _submit: SubmitConfig, + ) { + } + } + + #[tokio::test] + async fn drain_marks_alive_on_each_non_terminal_then_idle_on_final() { + let session = FakeSession { + events: vec![ + ReplyEvent::TextDelta { text: "a".into() }, + ReplyEvent::ToolActivity { label: "lit".into() }, + ReplyEvent::Heartbeat, + ReplyEvent::Final { + content: "fini".into(), + }, + ], + }; + let mediator = RecordingMediator::default(); + let out = drain_with_readiness(&session, "go", None, &mediator, agent(1)) + .await + .expect("drain ok"); + assert_eq!(out, "fini"); + // Trois battements (delta, activité, heartbeat) PUIS l'idle sur le Final. + assert_eq!( + *mediator.calls.lock().unwrap(), + vec!["alive", "alive", "alive", "idle"], + "un battement par événement non terminal, idle au Final (pas de battement sur le Final)" + ); + } +} diff --git a/crates/application/src/layout/mod.rs b/crates/application/src/layout/mod.rs index 58b0bc9..7a1b5eb 100644 --- a/crates/application/src/layout/mod.rs +++ b/crates/application/src/layout/mod.rs @@ -35,8 +35,8 @@ pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOut pub use snapshot::{ SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, }; -pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE}; pub(crate) use store::{persist_doc, resolve_doc}; +pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE}; pub use usecases::{ LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout, MutateLayoutInput, MutateLayoutOutput, diff --git a/crates/application/src/layout/usecases.rs b/crates/application/src/layout/usecases.rs index 773e63e..794edef 100644 --- a/crates/application/src/layout/usecases.rs +++ b/crates/application/src/layout/usecases.rs @@ -109,12 +109,7 @@ impl LayoutOperation { direction, new_leaf, container, - } => tree.split( - *target, - *direction, - LeafCell::new(*new_leaf), - *container, - ), + } => tree.split(*target, *direction, LeafCell::new(*new_leaf), *container), Self::Merge { container, keep_index, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index aeaf841..744699d 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -20,6 +20,7 @@ pub mod health; pub mod layout; pub mod memory; pub mod orchestrator; +pub mod permission; pub mod project; pub mod remote; pub mod skill; @@ -28,19 +29,20 @@ pub mod terminal; pub mod window; pub use agent::{ - reference_profile_id, reference_profiles, selectable_reference_profiles, ChangeAgentProfile, - ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput, - ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, - DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, - DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, - HandoffProvider, ProviderSessionProvider, - InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent, - LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, - ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime, - ListResumableAgentsOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput, - ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, - SaveProfileInput, SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext, - UpdateAgentContextInput, send_blocking, AGENT_MEMORY_RECALL_BUDGET, + drain_with_readiness, reference_profile_id, reference_profiles, selectable_reference_profiles, + send_blocking, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, + ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, + CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, + DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, + FirstRunStateOutput, HandoffProvider, InspectConversation, InspectConversationInput, + InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, + ListAgentsInput, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents, + ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry, + ProfileAvailability, + ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, + ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput, + SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, + AGENT_MEMORY_RECALL_BUDGET, }; pub use conversation::RecordTurn; pub use embedder::{ @@ -77,6 +79,12 @@ pub use memory::{ pub use orchestrator::{ McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider, }; +pub use permission::{ + GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput, + ResolveAgentPermissions, ResolveAgentPermissionsInput, ResolveAgentPermissionsOutput, + UpdateAgentPermissions, UpdateAgentPermissionsInput, UpdateProjectPermissions, + UpdateProjectPermissionsInput, +}; pub use project::{ CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject, diff --git a/crates/application/src/orchestrator/context_guard.rs b/crates/application/src/orchestrator/context_guard.rs index 577f3d1..1a6adc6 100644 --- a/crates/application/src/orchestrator/context_guard.rs +++ b/crates/application/src/orchestrator/context_guard.rs @@ -29,9 +29,7 @@ use domain::conversation::ConversationParty; use domain::fileguard::{FileGuard, GuardError, GuardedResource}; use domain::markdown::MarkdownDoc; use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType}; -use domain::ports::{ - AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath, -}; +use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath}; use domain::{AgentId, Project}; use crate::error::AppError; @@ -90,7 +88,11 @@ impl ReadContext { contexts: Arc, fs: Arc, ) -> Self { - Self { guard, contexts, fs } + Self { + guard, + contexts, + fs, + } } /// Reads the requested context, returning its Markdown body. @@ -98,7 +100,11 @@ impl ReadContext { /// # Errors /// [`AppError`] when the agent/context does not exist or the store/fs fails. pub async fn execute(&self, input: ReadContextInput) -> Result { - let ReadContextInput { project, target, requester } = input; + let ReadContextInput { + project, + target, + requester, + } = input; match target { None => { // Global project context: shared read-lease, then read the root file. @@ -109,8 +115,8 @@ impl ReadContext { .map_err(map_guard_err)?; let path = join_root(&project, PROJECT_CONTEXT_FILE); let bytes = self.fs.read(&path).await?; - let text = String::from_utf8(bytes) - .map_err(|e| AppError::Invalid(e.to_string()))?; + let text = + String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?; Ok(MarkdownDoc::new(text)) } Some(name) => { @@ -173,7 +179,12 @@ impl ProposeContext { fs: Arc, clock: Arc, ) -> Self { - Self { guard, contexts, fs, clock } + Self { + guard, + contexts, + fs, + clock, + } } /// Applies the proposal: direct write under a write-lease, or a materialised @@ -182,7 +193,12 @@ impl ProposeContext { /// # Errors /// [`AppError`] when the agent does not exist or the store/fs fails. pub async fn execute(&self, input: ProposeContextInput) -> Result { - let ProposeContextInput { project, target, content, requester } = input; + let ProposeContextInput { + project, + target, + content, + requester, + } = input; match target { Some(name) => { // Per-agent context: direct write under an exclusive write-lease. @@ -273,7 +289,11 @@ impl ReadMemory { /// [`AppError`] when the note does not exist or the store fails. An invalid slug /// is [`AppError::Invalid`]. pub async fn execute(&self, input: ReadMemoryInput) -> Result { - let ReadMemoryInput { project, slug, requester } = input; + let ReadMemoryInput { + project, + slug, + requester, + } = input; match slug { Some(raw) => { let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?; @@ -329,7 +349,12 @@ impl WriteMemory { /// [`AppError::Invalid`] for a bad slug or empty body; [`AppError`] on a store /// failure. pub async fn execute(&self, input: WriteMemoryInput) -> Result<(), AppError> { - let WriteMemoryInput { project, slug, content, requester } = input; + let WriteMemoryInput { + project, + slug, + content, + requester, + } = input; let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?; let _lease = self .guard @@ -527,11 +552,7 @@ mod tests { async fn list(&self, _root: &ProjectPath) -> Result, MemoryError> { Ok(Vec::new()) } - async fn get( - &self, - _root: &ProjectPath, - slug: &MemorySlug, - ) -> Result { + async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result { let body = self .notes .lock() @@ -556,11 +577,7 @@ mod tests { .insert(memory.slug().to_string(), memory.body.as_str().to_owned()); Ok(()) } - async fn delete( - &self, - _root: &ProjectPath, - _slug: &MemorySlug, - ) -> Result<(), MemoryError> { + async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> { Ok(()) } async fn read_index( @@ -805,7 +822,10 @@ mod tests { guard.acquire_write(agent_party(2), slug.clone()), ) .await; - assert!(blocked.is_err(), "a second writer must block while w1 holds"); + assert!( + blocked.is_err(), + "a second writer must block while w1 holds" + ); drop(w1); let w2 = tokio::time::timeout( Duration::from_millis(200), diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 82d55c2..c6f7fbc 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -19,21 +19,22 @@ use std::time::Duration; use tokio::sync::Mutex as AsyncMutex; -use domain::conversation::{ - ConversationParty, ConversationRegistry, SessionRef, WaitForGraph, -}; +use domain::conversation::{ConversationParty, ConversationRegistry, SessionRef, WaitForGraph}; use domain::conversation_log::{ConversationTurn, TurnId, TurnRole}; -use domain::input::{InputMediator, InputSource}; +use domain::input::{InputMediator, InputSource, SubmitConfig}; use domain::mailbox::{Ticket, TicketId}; use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle}; use domain::project::ProjectPath; -use domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project}; +use domain::{ + AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project, +}; use crate::conversation::RecordTurn; use crate::agent::{ - CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, - ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput, + drain_with_readiness, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, + ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, + UpdateAgentContextInput, }; use crate::error::AppError; use crate::orchestrator::{ @@ -41,7 +42,7 @@ use crate::orchestrator::{ ReadMemoryInput, WriteMemory, WriteMemoryInput, }; use crate::skill::{CreateSkill, CreateSkillInput}; -use crate::terminal::{CloseTerminal, CloseTerminalInput, TerminalSessions}; +use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions}; /// Default terminal geometry for an orchestrator-launched agent cell. The UI /// resizes the PTY to the real cell size on attach; these are sane starting rows @@ -57,7 +58,13 @@ const DEFAULT_COLS: u16 = 80; /// **without killing the session**, so the requester can retry. Internal and /// intentionally not yet config-exposed (it may become a per-project setting /// without changing the contract). -const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300); +/// +/// TEMPORAIRE (demande utilisateur 2026-06-13) : la borne de 300 s coupait des tours +/// délégués légitimement très longs (gros refactors + tests). On la relève donc à 24 h +/// (≈ « pas de limite ») le temps de concevoir un vrai signal de vivacité (savoir si +/// l'agent travaille encore) qui remplacera ce timeout brut. NE PAS considérer comme +/// définitif : à reconvertir en borne courte + heartbeat once that liveness signal exists. +const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60); /// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0, /// cadrage v5 §4). @@ -72,7 +79,82 @@ const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300); /// [`domain::ports::AgentSessionError::Timeout`]) ; le tour en cours n'est pas /// affecté. Largeur = un tour complet + sa propre file ⇒ on autorise deux tours /// pleins d'attente. -const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(600); +/// +/// TEMPORAIRE (cf. [`ASK_AGENT_TIMEOUT`]) : relevé à 48 h (≈ deux tours « sans limite ») +/// en cohérence avec la levée temporaire de la borne de tour, le temps d'un vrai +/// signal de vivacité. +const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(48 * 60 * 60); + +/// Borne de tour effective (lot 2, timeouts pilotés par profil) : le +/// `turn_timeout_ms` du profil de la cible **quand il existe**, sinon le défaut +/// historique [`ASK_AGENT_TIMEOUT`] (zéro régression pour un profil sans `liveness` / +/// sans `turn_timeout_ms`). Pure et testable sans wiring de service. +#[must_use] +fn resolve_turn_timeout(turn_timeout_ms: Option) -> Duration { + turn_timeout_ms.map_or(ASK_AGENT_TIMEOUT, |ms| Duration::from_millis(u64::from(ms))) +} + +/// Garde RAII de **fin de tour** : ramène la cible `Idle` quoi qu'il arrive (succès, +/// erreur, timeout, **et surtout future abandonné/dropped**) tant qu'elle n'a pas été +/// désarmée. +/// +/// Cause racine du blocage `Busy` à vie : l'agent passe `Idle→Busy` dès l'`enqueue` +/// (qui démarre le tour) mais ne revenait `Idle` que sur la **branche succès** du +/// `select!`/`match`. Si le futur `ask_agent` est **dropped** (le demandeur a interrompu +/// son appel), AUCUNE branche ne s'exécute ⇒ l'agent reste `Busy`, et toutes les +/// délégations suivantes s'empilent derrière ce tour fantôme sans jamais être livrées. +/// +/// Le garde tient les `Arc` nécessaires pour, à son `Drop`, faire à la fois +/// `cancel_head(agent, ticket)` (retire le ticket fantôme de la FIFO — idempotent et +/// positionnel : no-op si le head a déjà changé) **et** `mark_idle(agent)` (libère la +/// FIFO — idempotent). Sur **succès**, on appelle [`BusyTurnGuard::disarm`] AVANT de +/// retourner : le `mark_idle` « propre » déjà présent reste en place et on évite tout +/// double `cancel_head` d'un ticket déjà résolu. +struct BusyTurnGuard { + input: Arc, + mailbox: Arc, + agent: AgentId, + ticket: TicketId, + armed: bool, +} + +impl BusyTurnGuard { + /// Arme le garde juste après l'`enqueue` (qui a démarré le tour ⇒ cible `Busy`). + fn new( + input: Arc, + mailbox: Arc, + agent: AgentId, + ticket: TicketId, + ) -> Self { + Self { + input, + mailbox, + agent, + ticket, + armed: true, + } + } + + /// Désarme le garde (branche succès) : le `Drop` devient un no-op. À appeler AVANT + /// de retourner le contenu, pour préserver le `mark_idle` propre déjà fait et ne pas + /// re-`cancel_head` un ticket déjà résolu. + fn disarm(mut self) { + self.armed = false; + } +} + +impl Drop for BusyTurnGuard { + fn drop(&mut self) { + if self.armed { + // Ordre : retirer le ticket fantôme de la FIFO PUIS libérer le busy state. + // `cancel_head` est positionnel/idempotent (no-op si le head n'est plus ce + // ticket) et `mark_idle` est idempotent (no-op si déjà Idle) ⇒ sûr quel que + // soit le chemin (erreur, timeout, drop). + self.mailbox.cancel_head(self.agent, self.ticket); + self.input.mark_idle(self.agent); + } + } +} /// Fournit les faits OS/runtime (exe + endpoint projet) pour écrire la déclaration MCP /// réelle quand l'orchestrateur (re)lance une cible sur le chemin `ask`. Implémenté dans @@ -182,6 +264,15 @@ pub struct OrchestratorService { /// injectée avec [`Self::record_turn`]. La couche `application` reste **pure** : pas /// de `SystemTime::now()` brut ici, le temps vient du port injecté au composition root. clock: Option>, + /// Registre des **sessions IA structurées** vivantes (§17.5), pour router un `ask` + /// vers une cible à `structured_adapter` directement sur sa session + /// ([`drain_with_readiness`](crate::agent::drain_with_readiness)) — le `Final` + /// déterministe réveille le `pending` **et** marque l'agent `Idle` (chantier + /// readiness/heartbeat, lot 1, fix du blocage `Busy`). Injecté via + /// [`Self::with_structured`] ; `None` ⇒ on conserve **uniquement** le chemin + /// PTY/mailbox legacy (zéro régression pour les call sites/tests qui ne le branchent + /// pas). + structured: Option>, } /// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés @@ -244,9 +335,19 @@ impl OrchestratorService { context_guard: None, record_turn: None, clock: None, + structured: None, } } + /// Branche le registre des [`StructuredSessions`] (§17.5) pour router un `ask` + /// vers une cible **structurée** sur sa propre session. Builder additif (signature + /// de [`Self::new`] inchangée) ; `None` ⇒ chemin PTY/mailbox legacy uniquement. + #[must_use] + pub fn with_structured(mut self, structured: Arc) -> Self { + self.structured = Some(structured); + self + } + /// Branche les use cases C7 (`context.*`/`memory.*`) sous /// [`domain::fileguard::FileGuard`]. Builder additif (signature de [`Self::new`] /// inchangée). @@ -416,7 +517,10 @@ impl OrchestratorService { target, content, requester, - } => self.propose_context(project, target, content, requester).await, + } => { + self.propose_context(project, target, content, requester) + .await + } OrchestratorCommand::ReadMemory { slug, requester } => { self.read_memory(project, slug, requester).await } @@ -453,10 +557,7 @@ impl OrchestratorService { }) .await?; Ok(OrchestratorOutcome { - detail: format!( - "read {} context", - target.as_deref().unwrap_or("project") - ), + detail: format!("read {} context", target.as_deref().unwrap_or("project")), reply: Some(md.into_string()), }) } @@ -481,15 +582,17 @@ impl OrchestratorService { }) .await?; let detail = match outcome { - ProposeOutcome::Written => format!( - "wrote {} context", - target.as_deref().unwrap_or("project") - ), + ProposeOutcome::Written => { + format!("wrote {} context", target.as_deref().unwrap_or("project")) + } ProposeOutcome::Proposed { path } => { format!("filed proposal for project context at {path}") } }; - Ok(OrchestratorOutcome { detail, reply: None }) + Ok(OrchestratorOutcome { + detail, + reply: None, + }) } /// `memory.read` → reads a note (or the index) under a shared read-lease; the @@ -748,15 +851,62 @@ impl OrchestratorService { // Poser l'arête d'attente A→B (retirée en fin de tour par le RAII `_edge`). let _edge = requester.map(|from| WaitEdgeGuard::new(self, from, agent_id)); + // ── Chemin **structuré** (readiness/heartbeat lot 1) ────────────────────── + // Une cible à `structured_adapter` n'a **pas** de PTY : `ensure_live_pty` + // échouerait, et le tour ne pourrait se débloquer que par un `idea_reply` + // explicite (cause racine du blocage `Busy`). Quand le registre structuré est + // câblé et que la cible a une session vivante, on draine son tour via + // `drain_with_readiness` : le `Final` déterministe réveille le `pending` (valeur + // de retour) **et** marque l'agent `Idle` (`mark_idle`). On enregistre tout de + // même un ticket dans la FIFO pour la comptabilité busy et pour préserver + // `idea_reply` comme signal **alternatif** (premier arrivé gagne). + if let Some(structured) = self.structured.as_ref() { + // Lot 1b — auto-lancement d'une cible **froide** structurée : si le registre + // est câblé, qu'aucune session ne vit encore pour la cible, mais que son + // profil porte un `structured_adapter`, on **démarre** sa session via le + // launcher (qui route §17.4 vers `launch_structured` et l'insère dans CE + // même registre, avec la conf MCP matérialisée) plutôt que de tomber dans + // `ensure_live_pty` — chemin PTY qui échouerait pour une cible sans PTY. + // Une cible SANS `structured_adapter` (agent PTY/TUI legacy) conserve le + // chemin `ensure_live_pty` ci-dessous (zéro régression). + if let Some(session) = self + .ensure_structured_session(project, agent_id, &agent.profile_id, structured) + .await? + { + return self + .ask_structured( + project, + agent_id, + &target, + conversation_id, + requester, + task, + session.as_ref(), + ) + .await; + } + } + // 1. Garantir la cible vivante en PTY pour CE fil ; lier sa session à la // conversation, et brancher son handle d'entrée sur le médiateur (livraison). - let handle = self + let (handle, cold_launch) = self .ensure_live_pty(project, agent_id, conversation_id, &target) .await?; // Arm prompt-ready detection (C5) with the target profile's literal marker, so a // return-to-prompt frees the turn (the other OR signal being `idea_reply`). - let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await; - input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern); + let (prompt_pattern, submit, has_mcp) = + self.prompt_and_submit_for_agent(project, agent_id).await; + // Gate cold-launch : un agent froid n'est pas encore à son prompt. On diffère le + // 1er tour s'il existe un signal pour le libérer — soit le prompt-ready watcher + // (pattern non vide), soit la connexion du pont MCP de l'agent + // (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Sans aucun + // des deux ⇒ pas de gate (livraison immédiate, sinon blocage indéfini). + let gate_cold_start = + cold_launch && (prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()) || has_mcp); + if gate_cold_start { + input.mark_starting(agent_id); + } + input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern, submit); // 2. Enregistrer le ticket (slot de réponse) + livrer le tour via le médiateur // (écriture sérialisée dans le PTY — plus d'écriture ad hoc ici). Le ticket @@ -784,14 +934,28 @@ impl OrchestratorService { } None => Ticket::from_human(ticket_id, conversation_id, requester_label, task), }; + // Timeout de tour piloté par profil (lot 2) : `turn_timeout_ms` de la cible si + // défini, sinon le défaut [`ASK_AGENT_TIMEOUT`]. Arme aussi le seuil de stall sur + // le médiateur AVANT l'enqueue (consommé au start_turn). + let turn_timeout = self.turn_timeout_for(project, agent_id).await; let pending = input.enqueue(agent_id, ticket); + // Garde RAII de fin de tour, armé JUSTE après l'enqueue (la cible est maintenant + // `Busy`). Quel que soit le chemin de sortie — erreur, timeout, ou **futur + // abandonné (drop)** — son `Drop` ramène la cible `Idle` et retire le ticket + // fantôme de la FIFO. C'est le fix de la cause racine (cf. [`BusyTurnGuard`]). + let busy_guard = BusyTurnGuard::new( + Arc::clone(input), + Arc::clone(mailbox), + agent_id, + ticket_id, + ); // Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the // turn into the bound handle). The service no longer writes the PTY directly — // no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1). - // 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ retirer le ticket - // (cible laissée vivante) et renvoyer une erreur typée. - match tokio::time::timeout(ASK_AGENT_TIMEOUT, pending).await { + // 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ le garde retire le ticket + // (cible laissée vivante) et la ramène `Idle` au Drop ; renvoie une erreur typée. + match tokio::time::timeout(turn_timeout, pending).await { Ok(Ok(result)) => { // Checkpoint Response (P6b, best-effort) : persister la réponse AVANT de // déplacer `result` dans `reply_outcome`. Source = la **cible** (c'est @@ -804,21 +968,145 @@ impl OrchestratorService { result.clone(), ) .await; + // Succès : désarmer le garde AVANT de retourner. Le `mark_idle` propre + // sur cette branche est porté par le médiateur (prompt-ready / idea_reply + // qui a résolu le `pending`) ; on ne veut ni re-`cancel_head` un ticket + // déjà résolu, ni libérer un busy state qui ne nous appartient plus. + busy_guard.disarm(); Ok(self.reply_outcome(agent_id, &target, result)) } - Ok(Err(_cancelled)) => { - mailbox.cancel_head(agent_id, ticket_id); - Err(AppError::Process(format!( - "agent {target} : canal de réponse fermé avant un résultat" - ))) - } - Err(_elapsed) => { - mailbox.cancel_head(agent_id, ticket_id); - Err(AppError::from(domain::ports::AgentSessionError::Timeout)) - } + // Erreur / timeout : on laisse le garde faire `cancel_head` + `mark_idle` au + // Drop (retrait des `cancel_head` redondants — `cancel_head` reste idempotent). + Ok(Err(_cancelled)) => Err(AppError::Process(format!( + "agent {target} : canal de réponse fermé avant un résultat" + ))), + Err(_elapsed) => Err(AppError::from(domain::ports::AgentSessionError::Timeout)), } } + /// Chemin `ask` **structuré** (readiness/heartbeat lot 1) : la cible a un + /// `structured_adapter` et une session vivante ([`StructuredSessions`]). On pilote + /// son tour **directement** sur le port [`domain::ports::AgentSession`] et on le + /// draine via [`drain_with_readiness`] : le [`domain::ports::ReplyEvent::Final`] + /// déterministe rend le contenu (réveille le `pending`) **et** marque l'agent `Idle` + /// (`mark_idle`), sans dépendre d'un `idea_reply` explicite ni d'un PTY (que la cible + /// n'a pas). C'est le fix de la cause racine du blocage `Busy`. + /// + /// On enregistre tout de même un ticket dans la FIFO (`enqueue`) pour la comptabilité + /// busy et pour **préserver `idea_reply` comme signal alternatif** : on attend la + /// **première** des deux issues (réponse de la session OU résolution du `pending`). + /// Le checkpoint Prompt/Response best-effort est conservé à l'identique du chemin PTY. + #[allow(clippy::too_many_arguments)] + async fn ask_structured( + &self, + project: &Project, + agent_id: AgentId, + target: &str, + conversation_id: domain::conversation::ConversationId, + requester: Option, + task: String, + session: &dyn domain::ports::AgentSession, + ) -> Result { + let (input, mailbox) = match (&self.input, &self.mailbox) { + (Some(i), Some(m)) => (i, m), + _ => { + return Err(AppError::Invalid( + "la messagerie inter-agents (idea_ask_agent) n'est pas disponible : \ + médiateur d'entrée non câblé" + .to_owned(), + )) + } + }; + + // Checkpoint Prompt (best-effort), AVANT de déplacer `task` dans le ticket. + let prompt_source = match requester { + Some(from) => InputSource::agent(from), + None => InputSource::Human, + }; + self.record_turn_best_effort( + &project.root, + conversation_id, + prompt_source, + TurnRole::Prompt, + task.clone(), + ) + .await; + + // Ticket dans la FIFO : comptabilité busy + `idea_reply` comme signal alternatif. + let requester_label = self.requester_label(project, requester).await; + let ticket_id = TicketId::new_random(); + let ticket = match requester { + Some(from) => Ticket::from_agent( + ticket_id, + from, + conversation_id, + requester_label, + task.clone(), + ), + None => Ticket::from_human(ticket_id, conversation_id, requester_label, task.clone()), + }; + // Timeout de tour piloté par profil (lot 2) + armement du seuil de stall, AVANT + // l'enqueue qui démarre le tour (le médiateur arme alors sa fenêtre de vivacité). + let turn_timeout = self.turn_timeout_for(project, agent_id).await; + let pending = input.enqueue(agent_id, ticket); + // Garde RAII de fin de tour, armé JUSTE après l'enqueue (cible `Busy`). Couvre + // toutes les sorties — `return Err` des bras du select, OU **futur abandonné + // (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est + // le fix de la cause racine du blocage `Busy` à vie. + let busy_guard = BusyTurnGuard::new( + Arc::clone(input), + Arc::clone(mailbox), + agent_id, + ticket_id, + ); + + // Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`), borné par le + // **même** garde-fou que le chemin PTY (profil ou défaut). On attend la + // **première** issue : le tour structuré OU un `idea_reply` explicite. + let drain = + drain_with_readiness(session, &task, Some(turn_timeout), input.as_ref(), agent_id); + + let result = tokio::select! { + biased; + // Issue déterministe : la session a rendu son `Final`. + drained = drain => match drained { + Ok(content) => content, + // Erreur de drain : le garde fait `cancel_head` + `mark_idle` au Drop. + Err(err) => return Err(AppError::from(err)), + }, + // Issue alternative : un `idea_reply` explicite a résolu le ticket d'abord. + replied = pending => match replied { + Ok(content) => { + // La session draine encore en arrière-plan ; on s'assure que la FIFO + // avance même si le `Final` n'est pas encore observé. + input.mark_idle(agent_id); + content + } + // Canal fermé : le garde fait `cancel_head` + `mark_idle` au Drop. + Err(_cancelled) => { + return Err(AppError::Process(format!( + "agent {target} : canal de réponse fermé avant un résultat" + ))); + } + }, + }; + + // Succès : désarmer le garde (le `mark_idle` propre est déjà porté par le `Final` + // de la session ou par le bras `replied`) — pas de double `cancel_head`. + busy_guard.disarm(); + + // Checkpoint Response (best-effort), AVANT de déplacer `result`. + self.record_turn_best_effort( + &project.root, + conversation_id, + InputSource::agent(agent_id), + TurnRole::Response, + result.clone(), + ) + .await; + Ok(self.reply_outcome(agent_id, target, result)) + } + /// `SubmitHumanInput` (cadrage C4 §5.3) — the **human** Envoyer path. /// /// The operator types into IdeA's mediated input; this resolves the `User↔Agent` @@ -863,11 +1151,22 @@ impl OrchestratorService { // Ensure the target is live for this thread and bind its input handle on the // mediator (delivery path). Same call the ask path uses. - let handle = self + let (handle, cold_launch) = self .ensure_live_pty(project, agent_id, conversation_id, target) .await?; - let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await; - input.bind_handle_with_prompt(agent_id, handle, prompt_pattern); + let (prompt_pattern, submit, has_mcp) = + self.prompt_and_submit_for_agent(project, agent_id).await; + // Gate cold-launch : un agent froid n'est pas encore à son prompt. On diffère le + // 1er tour s'il existe un signal pour le libérer — soit le prompt-ready watcher + // (pattern non vide), soit la connexion du pont MCP de l'agent + // (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Sans aucun + // des deux ⇒ pas de gate (livraison immédiate, sinon blocage indéfini). + let gate_cold_start = + cold_launch && (prompt_pattern.as_ref().is_some_and(|p| !p.is_empty()) || has_mcp); + if gate_cold_start { + input.mark_starting(agent_id); + } + input.bind_handle_with_prompt(agent_id, handle, prompt_pattern, submit); // Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and- // forget: we drop the PendingReply (the human reads the terminal). The @@ -922,6 +1221,54 @@ impl OrchestratorService { }) } + /// **Ack de livraison** (ARCHITECTURE §20.3) — best-effort, observabilité only. + /// + /// The frontend write-portal calls this (via the `delegation_delivered` Tauri + /// command) once it has **physically written** a delegation `ticket` into the agent's + /// native PTY, to distinguish "queued because the human line was busy" from "written" + /// in logs/observability. It **does not** change correlation: the requester's `ask` + /// is still woken by `idea_reply`→`resolve_ticket`, and the per-turn timeout/cycle + /// guards are untouched. It is a pure log no-op when nothing is wired, so a missing + /// mediator/registry never breaks the rendezvous. + pub fn note_delegation_delivered( + &self, + project: &Project, + agent_id: AgentId, + ticket: TicketId, + ) { + // Observability beacon only: never mutates the mailbox/busy state nor resolves + // the ticket (that stays `idea_reply`-driven). Kept best-effort by construction — + // a pure, infallible hook so a missing mediator/registry never breaks the + // rendezvous. The application crate carries no logging framework (zero new dep); + // the ack is materialised as an `eprintln!` trace, the same lightweight channel + // the orchestrator already uses for best-effort diagnostics. + let _ = project; + eprintln!( + "[orchestrator] delegation delivered into agent {agent_id}'s native terminal \ + (front ack, ticket {ticket})" + ); + } + + /// Libère le premier tour différé d'un agent **lancé à froid** quand son pont MCP se + /// connecte (readiness de démarrage). Pont entre l'McpServer (adapter entrant) et le + /// médiateur d'entrée. No-op si aucun médiateur n'est câblé ou si rien n'est différé. + pub fn release_agent_cold_start(&self, agent: domain::AgentId) { + if let Some(input) = &self.input { + input.release_cold_start(agent); + } + } + + /// Déclare si une **cellule terminal frontend** est montée pour `agent` (write-portal + /// actif). Pont entre le write-portal (adapter UI) et le médiateur : un agent avec + /// cellule reçoit ses tours via l'événement `DelegationReady` (le front écrit) ; un + /// agent **headless** (délégué en arrière-plan, sans cellule) voit le médiateur écrire + /// lui-même la tâche dans son PTY — sinon le tour est perdu. No-op sans médiateur. + pub fn set_agent_front_attached(&self, agent: domain::AgentId, attached: bool) { + if let Some(input) = &self.input { + input.set_front_attached(agent, attached); + } + } + /// Resolves the conversation thread id for an ask: `A↔B` when an agent requests, /// else `User↔B` (cadrage C3 §5.2). Without a wired registry, falls back to a /// stable per-agent id derived from the target (legacy routing — never panics). @@ -944,7 +1291,6 @@ impl OrchestratorService { } } - /// `agent.reply` / `idea_reply`: the target agent renders the result of the task /// it is currently processing (Option 1, lot B-4). /// @@ -997,13 +1343,19 @@ impl OrchestratorService { /// a launch the handle is resolved from the registry; a missing handle is a /// [`AppError::Process`] (the launch did not register a PTY session, e.g. a profile /// IdeA cannot drive as a terminal). + /// + /// Le booléen renvoyé indique un **lancement à froid** (`true` quand l'agent n'avait + /// pas de session vivante et vient d'être démarré) : l'appelant l'utilise pour + /// *gater* le premier tour sur le prompt-ready (cf. [`InputMediator::mark_starting`]), + /// car un agent froid n'est pas encore à son prompt. `false` ⇒ session réutilisée + /// (agent déjà chaud) ⇒ livraison immédiate, comportement inchangé. async fn ensure_live_pty( &self, project: &Project, agent_id: AgentId, conversation_id: domain::conversation::ConversationId, target: &str, - ) -> Result { + ) -> Result<(PtyHandle, bool), AppError> { // «1 session vivante / conversation» (cadrage C3 §5.2) : on cherche d'abord la // session du **fil**, puis on retombe sur la session de l'agent (compat : un // agent mono-fil dont la session n'a pas encore été liée à sa conversation). @@ -1013,9 +1365,10 @@ impl OrchestratorService { .or_else(|| self.sessions.session_for_agent(&agent_id)); if let Some(session_id) = existing { if let Some(handle) = self.sessions.handle(&session_id) { - // (Re)lier le fil à cette session vivante (idempotent). + // (Re)lier le fil à cette session vivante (idempotent). Réutilisation + // d'une session déjà chaude ⇒ pas de gate de démarrage à froid. self.bind_conversation_session(conversation_id, session_id); - return Ok(handle); + return Ok((handle, false)); } } @@ -1039,20 +1392,92 @@ impl OrchestratorService { }) .await?; - let session_id = self - .sessions - .session_for_agent(&agent_id) - .ok_or_else(|| { - AppError::Process(format!( - "agent {target} n'a pas de session terminal vivante après lancement" - )) - })?; + let session_id = self.sessions.session_for_agent(&agent_id).ok_or_else(|| { + AppError::Process(format!( + "agent {target} n'a pas de session terminal vivante après lancement" + )) + })?; // Lier la session fraîchement lancée à CE fil (registre terminal + registre de // conversations) ⇒ un prochain ask sur le même fil la réutilise. self.bind_conversation_session(conversation_id, session_id); - self.sessions.handle(&session_id).ok_or_else(|| { - AppError::Process(format!("handle PTY de l'agent {target} introuvable après lancement")) - }) + let handle = self.sessions.handle(&session_id).ok_or_else(|| { + AppError::Process(format!( + "handle PTY de l'agent {target} introuvable après lancement" + )) + })?; + // Lancement à froid : `true` ⇒ l'appelant gatera le premier tour sur le prompt. + Ok((handle, true)) + } + + /// Lot 1b — garantit une **session structurée vivante** pour la cible d'un `ask`. + /// + /// Retourne : + /// - `Ok(Some(session))` si la cible a déjà une session vivante, **ou** si son + /// profil porte un `structured_adapter` et qu'on vient de la (re)lancer ; + /// - `Ok(None)` si la cible n'est **pas** structurée (profil sans `structured_adapter`, + /// ou profil introuvable) ⇒ l'appelant retombe sur le chemin PTY `ensure_live_pty`. + /// + /// L'auto-lancement réutilise le **même** [`LaunchAgent`] que le chemin PTV + /// ([`Self::ensure_live_pty`]) avec le **même** `mcp_runtime` matérialisé : pour un + /// profil structuré, le launcher route §17.4 vers `launch_structured`, démarre la + /// session via la fabrique et l'insère dans le registre [`StructuredSessions`] + /// **partagé** (le même `Arc` que `self.structured`, câblé au composition root). + /// La conf MCP est donc matérialisée comme pour une cellule chat lancée à la main, + /// si bien que la cible voit les outils `idea_*` pour répondre. + async fn ensure_structured_session( + &self, + project: &Project, + agent_id: AgentId, + profile_id: &ProfileId, + structured: &Arc, + ) -> Result>, AppError> { + // Cible déjà chaude : route directe (aucun lancement). + if let Some(session) = structured.session_for_agent(&agent_id) { + return Ok(Some(session)); + } + + // Cible froide : ne (re)lancer que si le profil sait être piloté en mode + // structuré. Sinon (agent PTY/TUI legacy, ou profil introuvable) ⇒ chemin PTY. + let is_structured = self + .profiles + .list() + .await? + .into_iter() + .find(|p| &p.id == profile_id) + .is_some_and(|p| p.is_selectable()); + if !is_structured { + return Ok(None); + } + + // Démarrer la session via le launcher (route §17.4 → `launch_structured`, + // insère dans CE registre). Mêmes faits MCP que le chemin PTY pour que le pont + // `idea_*` de la cible se branche. `conversation_id: None` ⇒ le launcher dérive + // l'id de paire (User↔agent) ou réutilise celui de la cellule (P8a), comme pour + // un lancement direct utilisateur. + self.launch_agent + .execute(LaunchAgentInput { + project: project.clone(), + agent_id, + rows: DEFAULT_ROWS, + cols: DEFAULT_COLS, + node_id: None, + conversation_id: None, + mcp_runtime: self + .mcp_runtime_provider + .as_ref() + .and_then(|p| p.runtime_for(project, agent_id)), + }) + .await?; + + // Le launcher a inséré la session dans le registre partagé : la relire. + structured + .session_for_agent(&agent_id) + .map(Some) + .ok_or_else(|| { + AppError::Process(format!( + "agent {agent_id} : aucune session structurée vivante après lancement" + )) + }) } /// Binds `conversation` to `session` in both the terminal registry (fast @@ -1287,8 +1712,6 @@ impl OrchestratorService { profile_id: &ProfileId, target: &str, ) -> Result<(), AppError> { - use domain::profile::{McpConfigStrategy, StructuredAdapter}; - let Some(profile) = self .profiles .list() @@ -1299,49 +1722,110 @@ impl OrchestratorService { return Ok(()); }; - let honours_mcp_json = matches!( - profile.mcp.as_ref().map(|c| &c.config), - Some(McpConfigStrategy::ConfigFile { target }) if target == ".mcp.json" - ); - let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude); - - if honours_mcp_json && is_claude { + // Source de vérité UNIQUE (domaine) : un profil supporte le pont `idea_*` ssi + // IdeA matérialise réellement sa config MCP pour la CLI qu'il pilote — Claude + // via `.mcp.json`, Codex via `config.toml`/`CODEX_HOME`. Tout autre couple + // (y compris MCP absent) ⇒ repli fichier, pont non branché. + if profile.materializes_idea_bridge() { return Ok(()); } Err(AppError::Invalid(format!( "la cible '{target}' (profil '{}', adaptateur {:?}) ne supporte pas encore le \ - pont idea_* : la délégation inter-agents passe par un serveur MCP déclaré en \ - .mcp.json, que seul un profil Claude consomme aujourd'hui. Cible un agent au \ - profil Claude.", + pont idea_* : la délégation inter-agents passe par un serveur MCP qu'IdeA \ + matérialise pour la CLI cible, et ce profil ne déclare pas un couple \ + (adaptateur × stratégie MCP) pris en charge. Cible un agent dont le profil \ + expose le pont MCP (Claude ou Codex).", profile.name, profile.structured_adapter ))) } - /// Resolves the **prompt-ready pattern** (cadrage §6, lot C5) of the agent's - /// profile, used to arm the [`InputMediator`]'s prompt detection at `bind_handle` - /// time. Returns `None` when the agent, its profile, or the pattern is absent — - /// the safe fallback: no pattern ⇒ Idle only via explicit signal/timeout. - async fn prompt_pattern_for_agent( + /// Resolves the target agent profile's **prompt-ready pattern** (§6, lot C5) **and** + /// its **submit config** (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) in + /// a single profile lookup. Both are carried into `bind_handle_with_prompt`: the + /// pattern arms prompt detection, the submit config is echoed on the next + /// `DelegationReady` so the frontend write-portal knows how to submit. Returns + /// `(None, default)` when the agent, its profile, or the field is absent — the safe + /// fallback (no pattern ⇒ Idle only via explicit signal/timeout; no submit ⇒ the + /// front applies its own `"\r"`/~60 ms default). + async fn prompt_and_submit_for_agent( &self, project: &Project, agent_id: AgentId, - ) -> Option { - let agent = self + ) -> (Option, SubmitConfig, bool) { + let Some(agent) = self .list_agents .execute(ListAgentsInput { project: project.clone(), }) .await - .ok()? - .agents - .into_iter() - .find(|a| a.id == agent_id)?; - let profiles = self.profiles.list().await.ok()?; - profiles - .into_iter() - .find(|p| p.id == agent.profile_id) - .and_then(|p| p.prompt_ready_pattern) + .ok() + .and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id)) + else { + return (None, SubmitConfig::default(), false); + }; + let Some(profile) = self + .profiles + .list() + .await + .ok() + .and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id)) + else { + return (None, SubmitConfig::default(), false); + }; + let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms); + // 3e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion + // (initialize) servira de signal de readiness de démarrage pour libérer un 1er + // tour différé — d'où le gate cold-launch même sans `prompt_ready_pattern`. + (profile.prompt_ready_pattern, submit, profile.mcp.is_some()) + } + + /// Résout la [`domain::profile::LivenessStrategy`] du profil de la cible (lot 2) : + /// le seuil de stagnation (`stall_after_ms`) et le timeout de tour + /// (`turn_timeout_ms`). `None`/absent ⇒ `(None, None)` : pas de détection de stall et + /// repli sur les bornes par défaut codées en dur (zéro régression pour un profil sans + /// bloc `liveness`). + async fn liveness_for_agent( + &self, + project: &Project, + agent_id: AgentId, + ) -> (Option, Option) { + let Some(agent) = self + .list_agents + .execute(ListAgentsInput { + project: project.clone(), + }) + .await + .ok() + .and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id)) + else { + return (None, None); + }; + let Some(profile) = self + .profiles + .list() + .await + .ok() + .and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id)) + else { + return (None, None); + }; + match profile.liveness { + Some(l) => (l.stall_after_ms, l.turn_timeout_ms), + None => (None, None), + } + } + + /// Borne de tour effective pour un `ask` : le `turn_timeout_ms` du profil de la cible + /// **quand il existe**, sinon le défaut historique [`ASK_AGENT_TIMEOUT`] (lot 2, + /// timeouts pilotés par profil). Arme aussi le seuil de stagnation sur le médiateur + /// avant l'enqueue (battement/`sweep_stalled`). + async fn turn_timeout_for(&self, project: &Project, agent_id: AgentId) -> Duration { + let (stall_after_ms, turn_timeout_ms) = self.liveness_for_agent(project, agent_id).await; + if let Some(input) = &self.input { + input.set_stall_threshold(agent_id, stall_after_ms); + } + resolve_turn_timeout(turn_timeout_ms) } /// Resolves a human-friendly profile reference (slug like `claude-code`, @@ -1420,6 +1904,18 @@ mod tests { use domain::profile::{AgentProfile, ContextInjection}; use domain::ProfileId; + // (c) — timeouts pilotés par profil (lot 2) : `turn_timeout_ms` prime sur le défaut. + #[test] + fn profile_turn_timeout_overrides_default() { + // Seuil de profil défini ⇒ il prime sur ASK_AGENT_TIMEOUT. + assert_eq!( + resolve_turn_timeout(Some(120_000)), + Duration::from_millis(120_000) + ); + // Absent (profil sans liveness / sans turn_timeout_ms) ⇒ repli sur le défaut. + assert_eq!(resolve_turn_timeout(None), ASK_AGENT_TIMEOUT); + } + fn profile(id: u128, name: &str, command: &str) -> AgentProfile { AgentProfile::new( ProfileId::from_uuid(uuid::Uuid::from_u128(id)), @@ -1452,4 +1948,109 @@ mod tests { assert!(by_name); assert_eq!(p.id.to_string(), p.id.to_string()); } + + // --- BusyTurnGuard (RAII de fin de tour) ------------------------------- + // + // Fakes minimaux pour observer ce que le garde appelle à son Drop : un médiateur + // qui enregistre les `mark_idle` et un mailbox qui enregistre les `cancel_head`. + + use std::sync::Mutex as TestMutex; + + #[derive(Default)] + struct SpyMediator { + idled: TestMutex>, + } + impl domain::input::InputMediator for SpyMediator { + fn enqueue( + &self, + _agent: AgentId, + _ticket: domain::mailbox::Ticket, + ) -> domain::mailbox::PendingReply { + domain::mailbox::PendingReply::new(Box::pin(async { + Err(domain::mailbox::MailboxError::Cancelled) + })) + } + fn preempt(&self, _agent: AgentId) {} + fn mark_idle(&self, agent: AgentId) { + self.idled.lock().unwrap().push(agent); + } + fn busy_state(&self, _agent: AgentId) -> domain::input::AgentBusyState { + domain::input::AgentBusyState::Idle + } + } + + #[derive(Default)] + struct SpyMailbox { + cancelled: TestMutex>, + } + impl domain::mailbox::AgentMailbox for SpyMailbox { + fn enqueue( + &self, + _agent: AgentId, + _ticket: domain::mailbox::Ticket, + ) -> domain::mailbox::PendingReply { + domain::mailbox::PendingReply::new(Box::pin(async { + Err(domain::mailbox::MailboxError::Cancelled) + })) + } + fn resolve( + &self, + _agent: AgentId, + _result: String, + ) -> Result<(), domain::mailbox::MailboxError> { + Ok(()) + } + fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) { + self.cancelled.lock().unwrap().push((agent, ticket_id)); + } + } + + fn aid(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) + } + fn tid(n: u128) -> TicketId { + TicketId::from_uuid(uuid::Uuid::from_u128(n)) + } + + /// Drop d'un garde **armé** ⇒ `cancel_head` + `mark_idle` sur la cible (c'est le + /// comportement qui débloque un agent resté `Busy` sur un futur abandonné). + #[test] + fn armed_guard_drop_cancels_head_and_marks_idle() { + let med = Arc::new(SpyMediator::default()); + let mb = Arc::new(SpyMailbox::default()); + { + let _g = BusyTurnGuard::new( + Arc::clone(&med) as Arc, + Arc::clone(&mb) as Arc, + aid(1), + tid(7), + ); + } // Drop ici. + assert_eq!(*med.idled.lock().unwrap(), vec![aid(1)], "mark_idle au Drop"); + assert_eq!( + *mb.cancelled.lock().unwrap(), + vec![(aid(1), tid(7))], + "cancel_head au Drop" + ); + } + + /// Garde **désarmé** (branche succès) ⇒ son Drop est un no-op : pas de `mark_idle` + /// parasite, surtout pas de `cancel_head` d'un ticket déjà résolu. + #[test] + fn disarmed_guard_drop_is_a_noop() { + let med = Arc::new(SpyMediator::default()); + let mb = Arc::new(SpyMailbox::default()); + let g = BusyTurnGuard::new( + Arc::clone(&med) as Arc, + Arc::clone(&mb) as Arc, + aid(1), + tid(7), + ); + g.disarm(); + assert!(med.idled.lock().unwrap().is_empty(), "pas de mark_idle après disarm"); + assert!( + mb.cancelled.lock().unwrap().is_empty(), + "pas de cancel_head après disarm" + ); + } } diff --git a/crates/application/src/permission.rs b/crates/application/src/permission.rs new file mode 100644 index 0000000..6b621e6 --- /dev/null +++ b/crates/application/src/permission.rs @@ -0,0 +1,150 @@ +//! Permission use cases. +//! +//! This module stays at the application boundary: it loads the project +//! permission document through [`PermissionStore`], applies simple mutations, and +//! delegates all merge semantics to the pure domain model. + +use std::sync::Arc; + +use domain::ports::PermissionStore; +use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions}; + +use crate::error::AppError; + +/// Reads the full project permission document. +pub struct GetProjectPermissions { + store: Arc, +} + +impl GetProjectPermissions { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the read. + pub async fn execute( + &self, + input: GetProjectPermissionsInput, + ) -> Result { + let permissions = self.store.load_permissions(&input.project).await?; + Ok(GetProjectPermissionsOutput { permissions }) + } +} + +/// Input for [`GetProjectPermissions`]. +pub struct GetProjectPermissionsInput { + /// Target project. + pub project: Project, +} + +/// Output for [`GetProjectPermissions`]. +pub struct GetProjectPermissionsOutput { + /// Persisted permission document. + pub permissions: ProjectPermissions, +} + +/// Replaces the project default policy. +pub struct UpdateProjectPermissions { + store: Arc, +} + +impl UpdateProjectPermissions { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the mutation. + pub async fn execute( + &self, + input: UpdateProjectPermissionsInput, + ) -> Result { + let mut doc = self.store.load_permissions(&input.project).await?; + doc.set_project_defaults(input.permissions); + self.store.save_permissions(&input.project, &doc).await?; + Ok(GetProjectPermissionsOutput { permissions: doc }) + } +} + +/// Input for [`UpdateProjectPermissions`]. +pub struct UpdateProjectPermissionsInput { + /// Target project. + pub project: Project, + /// New project default policy. `None` removes project defaults. + pub permissions: Option, +} + +/// Replaces one agent override. +pub struct UpdateAgentPermissions { + store: Arc, +} + +impl UpdateAgentPermissions { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the mutation. + pub async fn execute( + &self, + input: UpdateAgentPermissionsInput, + ) -> Result { + let mut doc = self.store.load_permissions(&input.project).await?; + doc.set_agent_permissions(input.agent_id, input.permissions); + self.store.save_permissions(&input.project, &doc).await?; + Ok(GetProjectPermissionsOutput { permissions: doc }) + } +} + +/// Input for [`UpdateAgentPermissions`]. +pub struct UpdateAgentPermissionsInput { + /// Target project. + pub project: Project, + /// Target agent. + pub agent_id: AgentId, + /// New agent policy. `None` removes the override. + pub permissions: Option, +} + +/// Resolves effective permissions for one agent. +pub struct ResolveAgentPermissions { + store: Arc, +} + +impl ResolveAgentPermissions { + /// Builds the use case. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Executes the resolution. + pub async fn execute( + &self, + input: ResolveAgentPermissionsInput, + ) -> Result { + let doc = self.store.load_permissions(&input.project).await?; + Ok(ResolveAgentPermissionsOutput { + effective: doc.resolve_for(input.agent_id), + }) + } +} + +/// Input for [`ResolveAgentPermissions`]. +pub struct ResolveAgentPermissionsInput { + /// Target project. + pub project: Project, + /// Target agent. + pub agent_id: AgentId, +} + +/// Output for [`ResolveAgentPermissions`]. +pub struct ResolveAgentPermissionsOutput { + /// Resolved policy, or `None` when neither project nor agent policy exists. + pub effective: Option, +} diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index 7df2ae0..b86e632 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -90,7 +90,12 @@ impl TerminalSessions { /// per-agent lookup for the orchestrator's ask path. #[must_use] pub fn session_for(&self, conversation: ConversationId) -> Option { - let sid = self.conversations.lock().ok()?.get(&conversation).copied()?; + let sid = self + .conversations + .lock() + .ok()? + .get(&conversation) + .copied()?; // Only return it if the session is still live in `entries`. self.entries .lock() diff --git a/crates/application/src/terminal/usecases.rs b/crates/application/src/terminal/usecases.rs index c9b8e03..013281b 100644 --- a/crates/application/src/terminal/usecases.rs +++ b/crates/application/src/terminal/usecases.rs @@ -79,6 +79,7 @@ impl OpenTerminal { cwd: cwd.clone(), env: Vec::new(), context_plan: None, + sandbox: None, }; // The PTY layer owns the session identity; we adopt the returned handle's diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index f7463ee..3b9988e 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -26,12 +26,18 @@ use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, - OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, - RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, + OutputStream, PermissionStore, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, + RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, +}; +use domain::permission::{ + EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile, + ProjectionContext, ProjectorKey, }; use domain::profile::{ - AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, SessionStrategy, + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, + SessionStrategy, StructuredAdapter, }; +use domain::{PermissionSet, ProjectPermissions}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; @@ -41,8 +47,8 @@ use uuid::Uuid; use application::{ CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent, - LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput, - TerminalSessions, UpdateAgentContext, UpdateAgentContextInput, + LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext, + ReadAgentContextInput, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput, }; // --------------------------------------------------------------------------- @@ -330,6 +336,7 @@ impl AgentRuntime for FakeRuntime { cwd: cwd.clone(), env: Vec::new(), context_plan: self.plan.clone(), + sandbox: None, }) } } @@ -352,6 +359,10 @@ struct FakeFs { /// recorded for them). Used by the MCP best-effort test to prove that an MCP /// config write failure never fails the launch. fail_writes_for: Arc>>, + /// Canned `path → contents` returned by [`FileSystem::read`] when no later write + /// to that path exists. Used by the `MergeToml` projection test to seed a + /// pre-existing `.codex/config.toml` carrying unmanaged user keys. + seeded_reads: Arc>>, } impl FakeFs { @@ -363,11 +374,27 @@ impl FakeFs { project_context: Arc::new(Mutex::new(None)), existing: Arc::new(Mutex::new(Vec::new())), fail_writes_for: Arc::new(Mutex::new(Vec::new())), + seeded_reads: Arc::new(Mutex::new(HashMap::new())), } } fn set_project_context(&self, content: &str) { *self.project_context.lock().unwrap() = Some(content.to_owned()); } + /// Seeds a canned content for `path` so [`FileSystem::read`] returns it until a + /// later [`FileSystem::write`] supersedes it (last-write-wins). + fn seed_read(&self, path: &str, content: &str) { + self.seeded_reads + .lock() + .unwrap() + .insert(path.to_owned(), content.to_owned()); + } + /// All writes whose path ends with `suffix` (e.g. the projected Codex config). + fn writes_ending_with(&self, suffix: &str) -> Vec<(String, Vec)> { + self.writes() + .into_iter() + .filter(|(p, _)| p.ends_with(suffix)) + .collect() + } /// Marks a path as already existing on disk, so [`FileSystem::exists`] returns /// `true` for it (non-clobbering scenarios). fn mark_existing(&self, path: &str) { @@ -411,16 +438,26 @@ impl FakeFs { #[async_trait] impl FileSystem for FakeFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { - if path.as_str().ends_with("/.ideai/CONTEXT.md") { + let p = path.as_str(); + // Last-write-wins: a previously written file reads back its latest content + // (so the MergeToml merge/idempotence test observes its own prior write). + if let Some((_, bytes)) = self.writes().into_iter().rev().find(|(wp, _)| wp == p) { + return Ok(bytes); + } + // Then any canned seed for this exact path. + if let Some(content) = self.seeded_reads.lock().unwrap().get(p) { + return Ok(content.clone().into_bytes()); + } + if p.ends_with("/.ideai/CONTEXT.md") { return self .project_context .lock() .unwrap() .clone() .map(|s| s.into_bytes()) - .ok_or_else(|| FsError::NotFound(path.as_str().to_owned())); + .ok_or_else(|| FsError::NotFound(p.to_owned())); } - Err(FsError::NotFound(path.as_str().to_owned())) + Err(FsError::NotFound(p.to_owned())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { let p = path.as_str(); @@ -831,17 +868,19 @@ async fn launch_orders_prepare_then_injection_then_spawn() { .await .expect("launch"); - // Ordering contract. The Claude permission seed is written first (right after - // the run dir is created), then prepare → injection (convention file) → spawn. + // Ordering contract (lot LP3-3): prepare → injection (convention file) → spawn. + // The permission projection no longer runs here — no projector registry is wired + // in this fixture (`LaunchAgent::new` without `with_permission_projectors`), so no + // extra `fs.write` precedes prepare. With a registry, the projection write would + // appear *after* the injection write and before spawn (see `apply_permission_projection`). assert_eq!( *tr.lock().unwrap(), vec![ - "fs.write".to_owned(), "prepare".to_owned(), "fs.write".to_owned(), "spawn".to_owned() ], - "seed → prepare → injection → spawn" + "prepare → injection → spawn" ); // The conventionFile was written inside the agent's isolated run directory @@ -862,24 +901,18 @@ async fn launch_orders_prepare_then_injection_then_spawn() { "convention file must carry the agent persona, got: {written}" ); - // Bug #5: a Claude permission seed was written into the run dir so the agent - // runs with the project's autonomy instead of prompting per command. - let seeds = fs.seed_writes(); - assert_eq!(seeds.len(), 1); - assert_eq!(seeds[0].0, format!("{run_dir}/.claude/settings.local.json")); - let seed = String::from_utf8(seeds[0].1.clone()).unwrap(); + // Lot LP3-3: the Claude permission seed is no longer written unconditionally + // here. It is now produced by the Claude permission projector and written only + // when a projector registry is wired (`with_permission_projectors`) — absent in + // this fixture — so no seed is written. (Projection coverage lives in the infra + // projector tests (LP3-2) and the LP3-3 projection wiring tests, QA.) assert!( - seed.contains("bypassPermissions"), - "seed grants autonomy: {seed}" - ); - assert!( - seed.contains("/home/me/proj"), - "seed grants the project root" + fs.seed_writes().is_empty(), + "no projector registry wired ⇒ no permission seed written" ); - // The run directory (and the seed's `.claude` subdir) were created before spawn. + // The run directory was created before spawn. assert_eq!(fs.created_run_dirs(), vec![run_dir.clone()]); - assert!(fs.created_dirs().contains(&format!("{run_dir}/.claude"))); // Spawn happened at the isolated run dir with the profile command. let spawns = pty.spawns(); @@ -1312,7 +1345,9 @@ async fn launch_conventionfile_injects_project_memory_in_order() { "memory section present: {doc}" ); assert!( - doc.contains("- [Git optionnel](.ideai/memory/git-optional.md) — git reste un simple tool (project)"), + doc.contains( + "- [Git optionnel](.ideai/memory/git-optional.md) — git reste un simple tool (project)" + ), "first memory line exact format: {doc}" ); assert!( @@ -1589,23 +1624,23 @@ async fn launch_without_mcp_writes_no_mcp_config_and_leaves_spec_untouched() { async fn launch_mcp_config_file_writes_declaration_at_run_dir_target() { // Test 2 — ConfigFile { target: ".mcp.json" } ⇒ a file is written at // /.mcp.json with a non-empty, coherent MCP server declaration. - let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = - launch_fixture_with_profile( - profile_with_mcp( - pid(9), - McpConfigStrategy::config_file(".mcp.json").unwrap(), - ), - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); launch.execute(launch_input(agent.id)).await.unwrap(); let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); let mcp = writes_ending_with(&fs, "/.mcp.json"); assert_eq!(mcp.len(), 1, "exactly one MCP config file written"); - assert_eq!(mcp[0].0, format!("{run_dir}/.mcp.json"), "written at run dir"); + assert_eq!( + mcp[0].0, + format!("{run_dir}/.mcp.json"), + "written at run dir" + ); let body = String::from_utf8(mcp[0].1.clone()).unwrap(); assert!(!body.trim().is_empty(), "MCP declaration is non-empty"); @@ -1615,25 +1650,20 @@ async fn launch_mcp_config_file_writes_declaration_at_run_dir_target() { "MCP declaration must declare the IdeA MCP server, got: {body}" ); // It is valid JSON. - let _: serde_json::Value = - serde_json::from_str(&body).expect("MCP declaration is valid JSON"); + let _: serde_json::Value = serde_json::from_str(&body).expect("MCP declaration is valid JSON"); } #[tokio::test] async fn launch_mcp_config_file_is_non_clobbering() { // Test 3 — if /.mcp.json already exists, the launch must NOT overwrite // it: no write is recorded against that path. - let profile = profile_with_mcp( - pid(9), - McpConfigStrategy::config_file(".mcp.json").unwrap(), + let profile = profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()); + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), ); - let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = - launch_fixture_with_profile( - profile, - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); // Pre-existing MCP config file on disk. let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); fs.mark_existing(&format!("{run_dir}/.mcp.json")); @@ -1649,17 +1679,13 @@ async fn launch_mcp_config_file_is_non_clobbering() { #[tokio::test] async fn launch_mcp_config_file_write_failure_is_best_effort() { // Test 4 — a write failure on the MCP config file must NOT fail the launch. - let profile = profile_with_mcp( - pid(9), - McpConfigStrategy::config_file(".mcp.json").unwrap(), + let profile = profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()); + let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile, + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), ); - let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = - launch_fixture_with_profile( - profile, - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); // Force the MCP config write to fail. fs.fail_write_suffix("/.mcp.json"); @@ -1676,13 +1702,12 @@ async fn launch_mcp_config_file_write_failure_is_best_effort() { async fn launch_mcp_flag_appends_flag_and_path_to_args() { // Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed // by the run-dir config path. - let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = - launch_fixture_with_profile( - profile_with_mcp(pid(9), McpConfigStrategy::flag("--mcp-config").unwrap()), - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); + let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::flag("--mcp-config").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); launch.execute(launch_input(agent.id)).await.unwrap(); @@ -1704,13 +1729,12 @@ async fn launch_mcp_flag_appends_flag_and_path_to_args() { #[tokio::test] async fn launch_mcp_env_appends_var_and_path_to_env() { // Test 6 — Env { var: "IDEA_MCP" } ⇒ spec.env carries (IDEA_MCP, ). - let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = - launch_fixture_with_profile( - profile_with_mcp(pid(9), McpConfigStrategy::env("IDEA_MCP").unwrap()), - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); + let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::env("IDEA_MCP").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); launch.execute(launch_input(agent.id)).await.unwrap(); @@ -1761,13 +1785,12 @@ async fn launch_mcp_runtime_writes_real_declaration_with_ordered_args() { // declaration is the REAL one: command == exe, and args == the ordered // ["mcp-server","--endpoint",endpoint,"--project",project_id,"--requester", // requester]. Structure-asserted via parsed JSON (not a fragile string match). - let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = - launch_fixture_with_profile( - profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); let exe = "/abs/idea"; let endpoint = "/run/idea-mcp/abc.sock"; @@ -1820,13 +1843,12 @@ async fn launch_mcp_runtime_special_chars_stay_valid_json() { // M5d-2 — an exe/endpoint with a space and a backslash ⇒ the .mcp.json stays // valid JSON (parse OK, asserted by read_single_mcp_json) and the values are // faithfully preserved (correct escaping, no corruption). - let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = - launch_fixture_with_profile( - profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); let exe = r"C:\Program Files\IdeA\idea.exe"; let endpoint = r"/tmp/idea mcp/a b\c.sock"; @@ -1867,13 +1889,12 @@ async fn launch_mcp_runtime_special_chars_stay_valid_json() { async fn launch_mcp_runtime_none_writes_minimal_declaration() { // M5d-3 — mcp_runtime = None + an MCP profile ⇒ the minimal declaration is // written: command == "idea", args == ["mcp-server"]. Still valid JSON. - let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = - launch_fixture_with_profile( - profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); // launch_input has mcp_runtime: None. launch.execute(launch_input(agent.id)).await.unwrap(); @@ -1928,22 +1949,28 @@ async fn launch_mcp_runtime_with_no_mcp_profile_writes_nothing() { } #[tokio::test] -async fn launch_mcp_runtime_is_non_clobbering() { - // M5d-5 — a pre-existing .mcp.json is NOT overwritten, even when a real runtime - // is injected (unchanged non-clobbering contract from M1). - let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = - launch_fixture_with_profile( - profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), - Some(ContextInjectionPlan::File { - target: "CLAUDE.md".to_owned(), - }), - ); +async fn launch_mcp_runtime_clobbers_stale_config_with_fresh_declaration() { + // M5d-5 — with a real injected runtime, a pre-existing .mcp.json MUST be + // regenerated and CLOBBERED on every (re)launch: its `command` (the IdeA exe + // path) and endpoint drift between runs (the AppImage internal mount path + // changes at each remount/reboot), so a stale declaration would point the bridge + // at a dead binary/socket. `.mcp.json` is IdeA-managed, so clobbering is safe. + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); + // A stale file already on disk (e.g. written by a previous run pointing at a + // now-dead AppImage mount). fs.mark_existing(&format!("{run_dir}/.mcp.json")); + let exe = "/abs/idea"; + let endpoint = "/run/idea-mcp/abc.sock"; let runtime = application::McpRuntime { - exe: "/abs/idea".to_owned(), - endpoint: "/run/idea-mcp/abc.sock".to_owned(), + exe: exe.to_owned(), + endpoint: endpoint.to_owned(), project_id: "0123456789abcdef0123456789abcdef".to_owned(), requester: "agent-7".to_owned(), }; @@ -1953,9 +1980,118 @@ async fn launch_mcp_runtime_is_non_clobbering() { .await .unwrap(); + // The fresh declaration is written despite the pre-existing file, and it carries + // the current exe path / endpoint. + let doc = read_single_mcp_json(&fs); + let server = &doc["mcpServers"]["idea"]; + assert_eq!( + server["command"].as_str(), + Some(exe), + "a stale .mcp.json must be clobbered with the fresh exe path, got: {doc}" + ); + let args: Vec<&str> = server["args"] + .as_array() + .expect("args must be a JSON array") + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!( + args.get(2).copied(), + Some(endpoint), + "the clobbered declaration must carry the fresh endpoint, got: {args:?}" + ); +} + +#[tokio::test] +async fn launch_mcp_runtime_none_is_non_clobbering() { + // M5d-5b — WITHOUT a runtime (orchestrator / hot-swap / tests) only the degraded + // minimal declaration is available, so a pre-existing .mcp.json must NOT be + // overwritten: we never replace a real declaration with the minimal one. + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); + fs.mark_existing(&format!("{run_dir}/.mcp.json")); + + // launch_input has mcp_runtime: None. + launch.execute(launch_input(agent.id)).await.unwrap(); + assert!( writes_ending_with(&fs, "/.mcp.json").is_empty(), - "an existing .mcp.json must not be clobbered even with an injected runtime" + "without a runtime, an existing .mcp.json must not be clobbered with the minimal decl" + ); +} + +#[tokio::test] +async fn launch_mcp_runtime_remount_clobbers_with_run_specific_facts() { + // M5d-5c (QA) — direct reproduction of the AppImage-remount bug: across two + // independent app sessions (= two reboots, each with a *different* mount path + // and a *different* loopback socket), each launch must clobber a pre-existing + // stale `.mcp.json` with the facts of THAT run. This proves the file is rebuilt + // per-launch from the live runtime — never frozen on a previous run's dead + // binary/socket. Two fixtures are used on purpose: relaunching the *same* live + // agent would short-circuit through the reattach guard (no respawn), so it + // would not exercise a second `apply_mcp_config`. + let launch_once = |exe: &'static str, endpoint: &'static str| async move { + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( + profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); + // A stale declaration left by the previous boot is already on disk. + fs.mark_existing(&format!("{run_dir}/.mcp.json")); + + let runtime = application::McpRuntime { + exe: exe.to_owned(), + endpoint: endpoint.to_owned(), + project_id: "0123456789abcdef0123456789abcdef".to_owned(), + requester: "agent-7".to_owned(), + }; + launch + .execute(launch_input_with_runtime(agent.id, runtime)) + .await + .unwrap(); + + let doc = read_single_mcp_json(&fs); + let server = &doc["mcpServers"]["idea"]; + let command = server["command"].as_str().unwrap().to_owned(); + let args: Vec = server["args"] + .as_array() + .expect("args must be a JSON array") + .iter() + .map(|v| v.as_str().unwrap().to_owned()) + .collect(); + (command, args) + }; + + // Boot #1: mount path A + socket A. + let exe_a = "/tmp/.mount_IdeA_AAA/idea"; + let endpoint_a = "/run/idea-mcp/boot-a.sock"; + let (command_a, args_a) = launch_once(exe_a, endpoint_a).await; + assert_eq!(command_a, exe_a, "boot #1 must carry mount path A"); + assert_eq!(args_a.get(2).map(String::as_str), Some(endpoint_a)); + + // Boot #2: a *new* mount path + a *new* socket (the very drift that caused the + // bug). The declaration must follow the live facts, not the previous boot's. + let exe_b = "/tmp/.mount_IdeA_BBB/idea"; + let endpoint_b = "/run/idea-mcp/boot-b.sock"; + let (command_b, args_b) = launch_once(exe_b, endpoint_b).await; + assert_eq!(command_b, exe_b, "boot #2 must carry the NEW mount path"); + assert_eq!(args_b.get(2).map(String::as_str), Some(endpoint_b)); + + // And nothing from the stale boot #1 leaks into the boot #2 declaration. + assert_ne!( + command_b, exe_a, + "boot #2 must not reuse boot #1's dead exe" + ); + assert!( + !args_b.iter().any(|a| a == endpoint_a), + "boot #2 must not reuse boot #1's dead endpoint, got: {args_b:?}" ); } @@ -2122,11 +2258,7 @@ impl HandoffStore for FakeHandoffStore { async fn load(&self, conversation: ConversationId) -> Result, StoreError> { Ok(self.0.lock().unwrap().get(&conversation).cloned()) } - async fn save( - &self, - conversation: ConversationId, - handoff: Handoff, - ) -> Result<(), StoreError> { + async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> { self.0.lock().unwrap().insert(conversation, handoff); Ok(()) } @@ -2338,8 +2470,8 @@ async fn launch_with_store_without_handoff_omits_resume_section() { #[tokio::test] async fn reopened_cell_loads_handoff_saved_under_resolve_key_end_to_end() { let agent_party = ConversationParty::agent(aid(1)); // l'agent du harnais. - // (1) Clé de SAUVEGARDE = resolve_conversation(None, agent) avec registre câblé - // == for_pair(User, agent) (égalité scellée Bloc 1, côté infrastructure). + // (1) Clé de SAUVEGARDE = resolve_conversation(None, agent) avec registre câblé + // == for_pair(User, agent) (égalité scellée Bloc 1, côté infrastructure). let save_key = ConversationId::for_pair(ConversationParty::User, agent_party); // (2) Clé que P8a persiste sur la cellule neuve, donc portée à la réouverture. let persisted_on_leaf = ConversationId::for_pair(ConversationParty::User, agent_party); @@ -2383,14 +2515,15 @@ async fn reopened_cell_loads_handoff_saved_under_resolve_key_end_to_end() { #[tokio::test] async fn reopened_cell_does_not_load_handoff_saved_under_a_different_pair_key() { let agent_party = ConversationParty::agent(aid(1)); // agent du harnais. - // La cellule porte SA clé de paire (celle que P8a aurait persistée)… + // La cellule porte SA clé de paire (celle que P8a aurait persistée)… let leaf_key = ConversationId::for_pair(ConversationParty::User, agent_party); // …mais le handoff est rangé sous la clé d'une AUTRE paire (autre agent). - let other_key = ConversationId::for_pair( - ConversationParty::User, - ConversationParty::agent(aid(2)), + let other_key = + ConversationId::for_pair(ConversationParty::User, ConversationParty::agent(aid(2))); + assert_ne!( + leaf_key, other_key, + "préalable : deux clés de paire distinctes" ); - assert_ne!(leaf_key, other_key, "préalable : deux clés de paire distinctes"); let store = FakeHandoffStore::with(other_key, handoff_for("ne doit pas fuiter", Some("X"))); let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc; @@ -2407,3 +2540,551 @@ async fn reopened_cell_does_not_load_handoff_saved_under_a_different_pair_key() "handoff d'une autre paire ⇒ aucune reprise (pas de fuite de clé): {doc}" ); } + +// =========================================================================== +// LP3-3 — permission projection wiring (apply_permission_projection + registry +// + MCP decoupling). FS-mocked, projectors are faithful test doubles (the real +// translation is covered by the infra LP3-2 tests; application must stay +// dependency-free of `infrastructure`). +// =========================================================================== + +const CLAUDE_SEED_REL: &str = "/.claude/settings.local.json"; +const CODEX_CONFIG_REL: &str = "/.codex/config.toml"; + +/// In-memory [`PermissionStore`] returning a fixed document (LP3-3 wiring tests). +struct FakePermissionStore(ProjectPermissions); + +#[async_trait] +impl PermissionStore for FakePermissionStore { + async fn load_permissions(&self, _project: &Project) -> Result { + Ok(self.0.clone()) + } + async fn save_permissions( + &self, + _project: &Project, + _permissions: &ProjectPermissions, + ) -> Result<(), StoreError> { + Ok(()) + } +} + +/// Faithful Claude projector double: emits a single owned `Replace` settings file +/// whose `defaultMode` mirrors the real posture→mode mapping (Allow→bypass, +/// Ask→acceptEdits, Deny→plan), and embeds the project root verbatim. `eff == None` +/// ⇒ empty projection. +struct FakeClaudeProjector; + +impl PermissionProjector for FakeClaudeProjector { + fn key(&self) -> ProjectorKey { + ProjectorKey::Claude + } + fn project( + &self, + eff: Option<&EffectivePermissions>, + ctx: &ProjectionContext, + ) -> PermissionProjection { + let Some(eff) = eff else { + return PermissionProjection::empty(); + }; + let mode = match eff.fallback() { + Posture::Deny => "plan", + Posture::Ask => "acceptEdits", + Posture::Allow => "bypassPermissions", + }; + let contents = format!( + "{{\"permissions\":{{\"defaultMode\":\"{mode}\",\"additionalDirectories\":[\"{}\"]}}}}\n", + ctx.project_root + ); + PermissionProjection { + files: vec![ProjectedFile::Replace { + rel_path: ".claude/settings.local.json".to_owned(), + contents, + }], + args: Vec::new(), + env: Vec::new(), + } + } + fn owned_replace_paths(&self) -> Vec { + vec![".claude/settings.local.json".to_owned()] + } +} + +/// Faithful Codex projector double: emits a co-owned `MergeToml` over the two +/// managed keys + the matching `--sandbox`/`--ask-for-approval` args, both derived +/// from the posture exactly like the real projector. `eff == None` ⇒ empty. +struct FakeCodexProjector; + +impl FakeCodexProjector { + fn modes(fallback: Posture) -> (&'static str, &'static str) { + match fallback { + Posture::Deny => ("read-only", "on-request"), + Posture::Ask => ("workspace-write", "on-request"), + Posture::Allow => ("workspace-write", "never"), + } + } +} + +impl PermissionProjector for FakeCodexProjector { + fn key(&self) -> ProjectorKey { + ProjectorKey::Codex + } + fn project( + &self, + eff: Option<&EffectivePermissions>, + _ctx: &ProjectionContext, + ) -> PermissionProjection { + let Some(eff) = eff else { + return PermissionProjection::empty(); + }; + let (sandbox, approval) = Self::modes(eff.fallback()); + let contents = + format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n"); + PermissionProjection { + files: vec![ProjectedFile::MergeToml { + rel_path: ".codex/config.toml".to_owned(), + managed_tables: Vec::new(), + managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()], + contents, + }], + args: vec![ + "--sandbox".to_owned(), + sandbox.to_owned(), + "--ask-for-approval".to_owned(), + approval.to_owned(), + ], + env: Vec::new(), + } + } + fn owned_replace_paths(&self) -> Vec { + Vec::new() + } +} + +/// A registry carrying both faithful projector doubles. +fn full_registry() -> Arc { + Arc::new( + PermissionProjectorRegistry::new() + .with(Arc::new(FakeClaudeProjector)) + .with(Arc::new(FakeCodexProjector)), + ) +} + +/// A project document whose project-level fallback is `posture` (no agent +/// override) ⇒ `resolve_for` yields `Some(eff)` with that fallback. +fn perm_doc(posture: Posture) -> ProjectPermissions { + ProjectPermissions::new(Some(PermissionSet::new(vec![], posture)), Vec::new()) +} + +/// Builds a `codex` profile (convention `AGENTS.md`) with the given customiser, so +/// the structured-adapter / projector / mcp variants can be exercised. +fn codex_profile() -> AgentProfile { + AgentProfile::new( + pid(9), + "OpenAI Codex CLI", + "codex", + Vec::new(), + ContextInjection::convention_file("AGENTS.md").unwrap(), + Some("codex --version".to_owned()), + "{agentRunDir}", + None, + ) + .unwrap() +} + +/// Wires a `LaunchAgent` over the fakes with an optional projector registry and an +/// optional permission document. Returns the use case + the seeded agent + the +/// recording fs/pty so the projection writes and folded args can be asserted. +fn launch_with_projection( + profile: AgentProfile, + plan: Option, + registry: Option>, + perm_doc: Option, +) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc) { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id); + let contexts = FakeContexts::with_agent(&agent, "# ctx body"); + let profiles = FakeProfiles::new(vec![profile]); + let tr = trace(); + let fs = FakeFs::new(Arc::clone(&tr)); + let pty = FakePty::new(Arc::clone(&tr), sid(777)); + let sessions = Arc::new(TerminalSessions::new()); + let mut launch = LaunchAgent::new( + Arc::new(contexts), + Arc::new(profiles), + Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)), + Arc::new(fs.clone()), + Arc::new(pty.clone()), + Arc::new(FakeSkills::default()), + Arc::clone(&sessions), + Arc::new(SpyBus::default()), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall::default()), + None, + ); + if let Some(doc) = perm_doc { + launch = launch.with_permission_store(Arc::new(FakePermissionStore(doc))); + } + if let Some(reg) = registry { + launch = launch.with_permission_projectors(reg); + } + (launch, agent, fs, pty, sessions) +} + +fn convention_file_plan() -> Option { + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }) +} + +// ---- (1) projector key selection ------------------------------------------- + +/// (1a) An explicit `projector = Some(Claude)` is honoured even when the heuristic +/// would not fire (here the convention file is GEMINI.md): the explicit field wins. +#[tokio::test] +async fn projection_selects_claude_from_explicit_projector_field() { + let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap()) + .with_projector(ProjectorKey::Claude); + let (launch, agent, fs, _pty, _s) = launch_with_projection( + profile, + Some(ContextInjectionPlan::File { + target: "GEMINI.md".to_owned(), + }), + Some(full_registry()), + Some(perm_doc(Posture::Allow)), + ); + + launch.execute(launch_input(agent.id)).await.expect("launch"); + + let seeds = fs.writes_ending_with(CLAUDE_SEED_REL); + assert_eq!(seeds.len(), 1, "the Claude projector ran (explicit key)"); + assert!( + fs.writes_ending_with(CODEX_CONFIG_REL).is_empty(), + "the Codex projector must NOT run" + ); +} + +/// (1b) Legacy fallback: no `projector` field, but a `CLAUDE.md` convention file ⇒ +/// Claude projector is selected. +#[tokio::test] +async fn projection_falls_back_to_claude_from_convention_file() { + let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap()); + assert!(profile.projector.is_none(), "no explicit projector (legacy)"); + let (launch, agent, fs, _pty, _s) = launch_with_projection( + profile, + convention_file_plan(), + Some(full_registry()), + Some(perm_doc(Posture::Allow)), + ); + + launch.execute(launch_input(agent.id)).await.expect("launch"); + + assert_eq!( + fs.writes_ending_with(CLAUDE_SEED_REL).len(), + 1, + "CLAUDE.md heuristic selects the Claude projector" + ); +} + +/// (1c) Legacy fallback: no `projector` field, but `StructuredAdapter::Codex` ⇒ +/// Codex projector is selected. +#[tokio::test] +async fn projection_falls_back_to_codex_from_structured_adapter() { + let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex); + assert!(profile.projector.is_none(), "no explicit projector (legacy)"); + let (launch, agent, fs, pty, _s) = launch_with_projection( + profile, + Some(ContextInjectionPlan::File { + target: "AGENTS.md".to_owned(), + }), + Some(full_registry()), + Some(perm_doc(Posture::Allow)), + ); + + launch.execute(launch_input(agent.id)).await.expect("launch"); + + assert_eq!( + fs.writes_ending_with(CODEX_CONFIG_REL).len(), + 1, + "Codex structured adapter selects the Codex projector" + ); + assert!( + fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(), + "the Claude projector must NOT run" + ); + // Args were folded into the spawned spec. + assert!( + pty.spawns()[0].args.contains(&"--sandbox".to_owned()), + "sandbox args folded into spec" + ); +} + +/// (1d) A non-projectable profile (no projector, no CLAUDE.md, no Codex signal) ⇒ +/// no projection at all, even with a full registry and a posed policy. +#[tokio::test] +async fn projection_noop_for_unprojectable_profile() { + let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap()); + let (launch, agent, fs, pty, _s) = launch_with_projection( + profile, + Some(ContextInjectionPlan::File { + target: "GEMINI.md".to_owned(), + }), + Some(full_registry()), + Some(perm_doc(Posture::Allow)), + ); + + launch.execute(launch_input(agent.id)).await.expect("launch"); + + assert!(fs.writes_ending_with(CLAUDE_SEED_REL).is_empty()); + assert!(fs.writes_ending_with(CODEX_CONFIG_REL).is_empty()); + assert!( + !pty.spawns()[0].args.contains(&"--sandbox".to_owned()), + "no projected args either" + ); +} + +// ---- (2) Replace clobber --------------------------------------------------- + +/// (2) A `Replace` file is **clobbered** (rewritten) on every (re)launch: launching +/// the same Claude agent twice (with the session removed in between to clear the +/// singleton guard) writes the owned seed twice to the SAME path — proving the +/// inversion vs. the MCP non-clobbering regime (which skips when the file exists). +#[tokio::test] +async fn claude_replace_seed_is_clobbered_on_relaunch() { + let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap()) + .with_projector(ProjectorKey::Claude); + let (launch, agent, fs, _pty, sessions) = launch_with_projection( + profile, + convention_file_plan(), + Some(full_registry()), + Some(perm_doc(Posture::Allow)), + ); + + let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); + let seed_path = format!("{run_dir}{CLAUDE_SEED_REL}"); + // Pre-mark the seed as already existing: a Replace must write anyway (clobber). + fs.mark_existing(&seed_path); + + // First launch, then simulate the agent exiting so a fresh relaunch is allowed. + launch.execute(launch_input(agent.id)).await.expect("launch 1"); + sessions.remove(&sid(777)); + launch.execute(launch_input(agent.id)).await.expect("launch 2"); + + let seeds = fs.writes_ending_with(CLAUDE_SEED_REL); + assert_eq!( + seeds.len(), + 2, + "the owned Replace seed is rewritten on each launch (clobber), got {seeds:?}" + ); + assert!( + seeds.iter().all(|(p, _)| p == &seed_path), + "both writes target the same seed path" + ); +} + +// ---- (3) MergeToml: upsert managed keys, preserve unmanaged, idempotent ----- + +/// (3) Projecting onto a pre-existing `.codex/config.toml` upserts the two managed +/// keys while preserving an unmanaged user key; a second projection does not +/// duplicate the managed keys (idempotence). +#[tokio::test] +async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() { + let profile = codex_profile().with_projector(ProjectorKey::Codex); + let (launch, agent, fs, _pty, sessions) = launch_with_projection( + profile, + Some(ContextInjectionPlan::File { + target: "AGENTS.md".to_owned(), + }), + Some(full_registry()), + Some(perm_doc(Posture::Allow)), + ); + + let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); + let cfg_path = format!("{run_dir}{CODEX_CONFIG_REL}"); + // Pre-existing config with an unmanaged top-level key + an unmanaged table. + fs.seed_read( + &cfg_path, + "user_key = \"keep-me\"\n[mcp_servers.idea]\ncommand = \"idea\"\n", + ); + + // First projection. + launch.execute(launch_input(agent.id)).await.expect("launch 1"); + let first = String::from_utf8( + fs.writes_ending_with(CODEX_CONFIG_REL) + .last() + .expect("a codex config write") + .1 + .clone(), + ) + .unwrap(); + assert!(first.contains("user_key = \"keep-me\""), "unmanaged key preserved: {first}"); + assert!(first.contains("[mcp_servers.idea]"), "unmanaged table preserved: {first}"); + assert!( + first.contains("sandbox_mode = \"workspace-write\""), + "managed sandbox_mode upserted: {first}" + ); + assert!( + first.contains("approval_policy = \"never\""), + "managed approval_policy upserted: {first}" + ); + + // Second projection (relaunch): managed keys are replaced in place, not dup'd. + sessions.remove(&sid(777)); + launch.execute(launch_input(agent.id)).await.expect("launch 2"); + let second = String::from_utf8( + fs.writes_ending_with(CODEX_CONFIG_REL) + .last() + .unwrap() + .1 + .clone(), + ) + .unwrap(); + assert_eq!( + second.matches("sandbox_mode =").count(), + 1, + "idempotent: no duplicate sandbox_mode after a second projection: {second}" + ); + assert_eq!( + second.matches("approval_policy =").count(), + 1, + "idempotent: no duplicate approval_policy: {second}" + ); + assert!(second.contains("user_key = \"keep-me\""), "unmanaged key still preserved"); +} + +// ---- (4) args/env fold into the spawned spec -------------------------------- + +/// (4) The projection's launch args are folded into the spec inherited by the +/// (PTY) spawn, in order, alongside the projected config file. +#[tokio::test] +async fn codex_projection_folds_args_into_spawn_spec() { + let profile = codex_profile().with_projector(ProjectorKey::Codex); + let (launch, agent, _fs, pty, _s) = launch_with_projection( + profile, + Some(ContextInjectionPlan::File { + target: "AGENTS.md".to_owned(), + }), + Some(full_registry()), + Some(perm_doc(Posture::Ask)), + ); + + launch.execute(launch_input(agent.id)).await.expect("launch"); + + let args = &pty.spawns()[0].args; + // Ask ⇒ workspace-write / on-request, in CLI order. + let pos = args + .windows(2) + .position(|w| w == ["--sandbox".to_owned(), "workspace-write".to_owned()]); + assert!(pos.is_some(), "expected --sandbox workspace-write in {args:?}"); + let pos2 = args + .windows(2) + .position(|w| w == ["--ask-for-approval".to_owned(), "on-request".to_owned()]); + assert!(pos2.is_some(), "expected --ask-for-approval on-request in {args:?}"); +} + +// ---- (5) MCP decoupling — THE key case of the lot --------------------------- + +/// (5) A Codex profile with **no MCP capability** still gets its sandbox projected +/// (config file + args). This proves the projection no longer depends on +/// `apply_mcp_config`: the MCP table is absent, yet the permission plan is applied. +#[tokio::test] +async fn codex_sandbox_projected_without_any_mcp_capability() { + let profile = codex_profile().with_projector(ProjectorKey::Codex); + assert!(profile.mcp.is_none(), "precondition: no MCP capability"); + let (launch, agent, fs, pty, _s) = launch_with_projection( + profile, + Some(ContextInjectionPlan::File { + target: "AGENTS.md".to_owned(), + }), + Some(full_registry()), + Some(perm_doc(Posture::Deny)), + ); + + launch.execute(launch_input(agent.id)).await.expect("launch"); + + // The sandbox config WAS written despite the absent MCP capability. + let cfg = fs.writes_ending_with(CODEX_CONFIG_REL); + assert_eq!(cfg.len(), 1, "sandbox config projected without MCP"); + let toml = String::from_utf8(cfg[0].1.clone()).unwrap(); + assert!(toml.contains("sandbox_mode = \"read-only\""), "Deny ⇒ read-only: {toml}"); + // And the args were folded too. + assert!( + pty.spawns()[0] + .args + .windows(2) + .any(|w| w == ["--sandbox".to_owned(), "read-only".to_owned()]), + "sandbox args projected without MCP: {:?}", + pty.spawns()[0].args + ); +} + +// ---- (6) no-op regimes ------------------------------------------------------ + +/// (6a) Registry absent (builder never called) ⇒ no permission file is written, +/// even with a posed policy. +#[tokio::test] +async fn no_registry_means_no_projection() { + let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap()) + .with_projector(ProjectorKey::Claude); + let (launch, agent, fs, _pty, _s) = launch_with_projection( + profile, + convention_file_plan(), + None, // no registry wired + Some(perm_doc(Posture::Deny)), + ); + + launch.execute(launch_input(agent.id)).await.expect("launch"); + + assert!( + fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(), + "no registry ⇒ no permission seed written (legacy behaviour)" + ); +} + +/// (6b) `eff == None` (no project/agent policy posed) ⇒ empty projection: nothing +/// written, even though the registry IS wired. +#[tokio::test] +async fn no_policy_posed_means_empty_projection() { + let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap()) + .with_projector(ProjectorKey::Claude); + let (launch, agent, fs, _pty, _s) = launch_with_projection( + profile, + convention_file_plan(), + Some(full_registry()), + Some(ProjectPermissions::default()), // project_defaults = None ⇒ resolve_for == None + ); + + launch.execute(launch_input(agent.id)).await.expect("launch"); + + assert!( + fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(), + "eff == None ⇒ empty projection ⇒ no seed written" + ); +} + +// ---- (7) resolved eff reflected in the projected file ----------------------- + +/// (7) Smoke: a posed `Deny` project posture flows through `resolve_for` into the +/// projector and is reflected in the produced Claude settings (`defaultMode=plan`). +#[tokio::test] +async fn resolved_deny_posture_reflected_as_plan_mode() { + let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap()) + .with_projector(ProjectorKey::Claude); + let (launch, agent, fs, _pty, _s) = launch_with_projection( + profile, + convention_file_plan(), + Some(full_registry()), + Some(perm_doc(Posture::Deny)), + ); + + launch.execute(launch_input(agent.id)).await.expect("launch"); + + let seed = String::from_utf8(fs.writes_ending_with(CLAUDE_SEED_REL)[0].1.clone()).unwrap(); + let json: serde_json::Value = serde_json::from_str(&seed).expect("valid settings JSON"); + assert_eq!( + json["permissions"]["defaultMode"], "plan", + "Deny posture ⇒ plan mode: {seed}" + ); + assert_eq!( + json["permissions"]["additionalDirectories"][0], "/home/me/proj", + "project root flowed into the projection ctx" + ); +} diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs index 83e25f0..defb033 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -30,24 +30,29 @@ use domain::events::DomainEvent; use domain::ids::{AgentId, ProfileId, ProjectId}; use domain::layout::Workspace; use domain::markdown::MarkdownDoc; +use domain::permission::{ + EffectivePermissions, PermissionProjection, PermissionProjector, ProjectedFile, + ProjectionContext, ProjectorKey, +}; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, - OutputStream, PreparedContext, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, - RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, + OutputStream, PermissionStore, PreparedContext, ProfileStore, ProjectStore, PtyError, + PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; -use domain::profile::{AgentProfile, ContextInjection}; +use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; use domain::{ - LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PtySize, SessionId, - SessionKind, SkillId, + LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PermissionSet, Posture, + ProjectPermissions, PtySize, SessionId, SessionKind, SkillId, }; use uuid::Uuid; use application::{ - ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, TerminalSessions, + ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, PermissionProjectorRegistry, + TerminalSessions, }; // --------------------------------------------------------------------------- @@ -241,6 +246,8 @@ struct FakeFsInner { files: HashMap>, dirs: HashSet, write_count: usize, + /// Paths passed to `remove_file`, in order (lot LP3-4 cleanup assertions). + removed: Vec, } #[derive(Default, Clone)] @@ -262,6 +269,14 @@ impl FakeFs { fn write_count(&self) -> usize { self.0.lock().unwrap().write_count } + /// Whether a file is currently present in the fake's state. + fn has_file(&self, path: &str) -> bool { + self.0.lock().unwrap().files.contains_key(path) + } + /// The ordered list of paths passed to `remove_file` (lot LP3-4). + fn removed(&self) -> Vec { + self.0.lock().unwrap().removed.clone() + } } #[async_trait] @@ -289,6 +304,15 @@ impl FileSystem for FakeFs { self.0.lock().unwrap().dirs.insert(path.as_str().to_owned()); Ok(()) } + /// Overrides the no-op default (lot LP3-4): records the path and actually + /// removes it from the fake's state, so a deletion is assertable. Idempotent — + /// removing an absent file still succeeds (best-effort cleanup contract). + async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> { + let mut inner = self.0.lock().unwrap(); + inner.removed.push(path.as_str().to_owned()); + inner.files.remove(path.as_str()); + Ok(()) + } async fn list(&self, _path: &RemotePath) -> Result, FsError> { Ok(Vec::new()) } @@ -339,6 +363,7 @@ impl AgentRuntime for FakeRuntime { context_plan: Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), + sandbox: None, }) } } @@ -368,6 +393,16 @@ impl FakePty { fn kills(&self) -> Vec { self.kills.lock().unwrap().clone() } + /// Args of the most recent spawn (used to assert folded projection args on a + /// relaunch, lot LP3-4). + fn last_spawn_args(&self) -> Vec { + self.spawns + .lock() + .unwrap() + .last() + .map(|s| s.args.clone()) + .unwrap_or_default() + } } #[async_trait] @@ -675,10 +710,7 @@ impl domain::HandoffStore for FakeHandoffs { } impl application::HandoffProvider for FakeHandoffs { - fn handoff_store_for( - &self, - _root: &ProjectPath, - ) -> Option> { + fn handoff_store_for(&self, _root: &ProjectPath) -> Option> { Some(Arc::new(self.clone())) } } @@ -771,6 +803,492 @@ fn change_input(agent_id: AgentId, profile_id: ProfileId) -> ChangeAgentProfileI } } +// --------------------------------------------------------------------------- +// LP3-4 — permission-file cleanup at swap: fakes, projectors, fixture +// --------------------------------------------------------------------------- + +/// In-memory [`PermissionStore`] returning a fixed document (so the relaunch's +/// projection resolves a non-empty `eff` and actually writes the new CLI config). +struct FakePermissionStore(ProjectPermissions); + +#[async_trait] +impl PermissionStore for FakePermissionStore { + async fn load_permissions(&self, _project: &Project) -> Result { + Ok(self.0.clone()) + } + async fn save_permissions( + &self, + _project: &Project, + _permissions: &ProjectPermissions, + ) -> Result<(), StoreError> { + Ok(()) + } +} + +/// Faithful Claude projector double (LP3-2 mapping): owns the `Replace` settings +/// seed `.claude/settings.local.json`. +struct FakeClaudeProjector; + +impl PermissionProjector for FakeClaudeProjector { + fn key(&self) -> ProjectorKey { + ProjectorKey::Claude + } + fn project( + &self, + eff: Option<&EffectivePermissions>, + ctx: &ProjectionContext, + ) -> PermissionProjection { + if eff.is_none() { + return PermissionProjection::empty(); + } + let contents = format!( + "{{\"permissions\":{{\"additionalDirectories\":[\"{}\"]}}}}\n", + ctx.project_root + ); + PermissionProjection { + files: vec![ProjectedFile::Replace { + rel_path: ".claude/settings.local.json".to_owned(), + contents, + }], + args: Vec::new(), + env: Vec::new(), + } + } + fn owned_replace_paths(&self) -> Vec { + vec![".claude/settings.local.json".to_owned()] + } +} + +/// Faithful Codex projector double (LP3-2 mapping): a co-owned `MergeToml` + the +/// `--sandbox`/`--ask-for-approval` args. Owns **no** `Replace` file. +struct FakeCodexProjector; + +impl PermissionProjector for FakeCodexProjector { + fn key(&self) -> ProjectorKey { + ProjectorKey::Codex + } + fn project( + &self, + eff: Option<&EffectivePermissions>, + _ctx: &ProjectionContext, + ) -> PermissionProjection { + if eff.is_none() { + return PermissionProjection::empty(); + } + PermissionProjection { + files: vec![ProjectedFile::MergeToml { + rel_path: ".codex/config.toml".to_owned(), + managed_tables: Vec::new(), + managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()], + contents: "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n" + .to_owned(), + }], + args: vec![ + "--sandbox".to_owned(), + "workspace-write".to_owned(), + "--ask-for-approval".to_owned(), + "never".to_owned(), + ], + env: Vec::new(), + } + } + fn owned_replace_paths(&self) -> Vec { + Vec::new() + } +} + +/// A registry carrying both faithful projector doubles. +fn full_registry() -> Arc { + Arc::new( + PermissionProjectorRegistry::new() + .with(Arc::new(FakeClaudeProjector)) + .with(Arc::new(FakeCodexProjector)), + ) +} + +/// A Claude profile (convention `CLAUDE.md` ⇒ legacy Claude selection). +fn claude_profile(id: ProfileId) -> AgentProfile { + profile(id) +} + +/// A Codex profile carrying an explicit `ProjectorKey::Codex` (and the Codex +/// structured adapter, so the legacy fallback would also select Codex). +fn codex_profile(id: ProfileId) -> AgentProfile { + AgentProfile::new( + id, + "OpenAI Codex CLI", + "codex", + Vec::new(), + ContextInjection::convention_file("AGENTS.md").unwrap(), + Some("codex --version".to_owned()), + "{agentRunDir}", + None, + ) + .unwrap() + .with_structured_adapter(StructuredAdapter::Codex) + .with_projector(ProjectorKey::Codex) +} + +/// The (stable) run dir of `agent` under the test project root. +fn run_dir_of(agent: &AgentId) -> String { + format!("{ROOT}/.ideai/run/{agent}") +} + +/// Wires a swap fixture with a projector registry on BOTH the swap and the +/// composed relaunch, plus an optional permission document on the relaunch (so +/// the relaunch's projection is non-empty). Mirrors [`fixture_with_profiles`]. +async fn fixture_with_projection( + agent: &Agent, + profiles: Vec, + registry: Arc, + perm_doc: Option, +) -> Fixture { + let contexts = FakeContexts::with_agent(agent, "# persona"); + let profiles = FakeProfiles::new(profiles); + let store = FakeStore::default(); + let fs = FakeFs::default(); + let pty = FakePty::new(sid(777)); + let sessions = Arc::new(TerminalSessions::new()); + let bus = SpyBus::default(); + let runtime = FakeRuntime::new(); + let handoffs = FakeHandoffs::default(); + + store.save(&project()).await; + + let mut launch = LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::new(profiles.clone()), + Arc::new(runtime.clone()), + Arc::new(fs.clone()), + Arc::new(pty.clone()), + Arc::new(FakeSkills), + Arc::clone(&sessions), + Arc::new(bus.clone()), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall), + None, + ) + .with_handoff_provider(Arc::new(handoffs.clone())) + .with_permission_projectors(Arc::clone(®istry)); + if let Some(doc) = perm_doc { + launch = launch.with_permission_store(Arc::new(FakePermissionStore(doc))); + } + + let swap = ChangeAgentProfile::new( + Arc::new(contexts.clone()), + Arc::new(profiles), + Arc::new(store), + Arc::new(fs.clone()), + Arc::clone(&sessions), + Arc::new(pty.clone()), + Arc::new(launch), + Arc::new(bus.clone()), + ) + .with_permission_projectors(Arc::clone(®istry)); + + Fixture { + swap, + contexts, + fs, + pty, + bus, + sessions, + runtime, + handoffs, + } +} + +/// The full `permissions.json` posing a project-level `Allow` policy ⇒ a relaunch +/// resolves `Some(eff)` and the projector writes the new CLI config. +fn allow_perm_doc() -> ProjectPermissions { + ProjectPermissions::new(Some(PermissionSet::new(vec![], Posture::Allow)), Vec::new()) +} + +const CLAUDE_SEED_REL: &str = ".claude/settings.local.json"; +const CODEX_CONFIG_REL: &str = ".codex/config.toml"; + +/// Seeds a live agent session + a persisted layout cell hosting it, so the swap +/// reaches its relaunch step (kill → relaunch in the same cell). +fn seed_live_for_relaunch(f: &Fixture, agent: &AgentId) { + let host = nid(1); + seed_live_agent_session(&f.sessions, *agent, host, sid(42)); + let tree = LayoutTree::single(agent_leaf(host, Some(*agent), Some(&pair_uuid()), true)); + seed_layouts(&f.fs, lid(1), &tree); +} + +/// A stable UUID-shaped pair id used by the relaunch scenarios. +fn pair_uuid() -> String { + Uuid::from_u128(0xABCD).to_string() +} + +// =========================================================================== +// LP3-4 — cleanup of orphan permission files at a cross-profile swap +// =========================================================================== + +/// (1) **Claude→Codex (phare)**: the pre-existing `.claude/settings.local.json` +/// is removed at the swap, and the relaunch projects the Codex sandbox config + +/// args. The orphan Replace file of the old projector is cleaned up; the new +/// projector (Codex) owns no Replace file, so nothing protects it from deletion. +#[tokio::test] +async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture_with_projection( + &agent, + vec![claude_profile(pid(1)), codex_profile(pid(2))], + full_registry(), + Some(allow_perm_doc()), + ) + .await; + + let run_dir = run_dir_of(&agent.id); + let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}"); + let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}"); + // The old Claude seed exists in the (stable) run dir before the swap. + f.fs.put(&seed_path, b"{\"permissions\":{}}"); + + seed_live_for_relaunch(&f, &agent.id); + f.swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("swap succeeds"); + + // The orphan Claude seed was deleted (recorded AND gone from the FS state). + assert!( + f.fs.removed().iter().any(|p| p == &seed_path), + "the orphan .claude seed must be removed; removed={:?}", + f.fs.removed() + ); + assert!(!f.fs.has_file(&seed_path), "the seed is gone from the FS state"); + + // The relaunch projected the Codex sandbox config + args. + assert!( + f.fs.has_file(&codex_path), + "the relaunch must project the Codex config" + ); + let toml = String::from_utf8(f.fs.read_file(&codex_path).unwrap()).unwrap(); + assert!(toml.contains("sandbox_mode = \"workspace-write\""), "{toml}"); + assert!( + f.pty.last_spawn_args().contains(&"--sandbox".to_owned()), + "sandbox args folded into the relaunch spawn: {:?}", + f.pty.last_spawn_args() + ); +} + +/// (2) **Claude→Claude**: `owned(old) − owned(new)` is empty, so the seed is NOT +/// removed (it is re-clobbered by the relaunch, never deleted). +#[tokio::test] +async fn swap_claude_to_claude_does_not_remove_seed() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture_with_projection( + &agent, + vec![claude_profile(pid(1)), claude_profile(pid(2))], + full_registry(), + Some(allow_perm_doc()), + ) + .await; + + let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id)); + f.fs.put(&seed_path, b"{\"permissions\":{}}"); + + seed_live_for_relaunch(&f, &agent.id); + f.swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("swap succeeds"); + + assert!( + !f.fs.removed().iter().any(|p| p == &seed_path), + "same-family swap must NOT delete the shared seed; removed={:?}", + f.fs.removed() + ); + // It is still present (re-clobbered by the relaunch's projection). + assert!(f.fs.has_file(&seed_path), "the seed survives the same-family swap"); +} + +/// (3) **Codex→Claude**: Codex owns no Replace file ⇒ nothing is removed on the +/// cleanup side; the relaunch writes the Claude seed; the co-owned +/// `.codex/config.toml` is never deleted. +#[tokio::test] +async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture_with_projection( + &agent, + vec![codex_profile(pid(1)), claude_profile(pid(2))], + full_registry(), + Some(allow_perm_doc()), + ) + .await; + + let run_dir = run_dir_of(&agent.id); + let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}"); + let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}"); + // A pre-existing co-owned Codex config that must survive the swap. + f.fs.put(&codex_path, b"sandbox_mode = \"read-only\"\n"); + + seed_live_for_relaunch(&f, &agent.id); + f.swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("swap succeeds"); + + // Nothing removed (Codex has no Replace-owned path). + assert!( + f.fs.removed().is_empty(), + "Codex→Claude removes no permission file; removed={:?}", + f.fs.removed() + ); + // The co-owned Codex config is untouched by cleanup… + assert!(f.fs.has_file(&codex_path), "the .codex/config.toml is never deleted"); + assert!( + !f.fs.removed().iter().any(|p| p == &codex_path), + "the .codex/config.toml is not in the removed list" + ); + // …and the relaunch projected the new Claude seed. + assert!(f.fs.has_file(&seed_path), "the relaunch writes the Claude seed"); +} + +/// (4a) **No-op (no registry)**: without a projector registry wired on the swap, +/// no cleanup runs at all — even on a Claude→Codex swap with the seed present. +#[tokio::test] +async fn swap_without_registry_removes_nothing() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + // The default fixture wires NO projector registry on the swap. + let f = fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await; + + let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id)); + f.fs.put(&seed_path, b"{\"permissions\":{}}"); + + f.swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("swap succeeds"); + + assert!( + f.fs.removed().is_empty(), + "no registry ⇒ no cleanup; removed={:?}", + f.fs.removed() + ); + assert!(f.fs.has_file(&seed_path), "the seed is left untouched"); +} + +/// (4b) **No-op (previous profile deleted)**: if the old profile id is no longer +/// in the store, the cleanup is skipped (the old projector can't be resolved) and +/// the swap still succeeds. +#[tokio::test] +async fn swap_with_unknown_previous_profile_skips_cleanup() { + // Agent records pid(1), but the store only knows pid(2) (the target) and pid(3): + // pid(1) was deleted between launch and swap. + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture_with_projection( + &agent, + vec![codex_profile(pid(2)), claude_profile(pid(3))], + full_registry(), + Some(allow_perm_doc()), + ) + .await; + + let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id)); + f.fs.put(&seed_path, b"{\"permissions\":{}}"); + + f.swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("swap succeeds even with an unknown previous profile"); + + assert!( + f.fs.removed().is_empty(), + "unknown previous profile ⇒ cleanup skipped; removed={:?}", + f.fs.removed() + ); +} + +/// (5) **Best-effort**: when the orphan seed file does NOT exist on disk, the +/// delete is still attempted (idempotent) and the swap succeeds without error. +#[tokio::test] +async fn swap_claude_to_codex_succeeds_when_seed_absent() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture_with_projection( + &agent, + vec![claude_profile(pid(1)), codex_profile(pid(2))], + full_registry(), + Some(allow_perm_doc()), + ) + .await; + + // Intentionally do NOT seed `.claude/settings.local.json`. + let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id)); + + f.swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("swap succeeds even when the orphan file is already gone"); + + // The delete was attempted (best-effort, idempotent) on the missing path. + assert!( + f.fs.removed().iter().any(|p| p == &seed_path), + "a remove was still attempted on the absent seed; removed={:?}", + f.fs.removed() + ); +} + +/// (6) **Non-regression P8d**: the cleanup must not disturb the preserved pair id. +/// A live Claude→Codex swap with a handoff seeded under the leaf's pair id still +/// re-injects that handoff into the new engine's convention file (proving the +/// pair `conversation_id` survived the cleanup step unchanged). +#[tokio::test] +async fn swap_with_cleanup_preserves_pair_id_and_handoff() { + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); + let f = fixture_with_projection( + &agent, + vec![claude_profile(pid(1)), codex_profile(pid(2))], + full_registry(), + Some(allow_perm_doc()), + ) + .await; + + // Seed the orphan Claude seed (so cleanup actually fires) and a handoff under + // the leaf's pair id. + let run_dir = run_dir_of(&agent.id); + f.fs.put(&format!("{run_dir}/{CLAUDE_SEED_REL}"), b"{}"); + let pair = pair_uuid(); + f.handoffs.seed(&pair, "État au dernier tour : LP3-4.", Some("objectif")); + + let host = nid(1); + seed_live_agent_session(&f.sessions, agent.id, host, sid(42)); + let tree = LayoutTree::single(agent_leaf(host, Some(agent.id), Some(&pair), true)); + seed_layouts(&f.fs, lid(1), &tree); + + f.swap + .execute(change_input(agent.id, pid(2))) + .await + .expect("swap succeeds"); + + // The cleanup fired… + assert!( + f.fs.removed() + .iter() + .any(|p| p == &format!("{run_dir}/{CLAUDE_SEED_REL}")), + "the orphan seed was cleaned up" + ); + // …and the pair id was preserved on the persisted leaf (P8d invariant). + let (conv, running) = leaf_state(&f.fs, host).expect("leaf persisted"); + assert_eq!(conv.as_deref(), Some(pair.as_str()), "pair id preserved"); + assert!(!running, "engine running flag reset by invalidate_engine_link"); + + // …and the handoff (keyed by that pair id) was re-injected into the new engine's + // convention file — proving the relaunch threaded the preserved pair id. (The + // shared FakeRuntime always materialises the convention file as `CLAUDE.md`.) + let conv_md = String::from_utf8( + f.fs.read_file(&format!("{run_dir}/CLAUDE.md")) + .expect("the relaunch wrote the new engine's convention file"), + ) + .unwrap(); + assert!( + conv_md.contains("LP3-4"), + "handoff re-injected under the preserved pair id: {conv_md}" + ); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -860,7 +1378,11 @@ async fn success_mutates_manifest_to_new_profile() { .await .expect("swap succeeds"); - assert_eq!(out.agent.profile_id, pid(2), "returned agent carries new profile"); + assert_eq!( + out.agent.profile_id, + pid(2), + "returned agent carries new profile" + ); assert_eq!( f.contexts.profile_of(&agent.id), Some(pid(2)), @@ -1017,7 +1539,10 @@ async fn live_agent_is_killed_and_relaunched_in_same_cell() { assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once"); // The relaunched session is returned and pinned on the SAME cell N. let relaunched = out.relaunched.expect("a live agent is relaunched"); - assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell"); + assert_eq!( + relaunched.node_id, host, + "relaunch reopens in the same cell" + ); assert_eq!(relaunched.id, sid(777), "relaunch adopts the new PTY id"); // The relaunched session is registered and tagged for this agent. assert!(matches!( @@ -1111,8 +1636,11 @@ async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() { // A UUID-shaped pair id (so resolve_handoff can parse it) and a seeded handoff. let pair = "11111111-1111-1111-1111-111111111111"; - f.handoffs - .seed(pair, "Résumé : on a fini l'étape 2.", Some("Livrer le lot P8d")); + f.handoffs.seed( + pair, + "Résumé : on a fini l'étape 2.", + Some("Livrer le lot P8d"), + ); // Live agent on cell N with a foreign engine resumable cache. let host = nid(5); @@ -1138,7 +1666,10 @@ async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() { // The old PTY was killed and a single relaunch happened in the SAME cell. assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed"); let relaunched = out.relaunched.expect("a live agent is relaunched"); - assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell"); + assert_eq!( + relaunched.node_id, host, + "relaunch reopens in the same cell" + ); // The pair id is preserved on the persisted leaf; the engine cache is cleared. assert_eq!( @@ -1171,7 +1702,10 @@ async fn live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume() { // The old engine resumable is NEVER replayed: the relaunch's SessionPlan is // None (providers.json[new provider] is empty ⇒ fresh engine, fidelity carried // by the handoff). It is emphatically NOT Resume{"engine-old"}. - let plan = f.runtime.last_plan().expect("the relaunch prepared an invocation"); + let plan = f + .runtime + .last_plan() + .expect("the relaunch prepared an invocation"); assert_eq!( plan, SessionPlan::None, diff --git a/crates/application/tests/conversation_record.rs b/crates/application/tests/conversation_record.rs index c0642e8..eac29c8 100644 --- a/crates/application/tests/conversation_record.rs +++ b/crates/application/tests/conversation_record.rs @@ -61,7 +61,12 @@ impl InMemoryConversationLog { } fn thread(&self, conv: ConversationId) -> Vec { - self.threads.lock().unwrap().get(&conv).cloned().unwrap_or_default() + self.threads + .lock() + .unwrap() + .get(&conv) + .cloned() + .unwrap_or_default() } } @@ -156,11 +161,7 @@ impl HandoffStore for InMemoryHandoffStore { Ok(self.store.lock().unwrap().get(&conversation).cloned()) } - async fn save( - &self, - conversation: ConversationId, - handoff: Handoff, - ) -> Result<(), StoreError> { + async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> { if let Some(err) = self.fail_save.lock().unwrap().clone() { return Err(err); } @@ -219,7 +220,9 @@ async fn single_record_appends_turn_and_advances_handoff() { let conv = conv_id(1); let t1 = turn(conv, turn_id(10), "hello world"); - uc.record(conv, t1.clone()).await.expect("record should succeed"); + uc.record(conv, t1.clone()) + .await + .expect("record should succeed"); // Log contains the turn. assert_eq!(log.thread(conv), vec![t1.clone()]); @@ -227,7 +230,11 @@ async fn single_record_appends_turn_and_advances_handoff() { // Handoff advanced: up_to == turn id, summary contains the turn text. let h = handoffs.loaded(conv).expect("handoff should be saved"); assert_eq!(h.up_to, t1.id); - assert!(h.summary_md.contains("hello world"), "summary={:?}", h.summary_md); + assert!( + h.summary_md.contains("hello world"), + "summary={:?}", + h.summary_md + ); // First fold: prev = None, exactly one new turn. assert_eq!(summarizer.calls(), vec![(false, 1)]); @@ -254,8 +261,16 @@ async fn two_records_fold_incrementally_without_full_reread() { // Handoff up_to == t2.id; summary contains both texts. let h = handoffs.loaded(conv).expect("handoff saved"); assert_eq!(h.up_to, t2.id); - assert!(h.summary_md.contains("first-text"), "summary={:?}", h.summary_md); - assert!(h.summary_md.contains("second-text"), "summary={:?}", h.summary_md); + assert!( + h.summary_md.contains("first-text"), + "summary={:?}", + h.summary_md + ); + assert!( + h.summary_md.contains("second-text"), + "summary={:?}", + h.summary_md + ); // Proof of incrementality: prev None then Some, and len == 1 on BOTH calls // (never 2 → the whole log is never re-read into the fold). @@ -311,9 +326,9 @@ async fn load_error_propagates_as_store() { #[tokio::test] async fn save_error_propagates_as_store() { let log = Arc::new(InMemoryConversationLog::default()); - let handoffs = Arc::new(InMemoryHandoffStore::failing_save(StoreError::Serialization( - "save boom".to_owned(), - ))); + let handoffs = Arc::new(InMemoryHandoffStore::failing_save( + StoreError::Serialization("save boom".to_owned()), + )); let summarizer = Arc::new(RecordingSummarizer::default()); let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone()); diff --git a/crates/application/tests/drain_with_readiness_lot1.rs b/crates/application/tests/drain_with_readiness_lot1.rs new file mode 100644 index 0000000..28b58f7 --- /dev/null +++ b/crates/application/tests/drain_with_readiness_lot1.rs @@ -0,0 +1,338 @@ +//! Lot 1 (readiness/heartbeat model-agnostique) — tests unitaires QA **indépendants** +//! du helper applicatif `drain_with_readiness`, **100 % fakes**. +//! +//! Couvre les points 5 et 6 du périmètre QA : +//! +//! - **Point 5** : un flux se terminant par `Final` appelle `mark_idle(agent)` +//! **exactement une fois** ; les `Heartbeat`/deltas/activités intermédiaires +//! n'appellent **jamais** `mark_idle`. +//! - **Point 6 (le bug d'origine)** : un agent cible qui **ne fait que renvoyer un +//! `Final`** (sans jamais appeler `idea_reply`) débloque bien la file via +//! `mark_idle` — c'est exactement la cause racine du blocage `Busy`. +//! +//! On teste au niveau `drain_with_readiness` (le rendez-vous synchrone qu'`ask_agent` +//! utilise sur la session structurée d'une cible) avec : +//! - un fake `AgentSession` scriptable (calqué sur `send_blocking_d1.rs`) ; +//! - un fake `InputMediator` qui **compte** les `mark_idle` par agent et journalise +//! l'ordre des appels, sans seconde file ni I/O. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use async_trait::async_trait; + +use application::drain_with_readiness; +use domain::ids::AgentId; +use domain::input::{AgentBusyState, InputMediator}; +use domain::mailbox::{PendingReply, Ticket}; +use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; +use domain::SessionId; +use uuid::Uuid; + +fn sid(n: u128) -> SessionId { + SessionId::from_uuid(Uuid::from_u128(n)) +} + +fn aid(n: u128) -> AgentId { + AgentId::from_uuid(Uuid::from_u128(n)) +} + +// --------------------------------------------------------------------------- +// Fake AgentSession scriptable (mono-usage), repris de send_blocking_d1.rs +// --------------------------------------------------------------------------- + +enum Script { + Stream(Vec), + Err(AgentSessionError), +} + +struct ScriptedSession { + id: SessionId, + script: Mutex>, + shutdowns: AtomicUsize, +} + +impl ScriptedSession { + fn new(script: Script) -> Self { + Self { + id: sid(1), + script: Mutex::new(Some(script)), + shutdowns: AtomicUsize::new(0), + } + } + fn shutdown_count(&self) -> usize { + self.shutdowns.load(Ordering::SeqCst) + } +} + +#[async_trait] +impl AgentSession for ScriptedSession { + fn id(&self) -> SessionId { + self.id + } + fn conversation_id(&self) -> Option { + None + } + async fn send(&self, _prompt: &str) -> Result { + let script = self + .script + .lock() + .unwrap() + .take() + .expect("send scripté une seule fois"); + match script { + Script::Stream(events) => Ok(Box::new(events.into_iter())), + Script::Err(e) => Err(e), + } + } + async fn shutdown(&self) -> Result<(), AgentSessionError> { + self.shutdowns.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Fake InputMediator : compte les mark_idle par agent + journalise les appels. +// --------------------------------------------------------------------------- + +struct RecordingMediator { + /// (agent, "mark_idle") dans l'ordre des appels. + calls: Mutex>, +} + +impl RecordingMediator { + fn new() -> Self { + Self { + calls: Mutex::new(Vec::new()), + } + } + fn mark_idle_count(&self, agent: AgentId) -> usize { + self.calls + .lock() + .unwrap() + .iter() + .filter(|(a, kind)| *a == agent && *kind == "mark_idle") + .count() + } + fn total_calls(&self) -> usize { + self.calls.lock().unwrap().len() + } +} + +impl InputMediator for RecordingMediator { + fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply { + // Jamais utilisé par drain_with_readiness ; un future qui ne résout pas. + PendingReply::new(Box::pin(std::future::pending())) + } + fn preempt(&self, agent: AgentId) { + self.calls.lock().unwrap().push((agent, "preempt")); + } + fn mark_idle(&self, agent: AgentId) { + self.calls.lock().unwrap().push((agent, "mark_idle")); + } + fn busy_state(&self, _agent: AgentId) -> AgentBusyState { + AgentBusyState::Idle + } +} + +// --------------------------------------------------------------------------- +// Helpers d'événements +// --------------------------------------------------------------------------- + +fn delta(t: &str) -> ReplyEvent { + ReplyEvent::TextDelta { text: t.to_owned() } +} +fn tool(l: &str) -> ReplyEvent { + ReplyEvent::ToolActivity { + label: l.to_owned(), + } +} +fn heartbeat() -> ReplyEvent { + ReplyEvent::Heartbeat +} +fn final_(c: &str) -> ReplyEvent { + ReplyEvent::Final { + content: c.to_owned(), + } +} + +// =========================================================================== +// Point 6 (LE point qui compte) : un Final SEUL débloque la file via mark_idle, +// sans aucun idea_reply. +// =========================================================================== + +#[tokio::test] +async fn final_only_stream_unblocks_queue_via_mark_idle() { + // L'agent cible ne fait QUE renvoyer un Final (jamais d'idea_reply). + let agent = aid(42); + let session = ScriptedSession::new(Script::Stream(vec![final_("done")])); + let mediator = RecordingMediator::new(); + + let out = drain_with_readiness(&session, "tâche", None, &mediator, agent).await; + + assert_eq!(out, Ok("done".to_owned()), "le Final rend bien son contenu"); + assert_eq!( + mediator.mark_idle_count(agent), + 1, + "le Final déterministe DOIT marquer l'agent Idle (fix de la cause racine du blocage Busy)" + ); + // Aucun autre appel parasite, et la session reste vivante (pas de shutdown). + assert_eq!(mediator.total_calls(), 1); + assert_eq!(session.shutdown_count(), 0); +} + +// =========================================================================== +// Point 5 : exactement UN mark_idle ; les events intermédiaires n'en émettent pas. +// =========================================================================== + +#[tokio::test] +async fn intermediate_events_do_not_mark_idle_only_final_does() { + let agent = aid(7); + // Heartbeats + deltas + activité PUIS le Final. + let session = ScriptedSession::new(Script::Stream(vec![ + heartbeat(), + delta("hel"), + tool("lit un fichier"), + heartbeat(), + delta("lo"), + final_("hello"), + ])); + let mediator = RecordingMediator::new(); + + let out = drain_with_readiness(&session, "x", None, &mediator, agent).await; + + assert_eq!(out, Ok("hello".to_owned())); + assert_eq!( + mediator.mark_idle_count(agent), + 1, + "mark_idle exactement UNE fois (au Final), jamais sur heartbeat/delta/activité" + ); + assert_eq!( + mediator.total_calls(), + 1, + "aucun appel mediator hormis l'unique mark_idle" + ); +} + +#[tokio::test] +async fn heartbeats_alone_never_mark_idle_before_final() { + // Que des heartbeats avant le Final : aucun ne doit marquer Idle. + let agent = aid(9); + let session = ScriptedSession::new(Script::Stream(vec![ + heartbeat(), + heartbeat(), + heartbeat(), + final_("fini"), + ])); + let mediator = RecordingMediator::new(); + + let out = drain_with_readiness(&session, "x", None, &mediator, agent).await; + assert_eq!(out, Ok("fini".to_owned())); + assert_eq!(mediator.mark_idle_count(agent), 1); +} + +// =========================================================================== +// Bords : pas de Final ⇒ pas de mark_idle ; erreur de send propagée ; mauvais agent. +// =========================================================================== + +#[tokio::test] +async fn stream_without_final_does_not_mark_idle_and_is_io_error() { + let agent = aid(3); + let session = ScriptedSession::new(Script::Stream(vec![heartbeat(), delta("a"), tool("b")])); + let mediator = RecordingMediator::new(); + + let out = drain_with_readiness(&session, "x", None, &mediator, agent).await; + assert!( + matches!(out, Err(AgentSessionError::Io(_))), + "flux épuisé sans Final ⇒ Io, obtenu {out:?}" + ); + assert_eq!( + mediator.mark_idle_count(agent), + 0, + "sans Final, on ne marque JAMAIS Idle (jamais de faux Idle)" + ); + assert_eq!(mediator.total_calls(), 0); +} + +#[tokio::test] +async fn send_error_is_propagated_and_no_mark_idle() { + let agent = aid(5); + let session = + ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned()))); + let mediator = RecordingMediator::new(); + + let out = drain_with_readiness(&session, "x", None, &mediator, agent).await; + assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned()))); + assert_eq!(mediator.mark_idle_count(agent), 0); +} + +#[tokio::test] +async fn mark_idle_targets_the_drained_agent_only() { + // Le mark_idle doit porter sur l'agent passé à drain_with_readiness, pas un autre. + let drained = aid(100); + let other = aid(200); + let session = ScriptedSession::new(Script::Stream(vec![final_("ok")])); + let mediator = RecordingMediator::new(); + + let _ = drain_with_readiness(&session, "x", None, &mediator, drained).await; + assert_eq!(mediator.mark_idle_count(drained), 1); + assert_eq!( + mediator.mark_idle_count(other), + 0, + "mark_idle ne doit cibler que l'agent drainé" + ); +} + +// =========================================================================== +// Timeout (garde-fou du tour) : Timeout renvoyé, pas de mark_idle, session vivante. +// (Le helper borne via tokio::time::timeout ; on force l'expiration avec un Final +// reporté — réutilise un fake retardé minimal local.) +// =========================================================================== + +struct DelayedSession { + delay: Duration, + shutdowns: AtomicUsize, +} + +#[async_trait] +impl AgentSession for DelayedSession { + fn id(&self) -> SessionId { + sid(2) + } + fn conversation_id(&self) -> Option { + None + } + async fn send(&self, _prompt: &str) -> Result { + tokio::time::sleep(self.delay).await; + Ok(Box::new(vec![final_("too late")].into_iter())) + } + async fn shutdown(&self) -> Result<(), AgentSessionError> { + self.shutdowns.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +#[tokio::test] +async fn timeout_returns_timeout_no_mark_idle_session_alive() { + let agent = aid(11); + let session = DelayedSession { + delay: Duration::from_secs(1), + shutdowns: AtomicUsize::new(0), + }; + let mediator = RecordingMediator::new(); + + let out = + drain_with_readiness(&session, "x", Some(Duration::from_millis(20)), &mediator, agent).await; + assert_eq!(out, Err(AgentSessionError::Timeout)); + assert_eq!( + mediator.mark_idle_count(agent), + 0, + "un timeout ne marque pas Idle (le tour n'a pas rendu son Final)" + ); + assert_eq!( + session.shutdowns.load(Ordering::SeqCst), + 0, + "timeout ne tue PAS la session (§17.1)" + ); +} diff --git a/crates/application/tests/list_resumable_agents.rs b/crates/application/tests/list_resumable_agents.rs index 685b703..74ec01b 100644 --- a/crates/application/tests/list_resumable_agents.rs +++ b/crates/application/tests/list_resumable_agents.rs @@ -19,6 +19,7 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; use domain::layout::Workspace; +use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, DirEntry, FileSystem, FsError, ProfileStore, ProjectStore, RemotePath, StoreError, @@ -26,7 +27,6 @@ use domain::ports::{ use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; -use domain::markdown::MarkdownDoc; use domain::{ AgentId, Direction, GridCell, GridContainer, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, ProfileId, ProjectId, SplitContainer, WeightedChild, @@ -521,12 +521,7 @@ async fn resume_supported_follows_profile() { weight: 1.0, }, WeightedChild { - node: LayoutNode::Leaf(agent_leaf( - plain_leaf, - Some(plain_agent), - Some("c2"), - true, - )), + node: LayoutNode::Leaf(agent_leaf(plain_leaf, Some(plain_agent), Some("c2"), true)), weight: 1.0, }, ], @@ -537,17 +532,16 @@ async fn resume_supported_follows_profile() { entry(supported_agent, "Resumable", pid(1)), entry(plain_agent, "Plain", pid(2)), ]); - let profiles = - FakeProfiles::new(vec![profile_with_session(pid(1)), profile_no_session(pid(2))]); + let profiles = FakeProfiles::new(vec![ + profile_with_session(pid(1)), + profile_no_session(pid(2)), + ]); let uc = make_use_case(&store, &fs, &contexts, &profiles); let out = run(&uc).await; assert_eq!(out.len(), 2, "both still listed: {out:?}"); - let supported = out - .iter() - .find(|r| r.agent_id == supported_agent) - .unwrap(); + let supported = out.iter().find(|r| r.agent_id == supported_agent).unwrap(); assert!(supported.resume_supported, "profile pid(1) has a session"); let plain = out.iter().find(|r| r.agent_id == plain_agent).unwrap(); @@ -620,7 +614,12 @@ async fn agent_absent_from_manifest_is_ignored() { direction: Direction::Row, children: vec![ WeightedChild { - node: LayoutNode::Leaf(agent_leaf(orphan_leaf, Some(orphan_agent), Some("c1"), true)), + node: LayoutNode::Leaf(agent_leaf( + orphan_leaf, + Some(orphan_agent), + Some("c1"), + true, + )), weight: 1.0, }, WeightedChild { @@ -667,7 +666,11 @@ async fn failing_profile_store_lists_agents_without_resume_support() { let uc = make_use_case(&store, &fs, &contexts, &profiles); let out = run(&uc).await; - assert_eq!(out.len(), 1, "agent still listed despite profile failure: {out:?}"); + assert_eq!( + out.len(), + 1, + "agent still listed despite profile failure: {out:?}" + ); assert_eq!(out[0].agent_id, agent); assert!( !out[0].resume_supported, diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 907c27b..bb0f68e 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -270,6 +270,7 @@ impl AgentRuntime for FakeRuntime { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } @@ -343,10 +344,10 @@ impl PtyPort for FakePty { }) } fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> { - self.writes - .lock() - .unwrap() - .push((handle.session_id, String::from_utf8_lossy(data).into_owned())); + self.writes.lock().unwrap().push(( + handle.session_id, + String::from_utf8_lossy(data).into_owned(), + )); Ok(()) } fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> { @@ -380,7 +381,6 @@ impl EventBus for SpyBus { } } - struct SeqIds(Mutex); impl SeqIds { fn new() -> Self { @@ -655,13 +655,19 @@ async fn agent_run_visible_other_cell_refuses_when_live_elsewhere() { assert_eq!(err.code(), "AGENT_ALREADY_RUNNING", "got {err:?}"); match err { application::AppError::AgentAlreadyRunning { node_id, .. } => { - assert_eq!(node_id, first_cell, "reports the live host cell, not target"); + assert_eq!( + node_id, first_cell, + "reports the live host cell, not target" + ); } other => panic!("expected AgentAlreadyRunning, got {other:?}"), } let session = fx.sessions.session(&sid(777)).expect("live session"); - assert_eq!(session.node_id, first_cell, "session stays on its host cell"); + assert_eq!( + session.node_id, first_cell, + "session stays on its host cell" + ); assert_eq!(fx.pty.spawns().len(), 1, "refused launch must not respawn"); } @@ -784,7 +790,6 @@ async fn create_skill_honours_global_scope() { assert_eq!(saved[0].scope, SkillScope::Global); } - // --------------------------------------------------------------------------- // Option 1 « Terminal + MCP » — idea_ask_agent / idea_reply (B-3 / B-4) // @@ -826,7 +831,11 @@ impl TestMailbox { } } fn pending(&self, agent: &AgentId) -> usize { - self.queues.lock().unwrap().get(agent).map_or(0, VecDeque::len) + self.queues + .lock() + .unwrap() + .get(agent) + .map_or(0, VecDeque::len) } /// Ids of the tickets queued for `agent`, in FIFO order (test inspection). fn ticket_ids(&self, agent: &AgentId) -> Vec { @@ -953,7 +962,10 @@ impl InputMediator for TestMediator { self.preempts.lock().unwrap().push(agent); } fn mark_idle(&self, agent: AgentId) { - self.busy.lock().unwrap().insert(agent, AgentBusyState::Idle); + self.busy + .lock() + .unwrap() + .insert(agent, AgentBusyState::Idle); } fn busy_state(&self, agent: AgentId) -> AgentBusyState { self.busy @@ -1004,7 +1016,9 @@ impl ConversationRegistry for TestConversations { } fn bind_session(&self, id: ConversationId, session: SessionRef) { if let Some(c) = self.by_id.lock().unwrap().get_mut(&id) { - c.session = ConversationSession::Live { handle_ref: session }; + c.session = ConversationSession::Live { + handle_ref: session, + }; } } fn suspend(&self, id: ConversationId, resumable_id: Option) { @@ -1131,8 +1145,7 @@ async fn await_until bool>(cond: F) { .expect("condition never reached within guard (possible hang/deadlock)"); } -const ASK_JSON: &str = - r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#; +const ASK_JSON: &str = r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#; /// Builds an `agent.reply` command for `from` with `result` (the handshake-injected /// path, exactly what the MCP server produces from a peer's `idea_reply`). @@ -1171,12 +1184,25 @@ async fn ask_live_pty_target_writes_task_and_returns_reply() { await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; // The task was written into the target's terminal, prefixed for idea_reply. let writes = fx.pty.writes_for(sid(800)); - assert_eq!(writes.len(), 1, "exactly one task write to the live terminal"); + assert_eq!( + writes.len(), + 1, + "exactly one task write to the live terminal" + ); assert!(writes[0].contains("Analyse §17"), "task body written"); - assert!(writes[0].contains("[IdeA · tâche"), "delegated-task prefix present"); - assert!(writes[0].contains("idea_reply") || writes[0].contains("ticket"), "carries ticket/idea_reply cue"); + assert!( + writes[0].contains("[IdeA · tâche"), + "delegated-task prefix present" + ); + assert!( + writes[0].contains("idea_reply") || writes[0].contains("ticket"), + "carries ticket/idea_reply cue" + ); // No PTY spawned: the live terminal was reused. - assert!(fx.pty.spawns().is_empty(), "live target reused, not respawned"); + assert!( + fx.pty.spawns().is_empty(), + "live target reused, not respawned" + ); // The target renders its result via idea_reply ⇒ resolves the head ticket. fx.service @@ -1230,6 +1256,124 @@ async fn idea_reply_marks_emitter_idle() { ); } +/// Régression (garde RAII de fin de tour) — LE test décisif : un `ask_agent` dont le +/// **futur est abandonné (drop)** alors qu'il attendait la réponse doit laisser la cible +/// `Idle`, **pas** `Busy` à vie. Avant le fix, aucune branche du `match`/`select!` ne +/// s'exécutait sur un drop ⇒ l'agent restait `Busy` et toute délégation suivante était +/// mise en file derrière ce tour fantôme sans jamais être livrée. +#[tokio::test] +async fn dropped_ask_future_frees_busy_target() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + // Le tour a démarré : la cible est Busy, un ticket est en file. + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + assert!( + fx.mediator.busy_state(aid(1)).is_busy(), + "cible Busy pendant le tour délégué" + ); + + // Le demandeur interrompt son appel ⇒ le futur `ask_agent` est dropped. + ask.abort(); + let _ = ask.await; // récolte la JoinError(Cancelled), ignorée. + + // Le garde RAII a ramené la cible Idle ET retiré le ticket fantôme de la FIFO. + await_until(|| !fx.mediator.busy_state(aid(1)).is_busy()).await; + assert_eq!( + fx.mediator.busy_state(aid(1)), + AgentBusyState::Idle, + "futur dropped ⇒ la cible est ramenée Idle par le garde (fix cause racine)" + ); + assert_eq!( + fx.mailbox.pending(&aid(1)), + 0, + "ticket fantôme retiré de la FIFO au Drop du garde" + ); +} + +/// Régression (garde RAII) — une **2e délégation** vers une cible dont un 1er `ask` a +/// été abandonné est bien livrée : l'agent n'est pas coincé `Busy`, son tour suivant +/// démarre et peut être résolu par `idea_reply`. +#[tokio::test] +async fn second_delegation_delivered_after_dropped_ask() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + + // 1er ask : démarre puis est abandonné (drop). + let svc = Arc::clone(&fx.service); + let ask1 = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + ask1.abort(); + let _ = ask1.await; + await_until(|| fx.mailbox.pending(&aid(1)) == 0).await; + + // 2e ask vers la MÊME cible : doit démarrer un nouveau tour (pas coincé derrière un + // tour fantôme) et se laisser résoudre. + let svc2 = Arc::clone(&fx.service); + let ask2 = tokio::spawn(async move { svc2.dispatch(&project(), cmd(ASK_JSON)).await }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + assert!( + fx.mediator.busy_state(aid(1)).is_busy(), + "le 2e tour démarre bien (cible Busy) — preuve qu'elle n'était pas coincée" + ); + + fx.service + .dispatch(&project(), reply_cmd(aid(1), "réponse au 2e tour")) + .await + .expect("reply ok"); + let out = timeout(TEST_GUARD, ask2) + .await + .expect("le 2e ask se termine") + .expect("join ok") + .expect("ask ok"); + assert_eq!(out.reply.as_deref(), Some("réponse au 2e tour")); + assert_eq!( + fx.mediator.busy_state(aid(1)), + AgentBusyState::Idle, + "cible Idle après résolution du 2e tour" + ); +} + +/// Régression (garde RAII) — la **branche erreur/timeout** (canal fermé) ramène aussi la +/// cible `Idle`. Avant le fix, ces branches faisaient `cancel_head` mais jamais +/// `mark_idle` ⇒ l'agent restait `Busy`. On déclenche la fermeture du canal en retirant +/// le ticket de tête (même nettoyage que le timeout de tour). +#[tokio::test] +async fn cancelled_ask_marks_target_idle() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + seed_live_pty(&fx.sessions, aid(1), sid(800)); + + let svc = Arc::clone(&fx.service); + let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); + await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; + assert!(fx.mediator.busy_state(aid(1)).is_busy()); + + // Retire le ticket de tête (drop du sender) ⇒ l'ask voit un canal fermé et part en + // erreur typée (PROCESS), le MÊME nettoyage que le timeout de tour. + let t = fx.mailbox.ticket_ids(&aid(1))[0]; + fx.mailbox.cancel_head(aid(1), t); + let err = timeout(TEST_GUARD, ask) + .await + .expect("ask retourne vite sur canal fermé") + .expect("join ok") + .unwrap_err(); + assert!( + matches!(err.code(), "PROCESS" | "INVALID"), + "ask sur canal fermé ⇒ erreur typée : {err:?}" + ); + // Le garde a ramené la cible Idle (la FIFO peut avancer). + assert_eq!( + fx.mediator.busy_state(aid(1)), + AgentBusyState::Idle, + "branche erreur ⇒ cible Idle (garde RAII)" + ); +} + /// A dead target is launched in the background (PTY) before the task is written. #[tokio::test] async fn ask_dead_target_launches_pty_then_writes_and_replies() { @@ -1242,7 +1386,11 @@ async fn ask_dead_target_launches_pty_then_writes_and_replies() { await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; // The dead target was launched as a PTY (spawn recorded, session registered). - assert_eq!(fx.pty.spawns(), vec![sid(777)], "dead target launched as PTY"); + assert_eq!( + fx.pty.spawns(), + vec![sid(777)], + "dead target launched as PTY" + ); assert_eq!(fx.sessions.session_for_agent(&aid(1)), Some(sid(777))); // The delegated task was written into the freshly-launched terminal (the Stdin // context injection may also write the persona, so we look for the task prefix). @@ -1289,7 +1437,10 @@ async fn ask_success_publishes_agent_replied_event() { .events() .into_iter() .find_map(|e| match e { - DomainEvent::AgentReplied { agent_id, reply_len } => Some((agent_id, reply_len)), + DomainEvent::AgentReplied { + agent_id, + reply_len, + } => Some((agent_id, reply_len)), _ => None, }) .expect("AgentReplied must be published"); @@ -1358,7 +1509,11 @@ async fn reply_without_pending_ask_is_invalid() { .dispatch(&project(), reply_cmd(aid(7), "orphan result")) .await .unwrap_err(); - assert_eq!(err.code(), "INVALID", "orphan reply is typed INVALID: {err:?}"); + assert_eq!( + err.code(), + "INVALID", + "orphan reply is typed INVALID: {err:?}" + ); } /// `Reply` resolves the HEAD ticket of the emitting agent's queue (positional @@ -1417,8 +1572,12 @@ async fn ask_different_targets_run_in_parallel() { let mut inner = contexts.0.lock().unwrap(); inner.manifest.entries.push(ManifestEntry::from_agent(&a)); inner.manifest.entries.push(ManifestEntry::from_agent(&b)); - inner.contents.insert(a.context_path.clone(), "# a".to_owned()); - inner.contents.insert(b.context_path.clone(), "# b".to_owned()); + inner + .contents + .insert(a.context_path.clone(), "# a".to_owned()); + inner + .contents + .insert(b.context_path.clone(), "# b".to_owned()); } let fx = ask_fixture(contexts); seed_live_pty(&fx.sessions, aid(1), sid(801)); @@ -1498,10 +1657,10 @@ impl FileSystem for CapturingFs { Err(FsError::NotFound(path.as_str().to_owned())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { - self.0 - .lock() - .unwrap() - .push((path.as_str().to_owned(), String::from_utf8_lossy(data).into_owned())); + self.0.lock().unwrap().push(( + path.as_str().to_owned(), + String::from_utf8_lossy(data).into_owned(), + )); Ok(()) } async fn exists(&self, _path: &RemotePath) -> Result { @@ -1640,8 +1799,7 @@ fn ask_fixture_ex( .with_events(Arc::new(bus.clone())); if let Some(p) = provider { - service = - service.with_mcp_runtime_provider(p as Arc); + service = service.with_mcp_runtime_provider(p as Arc); } AskFixtureEx { @@ -1677,7 +1835,11 @@ async fn f1_ask_dead_target_injects_provider_runtime_into_mcp_json() { let calls = provider.calls(); assert_eq!(calls.len(), 1, "provider interrogé exactement une fois"); assert_eq!(calls[0].0, project().id, "interrogé pour le bon projet"); - assert_eq!(calls[0].1, aid(1), "interrogé pour la cible relancée (= --requester)"); + assert_eq!( + calls[0].1, + aid(1), + "interrogé pour la cible relancée (= --requester)" + ); // La déclaration `.mcp.json` écrite porte les valeurs sentinelles du runtime. let decl = fx @@ -1780,13 +1942,20 @@ async fn f2_ask_codex_target_is_invalid_no_launch() { .await .unwrap_err(); - assert_eq!(err.code(), "INVALID", "garde F2 = erreur typée Invalid: {err:?}"); + assert_eq!( + err.code(), + "INVALID", + "garde F2 = erreur typée Invalid: {err:?}" + ); let msg = err.to_string(); assert!( msg.contains("idea_*") || msg.contains("pont") || msg.contains(".mcp.json"), "message explicite sur le pont non supporté: {msg}" ); - assert!(fx.pty.spawns().is_empty(), "aucun lancement sur cible refusée"); + assert!( + fx.pty.spawns().is_empty(), + "aucun lancement sur cible refusée" + ); assert_eq!(fx.mailbox.pending(&aid(1)), 0, "aucun ticket enfilé"); } @@ -1856,7 +2025,10 @@ fn ask_fixture_c3(contexts: FakeContexts) -> AskFixtureC3 { None, )); let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); - let close = Arc::new(CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions))); + let close = Arc::new(CloseTerminal::new( + Arc::new(pty.clone()), + Arc::clone(&sessions), + )); let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone()))); let create_skill = Arc::new(CreateSkill::new( Arc::new(RecordingSkills::default()) as Arc, @@ -1898,8 +2070,12 @@ fn seed_two_agents(contexts: &FakeContexts) { let mut inner = contexts.0.lock().unwrap(); inner.manifest.entries.push(ManifestEntry::from_agent(&a)); inner.manifest.entries.push(ManifestEntry::from_agent(&b)); - inner.contents.insert(a.context_path.clone(), "# a".to_owned()); - inner.contents.insert(b.context_path.clone(), "# b".to_owned()); + inner + .contents + .insert(a.context_path.clone(), "# a".to_owned()); + inner + .contents + .insert(b.context_path.clone(), "# b".to_owned()); } /// An `agent.message` from agent A (uuid requester) to a named target. @@ -1920,15 +2096,19 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() { let svc = Arc::clone(&fx.service); let ask = tokio::spawn(async move { - svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "do B"))) - .await + svc.dispatch( + &project(), + cmd(&ask_from_agent_json(aid(1), "beta", "do B")), + ) + .await }); await_until(|| fx.mailbox.pending(&aid(2)) == 1).await; // The registry now holds the A↔B thread (agent 1 ↔ agent 2), not User↔B. - let a_to_b = fx - .conversations - .resolve(ConversationParty::agent(aid(1)), ConversationParty::agent(aid(2))); + let a_to_b = fx.conversations.resolve( + ConversationParty::agent(aid(1)), + ConversationParty::agent(aid(2)), + ); let user_b = fx .conversations .resolve(ConversationParty::User, ConversationParty::agent(aid(2))); @@ -1941,7 +2121,10 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() { "ask A→B resolved the A↔B pair" ); // The live B session is bound to the A↔B thread (session keyed by conversation). - assert!(a_to_b.session.is_live(), "A↔B thread bound to a live session"); + assert!( + a_to_b.session.is_live(), + "A↔B thread bound to a live session" + ); fx.service .dispatch(&project(), reply_cmd(aid(2), "B done")) @@ -1964,21 +2147,30 @@ async fn cycle_a_to_b_to_a_is_refused_before_deadlock() { // A→B in flight (held — B never replies during the test): edge A→B is posted. let svc = Arc::clone(&fx.service); let _ask_ab = tokio::spawn(async move { - svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B"))) - .await + svc.dispatch( + &project(), + cmd(&ask_from_agent_json(aid(1), "beta", "to B")), + ) + .await }); await_until(|| fx.mailbox.pending(&aid(2)) == 1).await; // Now B→A would close the cycle ⇒ typed INVALID, no enqueue on A, returns fast. let err = timeout( TEST_GUARD, - fx.service - .dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "back to A"))), + fx.service.dispatch( + &project(), + cmd(&ask_from_agent_json(aid(2), "alpha", "back to A")), + ), ) .await .expect("cycle ask must return fast, never deadlock") .unwrap_err(); - assert_eq!(err.code(), "INVALID", "re-entrant cycle is typed INVALID: {err:?}"); + assert_eq!( + err.code(), + "INVALID", + "re-entrant cycle is typed INVALID: {err:?}" + ); assert!( err.to_string().contains("cycle") || err.to_string().contains("ré-entrante"), "explicit cycle message: {err}" @@ -1999,8 +2191,11 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() { // A→B completes. let svc = Arc::clone(&fx.service); let ask_ab = tokio::spawn(async move { - svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B"))) - .await + svc.dispatch( + &project(), + cmd(&ask_from_agent_json(aid(1), "beta", "to B")), + ) + .await }); await_until(|| fx.mailbox.pending(&aid(2)) == 1).await; fx.service @@ -2012,8 +2207,11 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() { // Edge A→B is now gone ⇒ B→A is allowed (would only cycle if A→B still pending). let svc2 = Arc::clone(&fx.service); let ask_ba = tokio::spawn(async move { - svc2.dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "now to A"))) - .await + svc2.dispatch( + &project(), + cmd(&ask_from_agent_json(aid(2), "alpha", "now to A")), + ) + .await }); await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; fx.service @@ -2021,7 +2219,11 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() { .await .expect("reply ok"); let out = timeout(TEST_GUARD, ask_ba).await.unwrap().unwrap().unwrap(); - assert_eq!(out.reply.as_deref(), Some("A ok"), "reverse delegation allowed"); + assert_eq!( + out.reply.as_deref(), + Some("A ok"), + "reverse delegation allowed" + ); } /// C3 — reply correlates **by ticket** (deterministic id-keyed resolution). The agent @@ -2064,7 +2266,11 @@ async fn reply_correlates_by_ticket() { .dispatch(&project(), reply_cmd_ticket(aid(2), bogus, "wrong")) .await .unwrap_err(); - assert_eq!(err.code(), "INVALID", "unknown ticket id ⇒ typed INVALID: {err:?}"); + assert_eq!( + err.code(), + "INVALID", + "unknown ticket id ⇒ typed INVALID: {err:?}" + ); let t2 = fx.mailbox.ticket_ids(&aid(2))[0]; fx.service .dispatch(&project(), reply_cmd_ticket(aid(2), t2, "R2")) @@ -2129,7 +2335,11 @@ async fn timeout_path_frees_queue_and_keeps_target_alive() { "closed-channel ask is a typed error: {err:?}" ); // Queue freed, and the target B is still live (never killed by the failed ask). - assert_eq!(fx.mailbox.pending(&aid(2)), 0, "queue freed after retirement"); + assert_eq!( + fx.mailbox.pending(&aid(2)), + 0, + "queue freed after retirement" + ); assert_eq!( fx.sessions.session_for_agent(&aid(2)), Some(sid(802)), @@ -2232,8 +2442,11 @@ async fn submit_and_ask_share_one_fifo_per_agent() { // A delegation A→B blocks in B's FIFO (it awaits a reply). let svc = Arc::clone(&fx.service); let ask = tokio::spawn(async move { - svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(10), "architect", "deleg"))) - .await + svc.dispatch( + &project(), + cmd(&ask_from_agent_json(aid(10), "architect", "deleg")), + ) + .await }); await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; @@ -2256,7 +2469,8 @@ async fn submit_and_ask_share_one_fifo_per_agent() { ); // Unblock the delegation so the spawned task ends cleanly. - fx.mailbox.cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]); + fx.mailbox + .cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]); let _ = timeout(TEST_GUARD, ask).await; } @@ -2275,11 +2489,7 @@ async fn interrupt_preempts_without_enqueue_or_resolve() { .expect("interrupt succeeds"); assert_eq!(fx.mediator.preempts(), vec![aid(1)], "preempt called once"); - assert_eq!( - fx.mailbox.pending(&aid(1)), - 0, - "interrupt enqueues nothing" - ); + assert_eq!(fx.mailbox.pending(&aid(1)), 0, "interrupt enqueues nothing"); } /// A human submit to an unknown agent id is a typed NotFound (never a panic). @@ -2304,7 +2514,10 @@ async fn interrupt_unknown_agent_is_not_found() { .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "unknown agent interrupt: {err:?}"); - assert!(fx.mediator.preempts().is_empty(), "no preempt on unknown agent"); + assert!( + fx.mediator.preempts().is_empty(), + "no preempt on unknown agent" + ); } // --------------------------------------------------------------------------- @@ -2323,13 +2536,13 @@ async fn interrupt_unknown_agent_is_not_found() { // in `conversation_record.rs`. // --------------------------------------------------------------------------- +use application::{OrchestratorOutcome, RecordTurn, RecordTurnProvider}; +use domain::ports::Clock; +use domain::InputSource; use domain::{ ConversationLog, ConversationTurn as DomainTurn, Handoff, HandoffStore, HandoffSummarizer, TurnId, TurnRole, }; -use application::{OrchestratorOutcome, RecordTurn, RecordTurnProvider}; -use domain::ports::Clock; -use domain::InputSource; /// A deterministic [`Clock`]: every persisted turn is stamped with this constant. struct FixedClock(i64); @@ -2367,7 +2580,9 @@ impl ConversationLog for RecordingLog { turn: DomainTurn, ) -> Result<(), StoreError> { if self.fail_append { - return Err(StoreError::Io("recording log: forced append failure".to_owned())); + return Err(StoreError::Io( + "recording log: forced append failure".to_owned(), + )); } self.appends.lock().unwrap().push(turn); Ok(()) @@ -2399,11 +2614,7 @@ impl HandoffStore for NoopHandoffStore { async fn load(&self, conversation: ConversationId) -> Result, StoreError> { Ok(self.0.lock().unwrap().get(&conversation).cloned()) } - async fn save( - &self, - conversation: ConversationId, - handoff: Handoff, - ) -> Result<(), StoreError> { + async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> { self.0.lock().unwrap().insert(conversation, handoff); Ok(()) } @@ -2419,7 +2630,10 @@ impl HandoffSummarizer for ConcatSummarizer { for t in new_turns { summary.push_str(&t.text); } - let up_to = new_turns.last().map(|t| t.id).expect("at least one new turn"); + let up_to = new_turns + .last() + .map(|t| t.id) + .expect("at least one new turn"); Handoff::new(summary, up_to, None) } } @@ -2564,7 +2778,11 @@ async fn p6b_user_ask_records_prompt_then_response_pair() { assert_eq!(out.reply.as_deref(), Some("the §17 answer")); let appends = log.appends(); - assert_eq!(appends.len(), 2, "exactly two turns persisted (Prompt + Response)"); + assert_eq!( + appends.len(), + 2, + "exactly two turns persisted (Prompt + Response)" + ); let prompt = &appends[0]; let response = &appends[1]; @@ -2575,12 +2793,26 @@ async fn p6b_user_ask_records_prompt_then_response_pair() { ); // Order + role. assert_eq!(prompt.role, TurnRole::Prompt, "first turn is the Prompt"); - assert_eq!(response.role, TurnRole::Response, "second turn is the Response"); + assert_eq!( + response.role, + TurnRole::Response, + "second turn is the Response" + ); // Text: task then result. - assert_eq!(prompt.text, "Analyse §17", "prompt text is the delegated task"); - assert_eq!(response.text, "the §17 answer", "response text is the reply result"); + assert_eq!( + prompt.text, "Analyse §17", + "prompt text is the delegated task" + ); + assert_eq!( + response.text, "the §17 answer", + "response text is the reply result" + ); // Source: Human prompt (no agent requester), target-sourced response. - assert_eq!(prompt.source, InputSource::Human, "User ask ⇒ Human prompt source"); + assert_eq!( + prompt.source, + InputSource::Human, + "User ask ⇒ Human prompt source" + ); assert_eq!( response.source, InputSource::agent(aid(1)), @@ -2603,8 +2835,11 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() { Arc::new(ConcatSummarizer) as Arc, )); let provider = Arc::new(SharedRecordProvider(record)) as Arc; - let fx = - ask_fixture_with_record(FakeContexts::with_agent(&architect, "# persona"), provider, 0); + let fx = ask_fixture_with_record( + FakeContexts::with_agent(&architect, "# persona"), + provider, + 0, + ); seed_live_pty(&fx.sessions, aid(1), sid(800)); // Requester is agent A (id 7). The orchestrator threads `requester` from the @@ -2625,7 +2860,10 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() { // assert both turns landed on it. let convs = TestConversations::new(); let expected = convs - .resolve(ConversationParty::agent(a), ConversationParty::agent(aid(1))) + .resolve( + ConversationParty::agent(a), + ConversationParty::agent(aid(1)), + ) .id; // (Resolution is deterministic per pair within one registry; we instead assert the // turns share a thread and the prompt source carries A — the load-bearing facts.) @@ -2655,7 +2893,11 @@ async fn p6b_no_provider_is_silent_no_op() { seed_live_pty(&fx.sessions, aid(1), sid(800)); let out = run_ask_roundtrip(&fx, ASK_JSON, aid(1), "ok").await; - assert_eq!(out.reply.as_deref(), Some("ok"), "ask succeeds without persistence"); + assert_eq!( + out.reply.as_deref(), + Some("ok"), + "ask succeeds without persistence" + ); } /// Provider wired but returns `None` for the root ⇒ ask still succeeds (best-effort @@ -2668,7 +2910,11 @@ async fn p6b_provider_returns_none_is_silent_no_op() { seed_live_pty(&fx.sessions, aid(1), sid(800)); let out = run_ask_roundtrip(&fx, ASK_JSON, aid(1), "ok").await; - assert_eq!(out.reply.as_deref(), Some("ok"), "ask succeeds when provider declines"); + assert_eq!( + out.reply.as_deref(), + Some("ok"), + "ask succeeds when provider declines" + ); } /// `RecordTurn` built on a log whose `append` always fails ⇒ persistence errors are @@ -2693,5 +2939,263 @@ async fn p6b_failing_record_does_not_degrade_ask() { "a failing append must not turn a successful delegation into an error" ); // The failing log recorded nothing (every append errored). - assert!(failing_log.appends().is_empty(), "no turns stored when append fails"); + assert!( + failing_log.appends().is_empty(), + "no turns stored when append fails" + ); +} + +// =========================================================================== +// Lot 1b — auto-lancement d'une cible **froide** structurée sur le chemin `ask` +// =========================================================================== +// +// Avant le Lot 1b, `ask_agent` ne routait vers `ask_structured` que si la cible avait +// **déjà** une session structurée vivante ; une cible structurée froide tombait dans +// `ensure_live_pty` (chemin PTY) — or une cible structurée n'a pas de PTY, donc le tour +// restait bloqué `Busy` (débloquable seulement par un `idea_reply` explicite). Le Lot 1b +// démarre la session structurée via le **même** launcher que le chemin PTY (qui route +// §17.4 vers `launch_structured` et l'insère dans le registre **partagé**), puis route +// vers `ask_structured` : le `Final` déterministe de la session débloque le tour et +// marque l'agent `Idle` (`mark_idle`), **sans** `idea_reply` ni PTY. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use application::StructuredSessions; +use domain::ports::{ + AgentSession, AgentSessionError, AgentSessionFactory, ReplyEvent, ReplyStream, +}; + +/// Session structurée fake : `send()` rend un unique [`ReplyEvent::Final`] qui **écho** +/// le prompt reçu — c'est ce `Final` que `drain_with_readiness` transforme en réponse +/// du tour **et** en `mark_idle`. Aucun `idea_reply` n'est nécessaire. +struct EchoSession { + id: SessionId, +} +#[async_trait] +impl AgentSession for EchoSession { + fn id(&self) -> SessionId { + self.id + } + fn conversation_id(&self) -> Option { + None + } + async fn send(&self, prompt: &str) -> Result { + let stream: ReplyStream = Box::new( + vec![ReplyEvent::Final { + content: format!("structured: {prompt}"), + }] + .into_iter(), + ); + Ok(stream) + } + async fn shutdown(&self) -> Result<(), AgentSessionError> { + Ok(()) + } +} + +/// Fabrique fake : `supports` = profil structuré, et `start` **compte** les démarrages +/// (preuve qu'une session froide a bien été auto-lancée) en rendant une [`EchoSession`]. +#[derive(Clone, Default)] +struct CountingFactory { + starts: Arc, + next_id: Arc>, +} +impl CountingFactory { + fn new() -> Self { + Self { + starts: Arc::new(AtomicUsize::new(0)), + next_id: Arc::new(Mutex::new(9000)), + } + } + fn start_count(&self) -> usize { + self.starts.load(Ordering::SeqCst) + } +} +#[async_trait] +impl AgentSessionFactory for CountingFactory { + fn supports(&self, profile: &AgentProfile) -> bool { + profile.structured_adapter.is_some() + } + async fn start( + &self, + _profile: &AgentProfile, + _ctx: &PreparedContext, + _cwd: &ProjectPath, + _session: &SessionPlan, + _sandbox: Option<&domain::sandbox::SandboxPlan>, + ) -> Result, AgentSessionError> { + self.starts.fetch_add(1, Ordering::SeqCst); + let id = { + let mut n = self.next_id.lock().unwrap(); + let id = SessionId::from_uuid(Uuid::from_u128(*n)); + *n += 1; + id + }; + Ok(Arc::new(EchoSession { id })) + } +} + +/// Tout le câblage `ask` **structuré** : launcher + service voient le **même** registre +/// `StructuredSessions`, et le launcher porte la fabrique (routage §17.4). +struct StructuredAskFixture { + service: Arc, + structured: Arc, + factory: CountingFactory, + pty: FakePty, +} + +fn structured_ask_fixture(contexts: FakeContexts) -> StructuredAskFixture { + let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()])); + let sessions = Arc::new(TerminalSessions::new()); + let pty = FakePty::new(sid(777)); + let bus = SpyBus::default(); + let mailbox = Arc::new(TestMailbox::new()); + let structured = Arc::new(StructuredSessions::new()); + let factory = CountingFactory::new(); + + let create = Arc::new(CreateAgentFromScratch::new( + Arc::new(contexts.clone()), + Arc::new(SeqIds::new()), + Arc::new(bus.clone()), + )); + // Launcher câblé **structuré** : pour un profil à `structured_adapter`, `execute` + // route §17.4 → `launch_structured`, démarre la session via la fabrique et l'insère + // dans CE registre partagé (le même `Arc` que le service ci-dessous). + let launch = Arc::new( + LaunchAgent::new( + Arc::new(contexts.clone()), + Arc::clone(&profiles) as Arc, + Arc::new(FakeRuntime), + Arc::new(FakeFs), + Arc::new(pty.clone()), + Arc::new(FakeSkills), + Arc::clone(&sessions), + Arc::new(bus.clone()), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall), + None, + ) + .with_structured( + Arc::new(factory.clone()) as Arc, + Arc::clone(&structured), + ), + ); + let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); + let close = Arc::new(CloseTerminal::new( + Arc::new(pty.clone()), + Arc::clone(&sessions), + )); + let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone()))); + let create_skill = Arc::new(CreateSkill::new( + Arc::new(RecordingSkills::default()) as Arc, + Arc::new(SeqIds::new()), + )); + let mediator = Arc::new(TestMediator::new( + Arc::clone(&mailbox), + Arc::new(pty.clone()) as Arc, + )); + let conversations = Arc::new(TestConversations::new()) as Arc; + + let service = Arc::new( + OrchestratorService::new( + create, + launch, + list, + close, + update, + create_skill, + Arc::clone(&profiles) as Arc, + Arc::clone(&sessions), + ) + .with_input_mediator( + Arc::clone(&mediator) as Arc, + Arc::clone(&mailbox) as Arc, + ) + .with_conversations(conversations) + .with_events(Arc::new(bus.clone())) + // Même registre que le launcher : `ask_agent` y relit la session fraîchement + // insérée par `launch_structured`. + .with_structured(Arc::clone(&structured)), + ); + + StructuredAskFixture { + service, + structured, + factory, + pty, + } +} + +/// Cible **froide** structurée (adaptateur Claude, aucune session vivante) : `ask_agent` +/// auto-lance sa session structurée via le launcher (factory.start appelé **une** fois), +/// puis la draine — le `Final` débloque le round-trip **sans** `idea_reply` ni PTY. +#[tokio::test] +async fn ask_cold_structured_target_autolaunches_session_and_final_unblocks() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + + // Pré-condition : aucune session structurée vivante pour la cible (elle est froide). + assert!( + fx.structured.session_for_agent(&aid(1)).is_none(), + "cible structurée froide : aucune session avant l'ask" + ); + + let out = fx + .service + .dispatch(&project(), cmd(ASK_JSON)) + .await + .expect("ask ok"); + + // Le `Final` de la session a rendu le contenu — sans aucun `idea_reply` dispatché. + assert_eq!( + out.reply.as_deref(), + Some("structured: Analyse §17"), + "le Final de la session auto-lancée débloque le tour" + ); + // Exactement **un** démarrage de session structurée (la cible froide a été lancée). + assert_eq!( + fx.factory.start_count(), + 1, + "une session structurée a été auto-lancée pour la cible froide" + ); + // La session est désormais vivante dans le registre partagé (insérée par le launcher). + assert!( + fx.structured.session_for_agent(&aid(1)).is_some(), + "la session auto-lancée est enregistrée dans StructuredSessions" + ); + // Chemin structuré ⇒ **aucun** PTY spawné (la cible n'a pas de terminal). + assert!( + fx.pty.spawns().is_empty(), + "cible structurée : aucun PTY spawné (chemin chat, pas terminal)" + ); +} + +/// Réutilisation : une **deuxième** délégation vers la **même** cible (désormais chaude) +/// ne relance **pas** de session — elle réutilise celle insérée au premier `ask`. Prouve +/// que le `session_for_agent` court-circuite l'auto-lancement (idempotence du chemin). +#[tokio::test] +async fn ask_warm_structured_target_reuses_session_without_relaunch() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + + // 1er ask : auto-lance (start_count == 1). + let _ = fx + .service + .dispatch(&project(), cmd(ASK_JSON)) + .await + .expect("first ask ok"); + assert_eq!(fx.factory.start_count(), 1, "1er ask auto-lance la session"); + + // 2e ask : cible chaude ⇒ aucune relance. + let out = fx + .service + .dispatch(&project(), cmd(ASK_JSON)) + .await + .expect("second ask ok"); + assert_eq!(out.reply.as_deref(), Some("structured: Analyse §17")); + assert_eq!( + fx.factory.start_count(), + 1, + "cible chaude : la session est réutilisée, aucune relance" + ); } diff --git a/crates/application/tests/permission_usecases.rs b/crates/application/tests/permission_usecases.rs new file mode 100644 index 0000000..6949c60 --- /dev/null +++ b/crates/application/tests/permission_usecases.rs @@ -0,0 +1,128 @@ +use std::sync::{Arc, Mutex}; + +use application::{ + ResolveAgentPermissions, ResolveAgentPermissionsInput, UpdateAgentPermissions, + UpdateAgentPermissionsInput, UpdateProjectPermissions, UpdateProjectPermissionsInput, +}; +use async_trait::async_trait; +use domain::ids::{AgentId, ProjectId}; +use domain::ports::{PermissionStore, StoreError}; +use domain::project::{Project, ProjectPath}; +use domain::remote::RemoteRef; +use domain::{PermissionSet, Posture, ProjectPermissions}; + +#[derive(Default)] +struct FakePermissionStore { + doc: Mutex, + saves: Mutex, +} + +#[async_trait] +impl PermissionStore for FakePermissionStore { + async fn load_permissions(&self, _project: &Project) -> Result { + Ok(self.doc.lock().unwrap().clone()) + } + + async fn save_permissions( + &self, + _project: &Project, + permissions: &ProjectPermissions, + ) -> Result<(), StoreError> { + *self.doc.lock().unwrap() = permissions.clone(); + *self.saves.lock().unwrap() += 1; + Ok(()) + } +} + +fn project() -> Project { + Project::new( + ProjectId::new_random(), + "permissions", + ProjectPath::new("/home/me/proj").unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + +#[tokio::test] +async fn update_project_permissions_replaces_defaults_and_persists() { + let store = Arc::new(FakePermissionStore::default()); + let use_case = UpdateProjectPermissions::new(store.clone()); + + let out = use_case + .execute(UpdateProjectPermissionsInput { + project: project(), + permissions: Some(PermissionSet::new(vec![], Posture::Ask)), + }) + .await + .unwrap(); + + assert_eq!( + out.permissions.project_defaults.unwrap().fallback(), + Posture::Ask + ); + assert_eq!(*store.saves.lock().unwrap(), 1); +} + +#[tokio::test] +async fn update_agent_permissions_adds_and_removes_sparse_override() { + let store = Arc::new(FakePermissionStore::default()); + let use_case = UpdateAgentPermissions::new(store.clone()); + let agent = AgentId::new_random(); + + use_case + .execute(UpdateAgentPermissionsInput { + project: project(), + agent_id: agent, + permissions: Some(PermissionSet::new(vec![], Posture::Deny)), + }) + .await + .unwrap(); + + assert_eq!( + store + .doc + .lock() + .unwrap() + .agent_permissions(agent) + .unwrap() + .fallback(), + Posture::Deny + ); + + let out = use_case + .execute(UpdateAgentPermissionsInput { + project: project(), + agent_id: agent, + permissions: None, + }) + .await + .unwrap(); + + assert!(out.permissions.agent_permissions(agent).is_none()); + assert_eq!(*store.saves.lock().unwrap(), 2); +} + +#[tokio::test] +async fn resolve_agent_permissions_returns_effective_policy() { + let agent = AgentId::new_random(); + let store = Arc::new(FakePermissionStore { + doc: Mutex::new(ProjectPermissions::new( + Some(PermissionSet::new(vec![], Posture::Ask)), + 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.effective.unwrap().fallback(), Posture::Ask); +} diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 2b65776..94c123e 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -218,16 +218,26 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() { assert!(out.is_first_run); // §17.3/D7: the wizard is seeded only with the *selectable* (structured- // drivable) profiles — Claude + Codex — not the full 4-profile catalogue. - assert_eq!(out.reference_profiles.len(), 2, "selectable catalogue seeded"); + assert_eq!( + out.reference_profiles.len(), + 2, + "selectable catalogue seeded" + ); let commands: Vec<&str> = out .reference_profiles .iter() .map(|p| p.command.as_str()) .collect(); - assert_eq!(commands, vec!["claude", "codex"], "only Claude/Codex offered"); + assert_eq!( + commands, + vec!["claude", "codex"], + "only Claude/Codex offered" + ); // Every seeded profile is selectable (the gate the menu relies on). assert!( - out.reference_profiles.iter().all(AgentProfile::is_selectable), + out.reference_profiles + .iter() + .all(AgentProfile::is_selectable), "seeded profiles must all be selectable" ); } diff --git a/crates/application/tests/send_blocking_d1.rs b/crates/application/tests/send_blocking_d1.rs index e46cd36..496cfbb 100644 --- a/crates/application/tests/send_blocking_d1.rs +++ b/crates/application/tests/send_blocking_d1.rs @@ -99,10 +99,14 @@ fn delta(t: &str) -> ReplyEvent { ReplyEvent::TextDelta { text: t.to_owned() } } fn tool(l: &str) -> ReplyEvent { - ReplyEvent::ToolActivity { label: l.to_owned() } + ReplyEvent::ToolActivity { + label: l.to_owned(), + } } fn final_(c: &str) -> ReplyEvent { - ReplyEvent::Final { content: c.to_owned() } + ReplyEvent::Final { + content: c.to_owned(), + } } // --------------------------------------------------------------------------- @@ -174,8 +178,9 @@ async fn empty_stream_is_io_error() { #[tokio::test] async fn send_decode_error_is_propagated() { - let session = - ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned()))); + let session = ScriptedSession::new(Script::Err(AgentSessionError::Decode( + "bad json".to_owned(), + ))); let out = send_blocking(&session, "x", None).await; assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned()))); } diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index 549955f..45d7738 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -303,6 +303,7 @@ impl AgentRuntime for FakeRuntime { context_plan: Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), + sandbox: None, }) } } @@ -519,6 +520,7 @@ impl AgentSessionFactory for FakeFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, session: &SessionPlan, + _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { self.starts .lock() @@ -570,10 +572,10 @@ impl FakeProviderSessionStore { /// so a P8c launch reading via [`ProviderSessionStore::get`] resolves the engine /// resumable for that pair (mirrors what a previous P8b launch would have stored). fn seed_sync(&self, conversation: ConversationId, provider: &str, resumable_id: &str) { - self.entries.lock().unwrap().insert( - (conversation, provider.to_owned()), - resumable_id.to_owned(), - ); + self.entries + .lock() + .unwrap() + .insert((conversation, provider.to_owned()), resumable_id.to_owned()); } /// Observed resumable id for a `(conversation, provider)` couple, if any. fn get_sync(&self, conversation: ConversationId, provider: &str) -> Option { @@ -606,10 +608,10 @@ impl ProviderSessionStore for FakeProviderSessionStore { if self.fail_set { return Err(StoreError::Io("forced set failure".to_owned())); } - self.entries - .lock() - .unwrap() - .insert((conversation, provider_id.to_owned()), resumable_id.to_owned()); + self.entries.lock().unwrap().insert( + (conversation, provider_id.to_owned()), + resumable_id.to_owned(), + ); Ok(()) } } @@ -862,14 +864,21 @@ async fn non_structured_profile_takes_pty_path_unchanged() { // PTY spawn appelé, factory jamais sollicitée. assert_eq!(f.pty.spawn_count(), 1, "pty path spawns"); - assert_eq!(f.factory.start_count(), 0, "factory not called for pty profile"); + assert_eq!( + f.factory.start_count(), + 0, + "factory not called for pty profile" + ); // Session côté registre PTY, rien côté structuré. assert_eq!(f.sessions.session_for_agent(&f.agent.id), Some(sid(777))); assert!(f.structured.session_for_agent(&f.agent.id).is_none()); // output.structured = None ; session PTY classique. - assert!(out.structured.is_none(), "no structured descriptor on pty path"); + assert!( + out.structured.is_none(), + "no structured descriptor on pty path" + ); assert_eq!(out.session.id, sid(777)); } @@ -911,7 +920,11 @@ async fn structured_launch_new_in_other_cell_refuses_when_live_elsewhere() { "no second factory.start on a refused launch" ); assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn"); - assert_eq!(f.structured.len(), 1, "still a single live structured session"); + assert_eq!( + f.structured.len(), + 1, + "still a single live structured session" + ); assert_eq!( f.structured.node_for_agent(&f.agent.id), Some(host), @@ -973,7 +986,11 @@ async fn structured_relaunch_other_cell_with_conversation_id_rebinds() { "no second factory.start on explicit reattach" ); assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn"); - assert_eq!(f.structured.len(), 1, "still a single live structured session"); + assert_eq!( + f.structured.len(), + 1, + "still a single live structured session" + ); assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(target)); let desc = out.structured.expect("descriptor on rebind"); assert_eq!(desc.session_id, sid(500), "same live session id"); @@ -1098,7 +1115,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() { let cwd = ProjectPath::new(ROOT).unwrap(); let session = f .factory - .start(&profile, &ctx, &cwd, &SessionPlan::None) + .start(&profile, &ctx, &cwd, &SessionPlan::None, None) .await .expect("seed structured session"); f.structured.insert(session, agent.id, host); @@ -1125,7 +1142,11 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() { f.pty.kills().is_empty(), "no PTY kill for a structured live session" ); - assert_eq!(f.pty.spawn_count(), 0, "no PTY spawn (target is structured)"); + assert_eq!( + f.pty.spawn_count(), + 0, + "no PTY spawn (target is structured)" + ); // Relance via la factory : un nouveau start (donc total 2). assert_eq!( @@ -1137,12 +1158,25 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() { // `ChangeAgentProfileOutput` ne surface qu'un snapshot `relaunched` (TerminalSession) // — on vérifie donc le registre structuré + le snapshot. // Seed consumed id 600; the relaunch's session is the factory's next id (601). - let relaunched = out.relaunched.expect("a live structured agent is relaunched"); - assert_eq!(relaunched.id, sid(601), "relaunch adopts the new structured id"); - assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell"); + let relaunched = out + .relaunched + .expect("a live structured agent is relaunched"); + assert_eq!( + relaunched.id, + sid(601), + "relaunch adopts the new structured id" + ); + assert_eq!( + relaunched.node_id, host, + "relaunch reopens in the same cell" + ); assert_eq!(f.structured.session_id_for_agent(&agent.id), Some(sid(601))); assert_eq!(f.structured.node_for_agent(&agent.id), Some(host)); - assert_eq!(f.structured.len(), 1, "single live structured session after swap"); + assert_eq!( + f.structured.len(), + 1, + "single live structured session after swap" + ); // Manifeste muté vers le nouveau profil. assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2))); } @@ -1169,12 +1203,19 @@ async fn swap_pty_live_session_keeps_a1_kill_behaviour() { // A1 d'origine : le PTY vivant est tué. assert_eq!(f.pty.kills(), vec![sid(42)], "the live PTY must be killed"); // Aucune session structurée n'a été créée ni shutdown. - assert_eq!(f.factory.start_count(), 0, "no structured start on pty swap"); + assert_eq!( + f.factory.start_count(), + 0, + "no structured start on pty swap" + ); assert_eq!(f.factory.shutdown_count(), 0, "no structured shutdown"); // Relance PTY : un spawn, même cellule. assert_eq!(f.pty.spawn_count(), 1, "the new engine spawns once"); let relaunched = out.relaunched.expect("a live agent is relaunched"); - assert_eq!(relaunched.node_id, host, "relaunch reopens in the same cell"); + assert_eq!( + relaunched.node_id, host, + "relaunch reopens in the same cell" + ); assert_eq!(relaunched.id, sid(777)); // The relaunched session lives in the PTY registry, not the structured one. assert_eq!(f.sessions.session_for_agent(&agent.id), Some(sid(777))); @@ -1321,7 +1362,8 @@ fn launch_fixture_p8b( ) .with_structured(Arc::new(factory), Arc::clone(&structured)); if let Some(store) = store { - let provider = Arc::new(FakeProviderSessionProvider(store)) as Arc; + let provider = + Arc::new(FakeProviderSessionProvider(store)) as Arc; launch = launch.with_provider_session_provider(provider); } (Arc::new(launch), agent) @@ -1335,8 +1377,11 @@ fn launch_fixture_p8b( async fn p8b_nominal_claude_writes_engine_id_under_pair_and_claude_key() { let store = Arc::new(FakeProviderSessionStore::new()); let factory = FakeFactory::new(500, Some("engine-xyz")); - let (launch, agent) = - launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + let (launch, agent) = launch_fixture_p8b( + structured_profile(pid(9)), + factory, + Some(Arc::clone(&store)), + ); // La cellule porte un id de paire UUID valide : c'est lui qui sert de clé. let pair = ConversationId::from_uuid(Uuid::from_u128(123)); @@ -1406,7 +1451,10 @@ async fn p8b_no_provider_wired_writes_nothing_launch_ok() { let mut input = launch_input(agent.id); input.conversation_id = Some(pair.to_string()); - launch.execute(input).await.expect("launch ok without provider"); + launch + .execute(input) + .await + .expect("launch ok without provider"); assert_eq!(store.len(), 0, "no provider wired ⇒ nothing written"); } @@ -1418,14 +1466,20 @@ async fn p8b_no_engine_id_writes_nothing_launch_ok() { let store = Arc::new(FakeProviderSessionStore::new()); // Factory avec conversation_id None ⇒ session.conversation_id() == None. let factory = FakeFactory::new(500, None); - let (launch, agent) = - launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + let (launch, agent) = launch_fixture_p8b( + structured_profile(pid(9)), + factory, + Some(Arc::clone(&store)), + ); let pair = ConversationId::from_uuid(Uuid::from_u128(123)); let mut input = launch_input(agent.id); input.conversation_id = Some(pair.to_string()); - launch.execute(input).await.expect("launch ok without engine id"); + launch + .execute(input) + .await + .expect("launch ok without engine id"); assert_eq!( store.len(), @@ -1441,8 +1495,11 @@ async fn p8b_no_engine_id_writes_nothing_launch_ok() { async fn p8b_store_set_error_does_not_fail_launch() { let store = Arc::new(FakeProviderSessionStore::failing()); let factory = FakeFactory::new(500, Some("engine-xyz")); - let (launch, agent) = - launch_fixture_p8b(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + let (launch, agent) = launch_fixture_p8b( + structured_profile(pid(9)), + factory, + Some(Arc::clone(&store)), + ); let pair = ConversationId::from_uuid(Uuid::from_u128(123)); let mut input = launch_input(agent.id); @@ -1498,8 +1555,11 @@ async fn p8c_structured_claude_resumes_engine_id_from_store_not_pair() { let store = Arc::new(FakeProviderSessionStore::new()); // Le moteur n'attribue pas d'id neuf (None) : le resume vient du store, pas du run. let factory = FakeFactory::new(500, None); - let (launch, agent, observed) = - launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + let (launch, agent, observed) = launch_fixture_p8c( + structured_profile(pid(9)), + factory, + Some(Arc::clone(&store)), + ); // Pré-remplit le store : (pair, "claude") → "engine-x". let pair = ConversationId::from_uuid(Uuid::from_u128(123)); @@ -1509,7 +1569,10 @@ async fn p8c_structured_claude_resumes_engine_id_from_store_not_pair() { let mut input = launch_input(agent.id); input.conversation_id = Some(pair.to_string()); - launch.execute(input).await.expect("structured launch resumes"); + launch + .execute(input) + .await + .expect("structured launch resumes"); // Le SessionPlan transmis à factory.start est Resume avec l'**engine id du store**. let starts = observed.starts(); @@ -1552,7 +1615,10 @@ async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() { let mut input = launch_input(agent.id); input.conversation_id = Some(pair.to_string()); - launch.execute(input).await.expect("structured codex launch"); + launch + .execute(input) + .await + .expect("structured codex launch"); let starts = observed.starts(); assert_eq!(starts.len(), 1); @@ -1572,8 +1638,11 @@ async fn p8c_structured_codex_resumes_engine_id_from_store_codex_key() { async fn p8c_provider_key_mismatch_falls_back_to_none() { let store = Arc::new(FakeProviderSessionStore::new()); let factory = FakeFactory::new(500, None); - let (launch, agent, observed) = - launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + let (launch, agent, observed) = launch_fixture_p8c( + structured_profile(pid(9)), + factory, + Some(Arc::clone(&store)), + ); let pair = ConversationId::from_uuid(Uuid::from_u128(123)); // Engine id rangé sous une AUTRE clé provider. @@ -1600,8 +1669,11 @@ async fn p8c_provider_key_mismatch_falls_back_to_none() { async fn p8c_structured_store_empty_resolves_to_none() { let store = Arc::new(FakeProviderSessionStore::new()); let factory = FakeFactory::new(500, None); - let (launch, agent, observed) = - launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + let (launch, agent, observed) = launch_fixture_p8c( + structured_profile(pid(9)), + factory, + Some(Arc::clone(&store)), + ); let pair = ConversationId::from_uuid(Uuid::from_u128(123)); let mut input = launch_input(agent.id); @@ -1624,8 +1696,11 @@ async fn p8c_structured_store_empty_resolves_to_none() { async fn p8c_structured_store_wired_fresh_cell_resolves_to_none() { let store = Arc::new(FakeProviderSessionStore::new()); let factory = FakeFactory::new(500, None); - let (launch, agent, observed) = - launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + let (launch, agent, observed) = launch_fixture_p8c( + structured_profile(pid(9)), + factory, + Some(Arc::clone(&store)), + ); // Cellule neuve : conversation_id = None. launch @@ -1649,8 +1724,11 @@ async fn p8c_structured_store_wired_fresh_cell_resolves_to_none() { async fn p8c_non_uuid_pair_id_with_store_resolves_to_none() { let store = Arc::new(FakeProviderSessionStore::new()); let factory = FakeFactory::new(500, None); - let (launch, agent, observed) = - launch_fixture_p8c(structured_profile(pid(9)), factory, Some(Arc::clone(&store))); + let (launch, agent, observed) = launch_fixture_p8c( + structured_profile(pid(9)), + factory, + Some(Arc::clone(&store)), + ); let mut input = launch_input(agent.id); input.conversation_id = Some("not-a-uuid".to_owned()); diff --git a/crates/application/tests/structured_registry_d1.rs b/crates/application/tests/structured_registry_d1.rs index 68226d2..0f74913 100644 --- a/crates/application/tests/structured_registry_d1.rs +++ b/crates/application/tests/structured_registry_d1.rs @@ -21,12 +21,8 @@ use std::sync::Arc; use async_trait::async_trait; use application::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions}; -use domain::ports::{ - AgentSession, AgentSessionError, PtyHandle, ReplyStream, -}; -use domain::{ - AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession, -}; +use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream}; +use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession}; use uuid::Uuid; // --- petits constructeurs déterministes ------------------------------------ @@ -121,10 +117,7 @@ fn structured_live_agents_lists_triples() { assert_eq!( live, - vec![ - (aid(10), nid(100), sid(1)), - (aid(20), nid(200), sid(2)), - ] + vec![(aid(10), nid(100), sid(1)), (aid(20), nid(200), sid(2)),] ); } diff --git a/crates/domain/src/conversation.rs b/crates/domain/src/conversation.rs index 47efb5e..e91b84f 100644 --- a/crates/domain/src/conversation.rs +++ b/crates/domain/src/conversation.rs @@ -366,11 +366,7 @@ mod tests { #[test] fn rejects_two_users() { assert_eq!( - Conversation::try_new( - conv_id(1), - ConversationParty::User, - ConversationParty::User - ), + Conversation::try_new(conv_id(1), ConversationParty::User, ConversationParty::User), Err(ConversationError::TwoUsers) ); } @@ -428,10 +424,7 @@ mod tests { // A→B exists; B→C is fine (no path C→…→B). let mut g = WaitForGraph::new(); g.add_edge(agent(1), agent(2)); // A waits B - assert!( - !g.would_cycle(agent(2), agent(3)), - "A→B→C is acyclic" - ); + assert!(!g.would_cycle(agent(2), agent(3)), "A→B→C is acyclic"); } #[test] diff --git a/crates/domain/src/conversation_log.rs b/crates/domain/src/conversation_log.rs index 410ac2c..188dfb6 100644 --- a/crates/domain/src/conversation_log.rs +++ b/crates/domain/src/conversation_log.rs @@ -31,9 +31,7 @@ use crate::ports::StoreError; /// Newtype autour d'[`uuid::Uuid`], calqué sur [`crate::conversation::ConversationId`] /// / [`crate::mailbox::TicketId`]. Sa monotonie (un id frappé à l'`append`) sert de /// **curseur** à la relecture incrémentale ([`ConversationLog::read`] avec `since`). -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, -)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct TurnId(pub uuid::Uuid); @@ -199,11 +197,7 @@ pub struct Handoff { impl Handoff { /// Construit un handoff à partir de ses composants. #[must_use] - pub fn new( - summary_md: impl Into, - up_to: TurnId, - objective: Option, - ) -> Self { + pub fn new(summary_md: impl Into, up_to: TurnId, objective: Option) -> Self { Self { summary_md: summary_md.into(), up_to, @@ -235,11 +229,7 @@ pub trait HandoffStore: Send + Sync { /// /// # Errors /// [`StoreError`] en cas d'échec d'écriture ou de sérialisation. - async fn save( - &self, - conversation: ConversationId, - handoff: Handoff, - ) -> Result<(), StoreError>; + async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError>; } /// Le résumeur incrémental de handoff (port driving, lot P4). @@ -435,7 +425,10 @@ mod tests { #[test] fn turn_role_serializes_camel_case() { - assert_eq!(serde_json::to_string(&TurnRole::Prompt).unwrap(), "\"prompt\""); + assert_eq!( + serde_json::to_string(&TurnRole::Prompt).unwrap(), + "\"prompt\"" + ); assert_eq!( serde_json::to_string(&TurnRole::Response).unwrap(), "\"response\"" @@ -581,7 +574,11 @@ mod tests { .unwrap(); } let last2 = log.last(c, 2).await.unwrap(); - assert_eq!(texts(&last2), vec!["c", "d"], "the 2 last, in insertion order"); + assert_eq!( + texts(&last2), + vec!["c", "d"], + "the 2 last, in insertion order" + ); } #[tokio::test] @@ -599,7 +596,10 @@ mod tests { .await .unwrap(); - assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]); + assert_eq!( + texts(&log.read(c1, None).await.unwrap()), + vec!["c1-a", "c1-b"] + ); assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]); // A cursor from one thread never leaks tours from another. assert_eq!(texts(&log.last(c2, 10).await.unwrap()), vec!["c2-a"]); diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 4c97b93..215634e 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -2,6 +2,7 @@ //! presentation layer (ARCHITECTURE §3.2). use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TemplateId}; +use crate::mailbox::TicketId; use crate::memory::MemorySlug; use crate::template::TemplateVersion; @@ -70,6 +71,19 @@ pub enum DomainEvent { /// `true` when a turn is in flight, `false` when the agent is idle. busy: bool, }, + /// An agent's **liveness** (alive/stalled) changed (lot 2, chantier + /// readiness/heartbeat). Emitted **once per transition** by the stall detector: + /// `Alive→Stalled` when no proof of liveness arrived for longer than the profile's + /// `stall_after_ms`, and `Stalled→Alive` when a late battement (delta / tool + /// activity / heartbeat) revives it or the agent returns to `Idle`. Discrete, + /// low-frequency beacon (no spam) relayed to the front so the mediated-input view + /// can badge a frozen agent. Purely advisory — the FIFO and the turn keep running. + AgentLivenessChanged { + /// The agent whose liveness changed. + agent_id: AgentId, + /// The new liveness state. + liveness: crate::input::AgentLiveness, + }, /// An agent's runtime profile was changed (hot-swap of the AI engine). AgentProfileChanged { /// The agent. @@ -173,6 +187,28 @@ pub enum DomainEvent { /// Whether the in-process ONNX capability (`localOnnx`) is compiled in. vector_onnx_enabled: bool, }, + /// A delegation is ready to be injected into the agent's **native terminal** + /// (ARCHITECTURE §20.2/§20.3). Published when a turn *starts* (Idle→Busy); the + /// backend stays the authority of the FIFO/busy state and **no longer writes the + /// turn into the PTY** — the frontend cell runs the write-portal handshake and + /// writes `text` + `submit_sequence` through the single PTY writer (the front). + /// Discrete, low-frequency (one per delegation). Relayed to the front as + /// `delegationReady` like every other [`DomainEvent`]. + DelegationReady { + /// The target agent whose terminal will receive the delegation. + agent_id: AgentId, + /// The mailbox ticket correlating this turn (acked back via + /// `delegation_delivered`); the requester's `ask` is woken by `idea_reply`. + ticket: TicketId, + /// The task text to inject (written without a trailing `\n` by the portal). + text: String, + /// Target profile's submit sequence (the cell applies it after the text). + /// `None` ⇒ the front applies its default (`"\r"`); never hard-coded in the + /// domain. + submit_sequence: Option, + /// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms). + submit_delay_ms: Option, + }, /// Raw PTY output (usually routed to a dedicated channel, not this bus). PtyOutput { /// The session. diff --git a/crates/domain/src/input.rs b/crates/domain/src/input.rs index 856f32d..be82041 100644 --- a/crates/domain/src/input.rs +++ b/crates/domain/src/input.rs @@ -80,6 +80,29 @@ pub enum AgentBusyState { }, } +/// Whether an agent currently shows **signs of life** during a turn (lot 2, +/// chantier readiness/heartbeat — détection « Stalled »). +/// +/// Orthogonal to [`AgentBusyState`]: an agent can be `Busy` **and** `Alive` (it is +/// working, producing deltas/heartbeats) or `Busy` **and** `Stalled` (no proof of +/// liveness for longer than its profile's `stall_after_ms`). Only meaningful while +/// `Busy`; an `Idle` agent is considered `Alive` (its turn ended cleanly). +/// +/// Published to the front (`AgentLivenessChanged`) **once per transition** so the UI +/// can badge a frozen agent without event spam. Derived from the per-agent +/// `last_seen_ms` heartbeat, never from parsing the model output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "liveness")] +pub enum AgentLiveness { + /// The agent is producing proof of liveness (deltas / tool activity / + /// heartbeats) within its `stall_after_ms` window — or is idle. + Alive, + /// No proof of liveness for longer than the profile's `stall_after_ms`: the + /// agent is presumed frozen. Advisory only — the FIFO and the turn keep running + /// (a late heartbeat flips it back to [`Self::Alive`]). + Stalled, +} + impl AgentBusyState { /// Whether a turn is currently in flight. #[must_use] @@ -97,6 +120,29 @@ impl AgentBusyState { } } +/// Per-agent submit configuration (ARCHITECTURE §20.3), resolved from the target +/// agent's profile and carried to the [`InputMediator`] at bind time so it can be +/// echoed on a [`crate::events::DomainEvent::DelegationReady`]. +/// +/// Both fields stay `Option`: the **default** (`"\r"`, ~60 ms) is applied at the +/// point of use (the frontend write-portal), never hard-coded in the domain — the +/// domain only transports the declared values. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SubmitConfig { + /// The profile's `submit_sequence` (e.g. `"\r"`). `None` ⇒ front default. + pub sequence: Option, + /// The profile's `submit_delay_ms`. `None` ⇒ front default (~60 ms). + pub delay_ms: Option, +} + +impl SubmitConfig { + /// Builds a submit config from the two optional profile fields. + #[must_use] + pub const fn new(sequence: Option, delay_ms: Option) -> Self { + Self { sequence, delay_ms } + } +} + /// The single convergence point of **all** of an agent's input (one FIFO/agent), /// with `enqueue` (Envoyer) and `preempt` (Interrompre) kept **distinct**, plus the /// busy state. @@ -108,12 +154,14 @@ pub trait InputMediator: Send + Sync { /// Envoyer = enqueue: appends `ticket` to `agent`'s FIFO and returns the /// [`PendingReply`] to await (the mailbox reply type, reused). /// - /// **Delivery** (cadrage C3 §5.2): the implementation also **writes** the turn - /// into the agent's input stream — *this* is the single, serialized write path - /// that replaces the orchestrator's former ad-hoc PTY write. The write target is - /// the [`PtyHandle`] previously registered with [`InputMediator::bind_handle`]; - /// without one, the enqueue still queues the ticket (forward, never reject) and - /// the orchestrator falls back to its own delivery. + /// **Delivery** (ARCHITECTURE §20.2/§20.3): the mediator **no longer writes the + /// turn into the PTY** (the `\n` band-aid is gone — it never submitted in raw mode + /// and produced a "double chat"). Instead, on the enqueue that **starts a turn** + /// (Idle→Busy), the adapter publishes a [`crate::events::DomainEvent::DelegationReady`] + /// carrying the task text + ticket + the target's [`SubmitConfig`]; the frontend + /// cell (sole owner of the terminal) runs the write-portal handshake and writes the + /// text + submit sequence through the single PTY writer. The mediator stays the + /// authority of the FIFO/busy state and correlation only. fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply; /// Registers (or refreshes) the live input [`PtyHandle`] of `agent` so a later @@ -134,26 +182,74 @@ pub trait InputMediator: Send + Sync { /// only returns `Idle` on the explicit signal or the per-turn timeout (fallback /// «en cas de doute → reste Busy mais la file accepte»; never a false `Idle`). /// - /// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern (a - /// mediator that does not observe the output stream). The infra adapter overrides - /// it to arm the watcher. + /// It also records the target's [`SubmitConfig`] (ARCHITECTURE §20.3) so the + /// adapter can echo `submit_sequence`/`submit_delay_ms` on the + /// [`crate::events::DomainEvent::DelegationReady`] published when a turn starts. + /// The bind is the natural carrier: both the prompt pattern and the submit config + /// are per-agent profile data the orchestrator resolves at the same time, so they + /// travel together (no extra ticket field, no second resolve). + /// + /// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern and + /// submit config (a mediator that does not observe the output stream). The infra + /// adapter overrides it to arm the watcher and stash the submit config. fn bind_handle_with_prompt( &self, agent: AgentId, handle: PtyHandle, _prompt_ready_pattern: Option, + _submit: SubmitConfig, ) { self.bind_handle(agent, handle); } - /// Whether this mediator owns the turn-delivery write for `agent` (i.e. it has a - /// bound handle **and** a PTY port). When `false`, the orchestrator must deliver - /// the turn itself. Default: `false`. + /// **Deprecated since ARCHITECTURE §20**: the mediator never writes the turn into + /// the PTY anymore — the **frontend** always delivers (via the write-portal). Kept + /// for source compatibility; it now always returns `false`. Callers should stop + /// branching on it (the orchestrator no longer falls back to its own PTY write). #[must_use] fn delivers_turn(&self, _agent: AgentId) -> bool { false } + /// Déclare qu'`agent` vient d'être **lancé à froid** : la livraison de son tout + /// premier tour doit être *gatée* sur le prompt-ready (la `DelegationReady` est + /// différée jusqu'à l'apparition du prompt du CLI), pour éviter d'écrire la tâche + /// avant que le CLI ait fini de booter (premier tour perdu sinon). + /// + /// À n'appeler **que** lorsqu'un prompt-ready watcher sera effectivement armé (le + /// profil porte un `prompt_ready_pattern` non vide). Sans watcher pour le libérer, + /// gater le premier tour le bloquerait indéfiniment ; dans ce cas l'orchestrateur ne + /// doit **pas** appeler `mark_starting` et l'`enqueue` livre la tâche immédiatement + /// (chemin chaud, fallback sûr, zéro régression). + /// + /// Default: no-op (a mediator that does not gate cold starts). + fn mark_starting(&self, _agent: AgentId) {} + + /// Signal de **readiness de démarrage** : le pont MCP de `agent` vient de se + /// connecter (son CLI est up et a chargé les outils `idea_*`). Si un premier tour + /// a été différé par [`InputMediator::mark_starting`] (démarrage à froid), c'est le + /// moment de le livrer ⇒ draine le `DelegationReady` retenu. **Contrairement à + /// `prompt_ready`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de + /// fin de tour. Idempotent : un second appel (ou après que `prompt_ready` ait déjà + /// drainé) ne trouve rien et est un no-op. En OR avec le prompt-ready : le premier + /// arrivé draine, l'autre est no-op. + /// + /// Default: no-op (médiateur qui ne gate pas les démarrages à froid). + fn release_cold_start(&self, _agent: AgentId) {} + + /// Déclare si une **cellule terminal du frontend** est montée pour `agent` + /// (`true` au montage du write-portal, `false` au démontage). C'est le frontend + /// qui possède l'écriture physique du PTY *quand une cellule existe* (cas d'un + /// agent épinglé dans le layout) ; un agent **délégué en arrière-plan n'a aucune + /// cellule**, donc personne ne consomme `DelegationReady` côté UI. Le médiateur + /// utilise cet état pour décider, à la livraison d'un tour, entre **publier + /// l'événement** (cellule présente ⇒ le front écrit) et **écrire lui-même** la + /// tâche dans le PTY (agent headless ⇒ sinon le tour est perdu). + /// + /// Default: no-op (médiateur sans notion de cellule front — tout passe par + /// l'événement, comportement historique). + fn set_front_attached(&self, _agent: AgentId, _attached: bool) {} + /// Interrompre = preempt: signals the running turn to stop (Échap/stop). This /// is **not** an enqueue and correlates **no** ticket. fn preempt(&self, agent: AgentId); @@ -161,6 +257,29 @@ pub trait InputMediator: Send + Sync { /// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances. fn mark_idle(&self, agent: AgentId); + /// Records a **proof of liveness** (« battement ») for `agent` — called on every + /// non-terminal turn event (text delta, tool activity, [`crate::ports::ReplyEvent::Heartbeat`]) + /// by the drain loop. Refreshes the per-agent `last_seen` timestamp so the stall + /// detector ([`crate::events::DomainEvent::AgentLivenessChanged`], lot 2) knows the + /// agent is still working; a battement arriving after a `Stalled` transition flips it + /// back to [`AgentLiveness::Alive`]. + /// + /// Default: no-op (a mediator that does not track liveness). The infra adapter + /// `MediatedInbox` overrides it to refresh `last_seen` and publish an + /// `AgentLivenessChanged{Stalled→Alive}` recovery on the first late battement. + fn mark_alive(&self, _agent: AgentId) {} + + /// Declares the agent's **stall threshold** (its profile's + /// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows + /// how long without a battement means "frozen" (lot 2). The orchestrator resolves it + /// from the target's profile and calls this **before** the enqueue that starts a + /// turn; the adapter stashes it and arms a fresh liveness window when the turn + /// begins. `None` ⇒ no stall detection for this agent (legacy / no `liveness` block — + /// zero regression). + /// + /// Default: no-op (a mediator that does not track liveness). + fn set_stall_threshold(&self, _agent: AgentId, _stall_after_ms: Option) {} + /// The current [`AgentBusyState`] of `agent`. fn busy_state(&self, agent: AgentId) -> AgentBusyState; } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 393672e..da35044 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -44,9 +44,12 @@ pub mod mailbox; pub mod markdown; pub mod memory; pub mod orchestrator; +pub mod permission; pub mod ports; pub mod profile; pub mod project; +pub mod readiness; +pub mod sandbox; pub mod remote; pub mod skill; pub mod template; @@ -74,7 +77,8 @@ pub use skill::{Skill, SkillRef, SkillScope}; pub use template::{AgentTemplate, TemplateVersion}; pub use profile::{ - AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, SessionStrategy, + AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy, + McpServerWiring, SessionStrategy, }; pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId}; @@ -84,7 +88,9 @@ pub use conversation::{ ConversationSession, SessionRef, WaitForGraph, }; -pub use input::{AgentBusyState, InputMediator, InputSource}; +pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource}; + +pub use readiness::{ReadinessPolicy, ReadinessSignal}; pub use conversation_log::{ ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer, @@ -113,6 +119,18 @@ pub use layout::{ pub use events::{DomainEvent, OrchestrationSource}; +pub use permission::{ + render_permission_summary, resolve as resolve_permissions, AgentPermissionOverride, Capability, + CommandMatcher, CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError, + PermissionProjection, PermissionProjector, PermissionRule, PermissionSet, Posture, + ProjectedFile, ProjectionContext, ProjectPermissions, ProjectorKey, PERMISSIONS_VERSION, +}; + +pub use sandbox::{ + compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError, + SandboxKind, SandboxPlan, SandboxStatus, +}; + pub use orchestrator::{ OrchestratorCommand, OrchestratorError, OrchestratorRequest, OrchestratorVisibility, }; @@ -122,7 +140,8 @@ pub use ports::{ EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, - MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PreparedContext, - ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, - RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore, + MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore, + PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, + PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, + TemplateStore, }; diff --git a/crates/domain/src/mailbox.rs b/crates/domain/src/mailbox.rs index 5e924b3..4eeb70a 100644 --- a/crates/domain/src/mailbox.rs +++ b/crates/domain/src/mailbox.rs @@ -40,16 +40,7 @@ use crate::input::InputSource; /// correlation is positional (head of the FIFO), the id only lets the caller name /// the exact ticket it wants retired on timeout ([`AgentMailbox::cancel_head`]). #[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - PartialOrd, - Ord, - serde::Serialize, - serde::Deserialize, + Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, )] #[serde(transparent)] pub struct TicketId(pub uuid::Uuid); @@ -189,9 +180,7 @@ pub struct PendingReply { impl PendingReply { /// Wraps a reply future built by the adapter (e.g. over a one-shot receiver). #[must_use] - pub fn new( - inner: Pin> + Send>>, - ) -> Self { + pub fn new(inner: Pin> + Send>>) -> Self { Self { inner } } } @@ -286,10 +275,7 @@ mod tests { assert_eq!(t.task, "do X"); // Back-compat default: human source, nil conversation. assert_eq!(t.source, InputSource::Human); - assert_eq!( - t.conversation, - ConversationId::from_uuid(uuid::Uuid::nil()) - ); + assert_eq!(t.conversation, ConversationId::from_uuid(uuid::Uuid::nil())); } #[test] @@ -322,9 +308,6 @@ mod tests { #[test] fn mailbox_errors_are_distinct_and_typed() { let a = AgentId::from_uuid(uuid::Uuid::from_u128(1)); - assert_ne!( - MailboxError::NoPendingRequest(a), - MailboxError::Cancelled - ); + assert_ne!(MailboxError::NoPendingRequest(a), MailboxError::Cancelled); } } diff --git a/crates/domain/src/permission.rs b/crates/domain/src/permission.rs new file mode 100644 index 0000000..38e3d46 --- /dev/null +++ b/crates/domain/src/permission.rs @@ -0,0 +1,1482 @@ +//! Agent permission model (cadrage « permissions des agents », lot LP0). +//! +//! A **pure, declarative** model of what an agent may do on its project root, +//! plus the single pure function that **resolves** a project-level and an +//! agent-level [`PermissionSet`] into the normalised [`EffectivePermissions`] +//! consumed by the (later) projectors that translate it to a concrete CLI +//! permission config (Claude `settings.json`, Codex sandbox…). +//! +//! This module is **pure** (ARCHITECTURE dependency rule): no `tokio`, no +//! `std::fs`, no `std::process`. It owns the value objects, their validating +//! constructors and invariants, and the [`resolve`] function. The store, the +//! OS-sandbox application and the per-CLI projection are **out of scope** here +//! (lots LP1/LP2/LP3) and live in `application`/`infrastructure`. +//! +//! ## The product invariant of [`resolve`] +//! +//! When **nothing** is posed (neither a project nor an agent set), [`resolve`] +//! returns [`None`]. A `None` result means «we project nothing» — the AI engine +//! keeps its own native prompting at run time. Any `Some` result means IdeA has +//! an explicit policy to project. This distinction is load-bearing: it is what +//! keeps an unconfigured project on the CLI's native behaviour instead of +//! silently locking it down. +//! +//! ## Deny-wins +//! +//! For a given *capability + concrete target*, a matching `Deny` (whether it +//! comes from the project set or the agent set, at the rule level or a command +//! level) **always** wins over any `Allow`. It is never overridable by a more +//! specific allow. The agent set may only **tighten** the inherited policy +//! (add denies, raise the fallback posture); it cannot loosen a project deny. + +use serde::{Deserialize, Serialize}; + +use crate::ids::AgentId; +use crate::validation::relative_safe; + +/// Current schema version for `.ideai/permissions.json`. +pub const PERMISSIONS_VERSION: u32 = 1; + +/// What an agent may attempt. Closed set. +/// +/// [`Capability::ExecuteBash`] is the **only** capability that carries +/// [`CommandRule`]s; the three file capabilities ([`Capability::Read`], +/// [`Capability::Write`], [`Capability::Delete`]) carry a [`PathScope`] instead. +/// This split is enforced by [`PermissionRule::new`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Capability { + /// Read a file under the project root. + Read, + /// Write (create/modify) a file under the project root. + Write, + /// Delete a file under the project root. + Delete, + /// Run a shell command. + ExecuteBash, +} + +impl Capability { + /// Whether this capability is one of the three **file** capabilities + /// (scoped by paths, never by commands). + #[must_use] + pub const fn is_file(self) -> bool { + matches!(self, Self::Read | Self::Write | Self::Delete) + } + + /// Whether this capability is [`Capability::ExecuteBash`] (scoped by + /// commands, never by paths). + #[must_use] + pub const fn is_bash(self) -> bool { + matches!(self, Self::ExecuteBash) + } +} + +/// The verdict a [`PermissionRule`] or [`CommandRule`] carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Effect { + /// Grant the capability for the matched target. + Allow, + /// Forbid the capability for the matched target. Wins over any `Allow`. + Deny, +} + +/// The default stance applied when **no** rule yields a verdict for a target. +/// +/// Unlike [`Effect`], a posture has a third, neutral value [`Posture::Ask`]: +/// «no decision posed, let the engine prompt». Postures are ordered by +/// restrictiveness (`Allow < Ask < Deny`) so an agent set can only **tighten** +/// an inherited fallback (cf. [`Posture::tighten`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Posture { + /// Defer to the engine's native prompting. + Ask, + /// Allow by default. + Allow, + /// Deny by default. + Deny, +} + +impl Posture { + /// Restrictiveness rank used by [`Posture::tighten`]: `Allow < Ask < Deny`. + #[must_use] + const fn restrictiveness(self) -> u8 { + match self { + Self::Allow => 0, + Self::Ask => 1, + Self::Deny => 2, + } + } + + /// Returns the **more restrictive** of `self` and `other`. + /// + /// Used to merge the project and agent fallbacks: the agent may resserrer + /// (raise restrictiveness) but never loosen the project's stance. + #[must_use] + pub fn tighten(self, other: Self) -> Self { + if other.restrictiveness() >= self.restrictiveness() { + other + } else { + self + } + } +} + +/// A single glob pattern, validated **non-empty and compilable**. +/// +/// The supported syntax (pure, no external glob crate) is the usual shell glob: +/// `*` (any run of non-`/` chars), `**` (any run including `/`), `?` (one +/// non-`/` char), `[...]` character classes (with ranges `a-z` and negation via +/// a leading `!`/`^`), everything else literal. Compilability here means the +/// pattern is non-empty and every `[` opens a terminated character class. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Glob(String); + +impl Glob { + /// Builds a validated glob. + /// + /// # Errors + /// - [`PermissionError::EmptyGlob`] if `pattern` is empty. + /// - [`PermissionError::InvalidGlob`] if a character class is unterminated. + pub fn new(pattern: impl Into) -> Result { + let pattern = pattern.into(); + if pattern.is_empty() { + return Err(PermissionError::EmptyGlob); + } + validate_glob(&pattern).map_err(|reason| PermissionError::InvalidGlob { + pattern: pattern.clone(), + reason, + })?; + Ok(Self(pattern)) + } + + /// The raw pattern. + #[must_use] + pub fn pattern(&self) -> &str { + &self.0 + } + + /// Whether this glob matches `text` in full. + #[must_use] + pub fn matches(&self, text: &str) -> bool { + let pat: Vec = self.0.chars().collect(); + let txt: Vec = text.chars().collect(); + glob_match(&pat, &txt) + } +} + +/// A set of globs, **all relative to the project root**. +/// +/// Every glob must be relative, must not escape the root via `..`, and must not +/// be an absolute path — the same guard [`crate::profile::ContextInjection`] +/// applies to its convention-file targets. An empty [`PathScope`] matches +/// nothing. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PathScope { + globs: Vec, +} + +impl PathScope { + /// Builds a validated path scope. + /// + /// # Errors + /// - any error from [`Glob::new`] (empty / uncompilable pattern); + /// - [`PermissionError::PathNotRelativeSafe`] if a pattern is absolute or + /// contains a `..` traversal component. + pub fn new(patterns: impl IntoIterator) -> Result { + let mut globs = Vec::new(); + for pattern in patterns { + // Path-safety is a PathScope concern (commands are not paths), so we + // check it here rather than inside `Glob`. + relative_safe(&pattern).map_err(|_| PermissionError::PathNotRelativeSafe { + pattern: pattern.clone(), + })?; + globs.push(Glob::new(pattern)?); + } + Ok(Self { globs }) + } + + /// An empty scope (matches nothing). + #[must_use] + pub fn empty() -> Self { + Self { globs: Vec::new() } + } + + /// The globs of this scope. + #[must_use] + pub fn globs(&self) -> &[Glob] { + &self.globs + } + + /// Whether the scope is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.globs.is_empty() + } + + /// Whether any glob matches `path`. + #[must_use] + pub fn matches(&self, path: &str) -> bool { + self.globs.iter().any(|g| g.matches(path)) + } +} + +/// How a [`CommandRule`] matches a candidate shell command. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] +pub enum CommandMatcher { + /// Matches the command verbatim. + Exact(String), + /// Matches when the command starts with this prefix. + Prefix(String), + /// Matches via a [`Glob`]. + Glob(Glob), +} + +impl CommandMatcher { + /// Builds an [`CommandMatcher::Exact`] matcher. + /// + /// # Errors + /// [`PermissionError::EmptyCommandMatcher`] if `value` is empty. + pub fn exact(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(PermissionError::EmptyCommandMatcher); + } + Ok(Self::Exact(value)) + } + + /// Builds a [`CommandMatcher::Prefix`] matcher. + /// + /// # Errors + /// [`PermissionError::EmptyCommandMatcher`] if `value` is empty. + pub fn prefix(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(PermissionError::EmptyCommandMatcher); + } + Ok(Self::Prefix(value)) + } + + /// Builds a [`CommandMatcher::Glob`] matcher. + /// + /// # Errors + /// any error from [`Glob::new`]. + pub fn glob(pattern: impl Into) -> Result { + Ok(Self::Glob(Glob::new(pattern)?)) + } + + /// Whether this matcher matches `command`. + #[must_use] + pub fn matches(&self, command: &str) -> bool { + match self { + Self::Exact(s) => command == s, + Self::Prefix(s) => command.starts_with(s.as_str()), + Self::Glob(g) => g.matches(command), + } + } +} + +/// A per-command verdict carried by a bash [`PermissionRule`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandRule { + /// How the command is matched. + pub matcher: CommandMatcher, + /// The verdict applied to a matched command. + pub effect: Effect, +} + +impl CommandRule { + /// Builds a command rule. + #[must_use] + pub fn new(matcher: CommandMatcher, effect: Effect) -> Self { + Self { matcher, effect } + } +} + +/// One permission rule: a capability, a rule-level [`Effect`], a [`PathScope`] +/// (file capabilities) **or** a list of [`CommandRule`]s ([`Capability::ExecuteBash`]). +/// +/// Built through [`PermissionRule::file`] / [`PermissionRule::bash`] which +/// enforce the structural invariants. The fields are read-only accessors. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionRule { + capability: Capability, + effect: Effect, + #[serde(default)] + paths: PathScope, + #[serde(default)] + commands: Vec, +} + +impl PermissionRule { + /// Builds a **file** rule (`Read`/`Write`/`Delete`) scoped by `paths`. + /// + /// # Errors + /// - [`PermissionError::NotAFileCapability`] if `capability` is + /// [`Capability::ExecuteBash`]. + pub fn file( + capability: Capability, + effect: Effect, + paths: PathScope, + ) -> Result { + if !capability.is_file() { + return Err(PermissionError::NotAFileCapability { capability }); + } + Ok(Self { + capability, + effect, + paths, + commands: Vec::new(), + }) + } + + /// Builds a **bash** rule ([`Capability::ExecuteBash`]). + /// + /// A rule with an **empty** `commands` list applies its `effect` as a + /// blanket verdict to every command. A rule with a **non-empty** `commands` + /// list contributes a verdict only for matched commands (the rule-level + /// `effect` is not consulted for unmatched commands — they fall through to + /// the resolved fallback). + #[must_use] + pub fn bash(effect: Effect, commands: Vec) -> Self { + Self { + capability: Capability::ExecuteBash, + effect, + paths: PathScope::empty(), + commands, + } + } + + /// Generic validating constructor used by deserialised input. + /// + /// # Errors + /// - [`PermissionError::BashRuleHasPaths`] if a bash rule carries a + /// non-empty [`PathScope`]; + /// - [`PermissionError::FileRuleHasCommands`] if a file rule carries + /// commands. + pub fn new( + capability: Capability, + effect: Effect, + paths: PathScope, + commands: Vec, + ) -> Result { + if capability.is_bash() { + if !paths.is_empty() { + return Err(PermissionError::BashRuleHasPaths); + } + } else if !commands.is_empty() { + return Err(PermissionError::FileRuleHasCommands); + } + Ok(Self { + capability, + effect, + paths, + commands, + }) + } + + /// The capability this rule governs. + #[must_use] + pub fn capability(&self) -> Capability { + self.capability + } + + /// The rule-level effect. + #[must_use] + pub fn effect(&self) -> Effect { + self.effect + } + + /// The path scope (empty for bash rules). + #[must_use] + pub fn paths(&self) -> &PathScope { + &self.paths + } + + /// The command rules (empty for file rules). + #[must_use] + pub fn commands(&self) -> &[CommandRule] { + &self.commands + } +} + +/// A bundle of [`PermissionRule`]s plus the default [`Posture`] applied when no +/// rule yields a verdict. +/// +/// A set may legally contain **both** an `Allow` and a `Deny` rule for the same +/// capability (over different scopes): deny-wins is resolved per target, not per +/// capability. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionSet { + rules: Vec, + fallback: Posture, +} + +impl PermissionSet { + /// Builds a permission set from already-validated rules. + #[must_use] + pub fn new(rules: Vec, fallback: Posture) -> Self { + Self { rules, fallback } + } + + /// The rules of this set. + #[must_use] + pub fn rules(&self) -> &[PermissionRule] { + &self.rules + } + + /// The fallback posture of this set. + #[must_use] + pub fn fallback(&self) -> Posture { + self.fallback + } +} + +/// One agent-specific permission override stored in `.ideai/permissions.json`. +/// +/// The agent policy is intentionally separate from `agents.json`: permissions are +/// a project security concern, not part of the agent's identity/template link. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentPermissionOverride { + /// The agent whose default project permissions are tightened. + pub agent_id: AgentId, + /// The agent-specific policy. + pub permissions: PermissionSet, +} + +impl AgentPermissionOverride { + /// Builds an override for `agent_id`. + #[must_use] + pub const fn new(agent_id: AgentId, permissions: PermissionSet) -> Self { + Self { + agent_id, + permissions, + } + } +} + +/// Persisted project permission document (`.ideai/permissions.json`). +/// +/// `project_defaults == None` means the project does not define an IdeA-level +/// policy; engines keep their legacy/native behavior unless an agent override is +/// present. Agent entries are sparse: only agents with a custom policy are listed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPermissions { + /// Schema version. + pub version: u32, + /// Project-level defaults inherited by every agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_defaults: Option, + /// Sparse agent-specific overrides. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub agents: Vec, +} + +impl Default for ProjectPermissions { + fn default() -> Self { + Self { + version: PERMISSIONS_VERSION, + project_defaults: None, + agents: Vec::new(), + } + } +} + +impl ProjectPermissions { + /// Builds a document and normalises duplicate agent entries by keeping the + /// last value for a given agent id. + #[must_use] + pub fn new( + project_defaults: Option, + agents: Vec, + ) -> Self { + let mut doc = Self { + version: PERMISSIONS_VERSION, + project_defaults, + agents: Vec::new(), + }; + for entry in agents { + doc.set_agent_permissions(entry.agent_id, Some(entry.permissions)); + } + doc + } + + /// Returns the agent-specific override, if any. + #[must_use] + pub fn agent_permissions(&self, agent_id: AgentId) -> Option<&PermissionSet> { + self.agents + .iter() + .find(|entry| entry.agent_id == agent_id) + .map(|entry| &entry.permissions) + } + + /// Replaces the project defaults. + pub fn set_project_defaults(&mut self, defaults: Option) { + self.project_defaults = defaults; + } + + /// Replaces or removes one agent override. + pub fn set_agent_permissions(&mut self, agent_id: AgentId, permissions: Option) { + self.agents.retain(|entry| entry.agent_id != agent_id); + if let Some(permissions) = permissions { + self.agents + .push(AgentPermissionOverride::new(agent_id, permissions)); + } + } + + /// Resolves effective permissions for `agent_id`. + #[must_use] + pub fn resolve_for(&self, agent_id: AgentId) -> Option { + resolve( + self.project_defaults.as_ref(), + self.agent_permissions(agent_id), + ) + } +} + +/// The normalised, flattened output of [`resolve`] — the **sole input** of the +/// future per-CLI projectors. +/// +/// It holds the union of the project and agent rules (project first, then agent +/// superposed) plus the resolved fallback. Deny-wins is applied at decision time +/// by [`EffectivePermissions::decide_file`] / [`EffectivePermissions::decide_bash`] +/// so the projectors can either emit the raw allow/deny lists or ask for a +/// concrete verdict. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectivePermissions { + rules: Vec, + fallback: Posture, +} + +impl EffectivePermissions { + /// All flattened rules (project rules followed by agent rules). + #[must_use] + pub fn rules(&self) -> &[PermissionRule] { + &self.rules + } + + /// The resolved fallback posture. + #[must_use] + pub fn fallback(&self) -> Posture { + self.fallback + } + + /// Resolves the verdict for a **file** capability against `path`. + /// + /// Deny-wins: any matching `Deny` (project or agent) yields + /// [`Posture::Deny`]; otherwise any matching `Allow` yields + /// [`Posture::Allow`]; otherwise the [`EffectivePermissions::fallback`]. + /// + /// A bash capability passed here always falls through to the fallback (file + /// rules and bash rules never cross). + #[must_use] + pub fn decide_file(&self, capability: Capability, path: &str) -> Posture { + let mut allowed = false; + for rule in &self.rules { + if rule.capability != capability || !rule.capability.is_file() { + continue; + } + if rule.paths.matches(path) { + match rule.effect { + Effect::Deny => return Posture::Deny, + Effect::Allow => allowed = true, + } + } + } + if allowed { + Posture::Allow + } else { + self.fallback + } + } + + /// Resolves the verdict for running `command`. + /// + /// Each bash rule contributes a verdict: a rule with a non-empty `commands` + /// list contributes the effect of every matching [`CommandRule`]; a rule + /// with an empty list contributes its rule-level effect for every command. + /// Deny-wins across every contribution; absent any contribution, the + /// fallback applies. + #[must_use] + pub fn decide_bash(&self, command: &str) -> Posture { + let mut allowed = false; + for rule in &self.rules { + if !rule.capability.is_bash() { + continue; + } + if rule.commands.is_empty() { + // Blanket bash rule: applies to every command. + match rule.effect { + Effect::Deny => return Posture::Deny, + Effect::Allow => allowed = true, + } + } else { + for cmd in &rule.commands { + if cmd.matcher.matches(command) { + match cmd.effect { + Effect::Deny => return Posture::Deny, + Effect::Allow => allowed = true, + } + } + } + } + } + if allowed { + Posture::Allow + } else { + self.fallback + } + } +} + +/// Resolves a project-level and an agent-level [`PermissionSet`] into the +/// normalised [`EffectivePermissions`]. +/// +/// 1. **`project == None && agent == None ⇒ None`** — nothing posed, nothing +/// projected; the engine keeps its native prompting (the product invariant). +/// 2. Otherwise the project rules are taken as the inherited base and the agent +/// rules are superposed on top (`project` first, then `agent`). +/// 3. The fallback is the **more restrictive** of the two present fallbacks +/// ([`Posture::tighten`]): the agent may resserrer, never loosen. +/// +/// Deny-wins itself is enforced by the decision methods of the returned +/// [`EffectivePermissions`], over the union of both sets' rules — a project deny +/// is therefore never overridable by an agent allow. +/// +/// The function is **total** and **deterministic**. +#[must_use] +pub fn resolve( + project: Option<&PermissionSet>, + agent: Option<&PermissionSet>, +) -> Option { + if project.is_none() && agent.is_none() { + return None; + } + + let mut rules = Vec::new(); + if let Some(p) = project { + rules.extend(p.rules.iter().cloned()); + } + if let Some(a) = agent { + rules.extend(a.rules.iter().cloned()); + } + + let fallback = match (project, agent) { + (Some(p), Some(a)) => p.fallback.tighten(a.fallback), + (Some(p), None) => p.fallback, + (None, Some(a)) => a.fallback, + // Unreachable: the both-`None` case returned above. + (None, None) => Posture::Ask, + }; + + Some(EffectivePermissions { rules, fallback }) +} + +/// Renders a human-readable Markdown **summary** of the resolved policy, suitable +/// for injection into an agent's context (lot LP4-0). +/// +/// `eff == None ⇒ None` (mirrors [`resolve`]'s product invariant: nothing posed ⇒ +/// nothing to summarise). A `Some` result is a self-contained Markdown block. +/// +/// The summary makes the **enforcement boundary** explicit: file rules are locked +/// by the OS sandbox **when supported** (Landlock), whereas command rules +/// ([`Capability::ExecuteBash`]) remain **advisory** — honoured only by the CLI's +/// own prompting, never by the OS. This honesty is load-bearing: an agent must not +/// believe a command deny is airtight. +#[must_use] +pub fn render_permission_summary(eff: Option<&EffectivePermissions>) -> Option { + let eff = eff?; + + let mut file_lines: Vec = Vec::new(); + let mut bash_lines: Vec = Vec::new(); + for rule in eff.rules() { + if rule.capability().is_file() { + let verb = match rule.effect() { + Effect::Allow => "Allow", + Effect::Deny => "Deny", + }; + let cap = match rule.capability() { + Capability::Read => "read", + Capability::Write => "write", + Capability::Delete => "delete", + Capability::ExecuteBash => "run", // unreachable (is_file filtered) + }; + let scope = if rule.paths().is_empty() { + "(nothing)".to_string() + } else { + rule.paths() + .globs() + .iter() + .map(|g| format!("`{}`", g.pattern())) + .collect::>() + .join(", ") + }; + file_lines.push(format!("- {verb} {cap}: {scope}")); + } else { + // Bash rule: blanket (empty commands) or per-command. + if rule.commands().is_empty() { + let verb = match rule.effect() { + Effect::Allow => "Allow", + Effect::Deny => "Deny", + }; + bash_lines.push(format!("- {verb}: every command")); + } else { + for cmd in rule.commands() { + let verb = match cmd.effect { + Effect::Allow => "Allow", + Effect::Deny => "Deny", + }; + let shown = match &cmd.matcher { + CommandMatcher::Exact(s) => format!("`{s}` (exact)"), + CommandMatcher::Prefix(s) => format!("`{s}*` (prefix)"), + CommandMatcher::Glob(g) => format!("`{}` (glob)", g.pattern()), + }; + bash_lines.push(format!("- {verb}: {shown}")); + } + } + } + } + + let mut out = String::new(); + out.push_str("## Permissions (IdeA)\n\n"); + out.push_str(&format!( + "**Default posture:** {}\n\n", + match eff.fallback() { + Posture::Allow => "Allow", + Posture::Ask => "Ask", + Posture::Deny => "Deny", + } + )); + + out.push_str("### Files — OS-enforced when the sandbox is supported (Landlock)\n"); + if file_lines.is_empty() { + out.push_str("- (no file rule; only the default posture applies)\n"); + } else { + for line in &file_lines { + out.push_str(line); + out.push('\n'); + } + } + out.push('\n'); + + out.push_str("### Commands — advisory, NOT OS-locked\n"); + out.push_str( + "These rules are honoured only by the AI CLI's own prompting, not by the OS \ +sandbox: Landlock locks file access only, so command execution (`ExecuteBash`) \ +cannot be sandboxed and stays advisory.\n", + ); + if bash_lines.is_empty() { + out.push_str("- (no command rule; only the default posture applies)\n"); + } else { + for line in &bash_lines { + out.push_str(line); + out.push('\n'); + } + } + + Some(out) +} + +// --------------------------------------------------------------------------- +// Per-CLI projection (lot LP3) — the PURE contract that translates the resolved +// `EffectivePermissions` into a concrete, file/args/env-level *plan* for one CLI. +// This module owns only the contract (a value + a trait); the concrete Claude / +// Codex projectors and the launch-time application live in `infrastructure`. +// --------------------------------------------------------------------------- + +/// Stable key identifying **which** per-CLI projector a profile uses. +/// +/// Closed set, mirroring [`crate::profile::StructuredAdapter`]: a projector is a +/// declarative choice (data, not code), and each variant has exactly one concrete +/// implementation in `infrastructure`. A closed enum (rather than a `String` +/// newtype) is the idiomatic shape here — it matches the other engine-family keys +/// of the domain, makes the match in the launch path exhaustive, and cannot carry +/// an unknown value at run time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ProjectorKey { + /// Projects to Claude Code's `settings.json` permission shape. + Claude, + /// Projects to Codex's sandbox / `config.toml` permission shape. + Codex, +} + +/// Immutable inputs a [`PermissionProjector`] may interpolate into the paths it +/// emits. Borrowed (no ownership): a projection is computed and consumed at the +/// launch site, never stored. +pub struct ProjectionContext<'a> { + /// Absolute project root. + pub project_root: &'a str, + /// Absolute isolated run dir of the agent (`.ideai/run//`). + pub run_dir: &'a str, +} + +/// One file a projector wants materialised at launch, tagged by **ownership**. +/// +/// Ownership decides the launch/swap lifecycle: a [`Self::Replace`] file is 100 % +/// IdeA's (clobbered at launch, removed when the agent swaps away from this +/// projector), whereas a [`Self::MergeToml`] file is co-owned with the CLI (only +/// the managed tables/keys are merged in, and it is **never** deleted on swap). +pub enum ProjectedFile { + /// File fully owned by IdeA → clobber at launch, delete on swap-away. + Replace { + /// Run-dir-relative path of the file. + rel_path: String, + /// Full contents to write. + contents: String, + }, + /// File co-owned with the CLI (e.g. Codex `config.toml`) → merge only the + /// managed tables/keys, never deleted on swap. + MergeToml { + /// Run-dir-relative path of the file. + rel_path: String, + /// TOML tables IdeA manages (everything else is preserved). + managed_tables: Vec, + /// Top-level keys IdeA manages (everything else is preserved). + managed_keys: Vec, + /// The managed fragment to merge in. + contents: String, + }, +} + +/// The concrete, CLI-specific **plan** a [`PermissionProjector`] produces from the +/// resolved [`EffectivePermissions`]: the files to materialise, plus any launch +/// args and environment variables. It is a *value* (a plan), not an action — the +/// infrastructure applies it. +pub struct PermissionProjection { + /// Files to materialise (ownership-tagged, cf. [`ProjectedFile`]). + pub files: Vec, + /// Extra launch arguments the CLI needs to honour the policy. + pub args: Vec, + /// Extra environment variables (`name`, `value`). + pub env: Vec<(String, String)>, +} + +impl PermissionProjection { + /// The empty projection: nothing materialised, no args, no env. + /// + /// This is the **invariant value** a projector returns when `eff == None`: + /// nothing is posed ⇒ we project nothing and the CLI keeps its native + /// prompting (mirrors [`resolve`]'s product invariant). + #[must_use] + pub fn empty() -> Self { + Self { + files: Vec::new(), + args: Vec::new(), + env: Vec::new(), + } + } +} + +/// Translates the resolved [`EffectivePermissions`] into a CLI-specific +/// [`PermissionProjection`]. +/// +/// **Pure**: an implementation only *computes* a plan; it writes nothing and +/// touches no I/O. The launch path (infrastructure) is what materialises the +/// returned files / args / env. Concrete impls (Claude, Codex) live in +/// `infrastructure`, keyed by [`ProjectorKey`]. +pub trait PermissionProjector: Send + Sync { + /// The key this projector answers to. + fn key(&self) -> ProjectorKey; + + /// Computes the projection. + /// + /// `eff == None` ⇒ the **empty** projection ([`PermissionProjection::empty`]): + /// we keep the CLI's native prompting (the product invariant of [`resolve`]). + fn project(&self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext) + -> PermissionProjection; + + /// Run-dir-relative paths of the [`ProjectedFile::Replace`] files this + /// projector owns, so the swap path can clean them up when an agent moves + /// away from this projector. + fn owned_replace_paths(&self) -> Vec; +} + +/// Errors raised by the validating constructors of this module. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum PermissionError { + /// A glob pattern was empty. + #[error("glob pattern must not be empty")] + EmptyGlob, + /// A glob pattern was not compilable. + #[error("glob `{pattern}` is not compilable: {reason}")] + InvalidGlob { + /// The offending pattern. + pattern: String, + /// Why it failed to compile. + reason: String, + }, + /// An `Exact`/`Prefix` command matcher was empty. + #[error("command matcher must not be empty")] + EmptyCommandMatcher, + /// A path-scope glob was absolute or contained a `..` traversal component. + #[error("path glob `{pattern}` must be relative and must not contain `..`")] + PathNotRelativeSafe { + /// The offending pattern. + pattern: String, + }, + /// A bash rule carried a non-empty path scope. + #[error("an ExecuteBash rule must not carry a path scope")] + BashRuleHasPaths, + /// A file rule carried command rules. + #[error("a Read/Write/Delete rule must not carry commands")] + FileRuleHasCommands, + /// [`PermissionRule::file`] was given [`Capability::ExecuteBash`]. + #[error("expected a file capability (Read/Write/Delete), got {capability:?}")] + NotAFileCapability { + /// The offending capability. + capability: Capability, + }, +} + +// --------------------------------------------------------------------------- +// Pure glob engine (no external crate; supports `*`, `**`, `?`, `[...]`). +// --------------------------------------------------------------------------- + +/// Validates that every `[` in `pattern` opens a terminated character class. +fn validate_glob(pattern: &str) -> Result<(), String> { + let chars: Vec = pattern.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '[' { + i = class_end(&chars, i) + .ok_or_else(|| "unterminated character class `[`".to_string())?; + } else { + i += 1; + } + } + Ok(()) +} + +/// Given `chars[start] == '['`, returns the index **after** the closing `]`, or +/// `None` if the class is unterminated. A `]` immediately after the (optional) +/// negation marker is treated as a literal member (POSIX rule). +fn class_end(chars: &[char], start: usize) -> Option { + let mut i = start + 1; + if i < chars.len() && (chars[i] == '!' || chars[i] == '^') { + i += 1; + } + // A leading ']' is a literal member, not the terminator. + if i < chars.len() && chars[i] == ']' { + i += 1; + } + while i < chars.len() { + if chars[i] == ']' { + return Some(i + 1); + } + i += 1; + } + None +} + +/// Whether `pat` matches the whole of `text`. +fn glob_match(pat: &[char], text: &[char]) -> bool { + if pat.is_empty() { + return text.is_empty(); + } + match pat[0] { + '*' => { + if pat.get(1) == Some(&'*') { + // `**` — match any run of chars, including `/`. + let rest = &pat[2..]; + (0..=text.len()).any(|i| glob_match(rest, &text[i..])) + } else { + // `*` — match any run of non-`/` chars. + let rest = &pat[1..]; + let mut i = 0; + loop { + if glob_match(rest, &text[i..]) { + return true; + } + if i >= text.len() || text[i] == '/' { + return false; + } + i += 1; + } + } + } + '?' => match text.first() { + Some(&c) if c != '/' => glob_match(&pat[1..], &text[1..]), + _ => false, + }, + '[' => { + if let Some(end) = class_end(pat, 0) { + match text.first() { + Some(&c) if c != '/' && class_contains(&pat[..end], c) => { + glob_match(&pat[end..], &text[1..]) + } + _ => false, + } + } else { + // Not a valid class (should not happen post-validation): literal. + matches_literal(pat, text, '[') + } + } + c => matches_literal(pat, text, c), + } +} + +/// Matches a single literal char `c` at the head of `pat` against `text`. +fn matches_literal(pat: &[char], text: &[char], c: char) -> bool { + match text.first() { + Some(&t) if t == c => glob_match(&pat[1..], &text[1..]), + _ => false, + } +} + +/// Whether character `c` belongs to the class `class` (`class[0] == '['`, +/// `class` ends just after the closing `]`). +fn class_contains(class: &[char], c: char) -> bool { + let mut i = 1; + let mut negated = false; + if class.get(i) == Some(&'!') || class.get(i) == Some(&'^') { + negated = true; + i += 1; + } + let end = class.len() - 1; // index of the closing ']' + let mut found = false; + let mut first = true; + while i < end { + // A literal ']' is only possible as the first member. + if class[i] == ']' && !first { + break; + } + first = false; + if i + 2 < end && class[i + 1] == '-' && class[i + 2] != ']' { + let (lo, hi) = (class[i], class[i + 2]); + if lo <= c && c <= hi { + found = true; + } + i += 3; + } else { + if class[i] == c { + found = true; + } + i += 1; + } + } + found ^ negated +} + +#[cfg(test)] +mod tests { + use super::*; + + fn glob(p: &str) -> Glob { + Glob::new(p).unwrap() + } + + fn path_scope(patterns: &[&str]) -> PathScope { + PathScope::new(patterns.iter().map(|s| s.to_string())).unwrap() + } + + // ---- VO construction & invariants ----------------------------------- + + #[test] + fn glob_rejects_empty_and_unterminated_class() { + assert_eq!(Glob::new(""), Err(PermissionError::EmptyGlob)); + assert!(matches!( + Glob::new("src/[abc"), + Err(PermissionError::InvalidGlob { .. }) + )); + assert!(Glob::new("src/[abc]/*.rs").is_ok()); + } + + #[test] + fn path_scope_rejects_absolute_and_traversal() { + assert!(matches!( + PathScope::new(["/etc/passwd".to_string()]), + Err(PermissionError::PathNotRelativeSafe { .. }) + )); + assert!(matches!( + PathScope::new(["../secret".to_string()]), + Err(PermissionError::PathNotRelativeSafe { .. }) + )); + assert!(PathScope::new(["src/**/*.rs".to_string()]).is_ok()); + } + + #[test] + fn file_rule_rejects_bash_capability() { + assert!(matches!( + PermissionRule::file(Capability::ExecuteBash, Effect::Allow, PathScope::empty()), + Err(PermissionError::NotAFileCapability { .. }) + )); + assert!( + PermissionRule::file(Capability::Read, Effect::Allow, path_scope(&["src/**"])).is_ok() + ); + } + + #[test] + fn new_enforces_bash_paths_and_file_commands_invariants() { + // Bash rule with paths => error. + assert_eq!( + PermissionRule::new( + Capability::ExecuteBash, + Effect::Allow, + path_scope(&["src/**"]), + vec![], + ), + Err(PermissionError::BashRuleHasPaths) + ); + // File rule with commands => error. + let cmd = CommandRule::new(CommandMatcher::exact("ls").unwrap(), Effect::Allow); + assert_eq!( + PermissionRule::new( + Capability::Read, + Effect::Allow, + PathScope::empty(), + vec![cmd], + ), + Err(PermissionError::FileRuleHasCommands) + ); + } + + #[test] + fn empty_command_matchers_are_rejected() { + assert_eq!( + CommandMatcher::exact(""), + Err(PermissionError::EmptyCommandMatcher) + ); + assert_eq!( + CommandMatcher::prefix(""), + Err(PermissionError::EmptyCommandMatcher) + ); + } + + // ---- glob matching --------------------------------------------------- + + #[test] + fn star_does_not_cross_slash_but_doublestar_does() { + assert!(glob("src/*.rs").matches("src/main.rs")); + assert!(!glob("src/*.rs").matches("src/a/main.rs")); + assert!(glob("src/**/*.rs").matches("src/a/b/main.rs")); + assert!(glob("**").matches("any/deep/path")); + assert!(glob("*.rs").matches("main.rs")); + } + + #[test] + fn question_and_class_match_single_char() { + assert!(glob("?.rs").matches("a.rs")); + assert!(!glob("?.rs").matches("ab.rs")); + assert!(glob("[abc].rs").matches("b.rs")); + assert!(!glob("[abc].rs").matches("d.rs")); + assert!(glob("[a-z].rs").matches("q.rs")); + assert!(glob("[!a-z].rs").matches("Q.rs")); + assert!(!glob("[!a-z].rs").matches("q.rs")); + } + + // ---- command matchers ------------------------------------------------ + + #[test] + fn command_matchers_behave() { + assert!(CommandMatcher::exact("git status") + .unwrap() + .matches("git status")); + assert!(!CommandMatcher::exact("git").unwrap().matches("git status")); + assert!(CommandMatcher::prefix("git ") + .unwrap() + .matches("git status")); + assert!(CommandMatcher::glob("git *").unwrap().matches("git status")); + assert!(!CommandMatcher::glob("git *").unwrap().matches("npm test")); + } + + // ---- resolve: product invariant ------------------------------------- + + #[test] + fn resolve_none_when_nothing_posed() { + assert!(resolve(None, None).is_none()); + } + + #[test] + fn resolve_some_when_anything_posed() { + let set = PermissionSet::new(vec![], Posture::Ask); + assert!(resolve(Some(&set), None).is_some()); + assert!(resolve(None, Some(&set)).is_some()); + } + + // ---- resolve: merge + deny-wins ------------------------------------- + + #[test] + fn merge_superposes_project_then_agent() { + let project = PermissionSet::new( + vec![ + PermissionRule::file(Capability::Read, Effect::Allow, path_scope(&["src/**"])) + .unwrap(), + ], + Posture::Ask, + ); + let agent = PermissionSet::new( + vec![ + PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["out/**"])) + .unwrap(), + ], + Posture::Ask, + ); + let eff = resolve(Some(&project), Some(&agent)).unwrap(); + assert_eq!(eff.rules().len(), 2); + assert_eq!( + eff.decide_file(Capability::Read, "src/main.rs"), + Posture::Allow + ); + assert_eq!(eff.decide_file(Capability::Write, "out/x"), Posture::Allow); + } + + #[test] + fn project_deny_beats_agent_allow() { + let project = PermissionSet::new( + vec![ + PermissionRule::file(Capability::Write, Effect::Deny, path_scope(&[".ideai/**"])) + .unwrap(), + ], + Posture::Allow, + ); + let agent = PermissionSet::new( + vec![ + PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["**"])) + .unwrap(), + ], + Posture::Allow, + ); + let eff = resolve(Some(&project), Some(&agent)).unwrap(); + // The agent's broad allow does NOT override the project's specific deny. + assert_eq!( + eff.decide_file(Capability::Write, ".ideai/agents.json"), + Posture::Deny + ); + // A path outside the deny scope is allowed. + assert_eq!( + eff.decide_file(Capability::Write, "src/main.rs"), + Posture::Allow + ); + } + + #[test] + fn decide_file_falls_back_when_no_rule_matches() { + let set = PermissionSet::new( + vec![ + PermissionRule::file(Capability::Read, Effect::Allow, path_scope(&["src/**"])) + .unwrap(), + ], + Posture::Ask, + ); + let eff = resolve(Some(&set), None).unwrap(); + assert_eq!( + eff.decide_file(Capability::Read, "doc/readme.md"), + Posture::Ask + ); + // A bash query never crosses into file rules. + assert_eq!(eff.decide_bash("ls"), Posture::Ask); + } + + // ---- resolve: bash semantics ---------------------------------------- + + #[test] + fn bash_blanket_allow_with_specific_deny() { + let set = PermissionSet::new( + vec![ + PermissionRule::bash(Effect::Allow, vec![]), + PermissionRule::bash( + Effect::Deny, + vec![CommandRule::new( + CommandMatcher::prefix("rm ").unwrap(), + Effect::Deny, + )], + ), + ], + Posture::Deny, + ); + let eff = resolve(Some(&set), None).unwrap(); + assert_eq!(eff.decide_bash("ls -la"), Posture::Allow); + assert_eq!(eff.decide_bash("rm -rf /"), Posture::Deny); + } + + #[test] + fn bash_specific_only_falls_back_for_unmatched() { + let set = PermissionSet::new( + vec![PermissionRule::bash( + Effect::Deny, // ignored: commands non-empty + vec![CommandRule::new( + CommandMatcher::glob("git *").unwrap(), + Effect::Allow, + )], + )], + Posture::Ask, + ); + let eff = resolve(Some(&set), None).unwrap(); + assert_eq!(eff.decide_bash("git status"), Posture::Allow); + assert_eq!(eff.decide_bash("npm test"), Posture::Ask); + } + + // ---- resolve: fallback tightening ----------------------------------- + + #[test] + fn fallback_takes_more_restrictive_of_both() { + let project = PermissionSet::new(vec![], Posture::Allow); + let agent = PermissionSet::new(vec![], Posture::Deny); + let eff = resolve(Some(&project), Some(&agent)).unwrap(); + assert_eq!(eff.fallback(), Posture::Deny); + + let project2 = PermissionSet::new(vec![], Posture::Deny); + let agent2 = PermissionSet::new(vec![], Posture::Allow); + // Agent cannot loosen the project's stance. + assert_eq!( + resolve(Some(&project2), Some(&agent2)).unwrap().fallback(), + Posture::Deny + ); + } + + #[test] + fn posture_tighten_orders_allow_ask_deny() { + assert_eq!(Posture::Allow.tighten(Posture::Ask), Posture::Ask); + assert_eq!(Posture::Ask.tighten(Posture::Allow), Posture::Ask); + assert_eq!(Posture::Ask.tighten(Posture::Deny), Posture::Deny); + assert_eq!(Posture::Deny.tighten(Posture::Allow), Posture::Deny); + } + + #[test] + fn project_permissions_resolves_sparse_agent_override() { + let agent = AgentId::new_random(); + let project = PermissionSet::new(vec![], Posture::Ask); + let custom = PermissionSet::new(vec![], Posture::Deny); + let doc = ProjectPermissions::new( + Some(project), + vec![AgentPermissionOverride::new(agent, custom)], + ); + + assert_eq!(doc.resolve_for(agent).unwrap().fallback(), Posture::Deny); + assert_eq!( + doc.resolve_for(AgentId::new_random()).unwrap().fallback(), + Posture::Ask + ); + } + + // ---- LP3: ProjectorKey serde + PermissionProjection invariant ------ + + #[test] + fn projector_key_serialises_to_stable_camel_case() { + // The wire form is load-bearing (it keys the concrete projector in infra). + assert_eq!( + serde_json::to_string(&ProjectorKey::Claude).unwrap(), + "\"claude\"" + ); + assert_eq!( + serde_json::to_string(&ProjectorKey::Codex).unwrap(), + "\"codex\"" + ); + } + + #[test] + fn projector_key_round_trips() { + for key in [ProjectorKey::Claude, ProjectorKey::Codex] { + let json = serde_json::to_string(&key).unwrap(); + let back: ProjectorKey = serde_json::from_str(&json).unwrap(); + assert_eq!(key, back); + } + // And the literal wire form deserialises back to the expected variant. + assert_eq!( + serde_json::from_str::("\"claude\"").unwrap(), + ProjectorKey::Claude + ); + assert_eq!( + serde_json::from_str::("\"codex\"").unwrap(), + ProjectorKey::Codex + ); + } + + #[test] + fn permission_projection_empty_is_fully_empty() { + // The invariant value returned when `eff == None`: project nothing. + let proj = PermissionProjection::empty(); + assert!(proj.files.is_empty(), "no files materialised"); + assert!(proj.args.is_empty(), "no launch args"); + assert!(proj.env.is_empty(), "no env vars"); + } + + #[test] + fn project_permissions_keeps_last_override_for_agent() { + let agent = AgentId::new_random(); + let doc = ProjectPermissions::new( + None, + vec![ + AgentPermissionOverride::new(agent, PermissionSet::new(vec![], Posture::Ask)), + AgentPermissionOverride::new(agent, PermissionSet::new(vec![], Posture::Deny)), + ], + ); + + assert_eq!(doc.agents.len(), 1); + assert_eq!(doc.resolve_for(agent).unwrap().fallback(), Posture::Deny); + } + + // ---- LP4-0: render_permission_summary ------------------------------- + + #[test] + fn summary_is_none_when_nothing_posed() { + // Mirrors resolve's product invariant: nothing posed ⇒ nothing to render. + assert!(render_permission_summary(None).is_none()); + } + + #[test] + fn summary_states_files_os_enforced_and_commands_advisory() { + // The honesty invariant: files are OS-enforced (Landlock when supported) + // while commands stay advisory / NOT OS-locked. Both must be explicit. + let set = PermissionSet::new( + vec![ + PermissionRule::file(Capability::Read, Effect::Allow, path_scope(&["src/**"])) + .unwrap(), + PermissionRule::bash( + Effect::Deny, + vec![CommandRule::new( + CommandMatcher::prefix("rm ").unwrap(), + Effect::Deny, + )], + ), + ], + Posture::Ask, + ); + let eff = resolve(Some(&set), None).unwrap(); + let md = render_permission_summary(Some(&eff)).expect("a posed policy renders a summary"); + + // Files: OS-enforced / Landlock when supported. + assert!(md.contains("OS-enforced"), "files block must say OS-enforced"); + assert!(md.contains("Landlock"), "files block must name Landlock"); + // Commands: advisory, NOT OS-locked, and the why (ExecuteBash). + assert!(md.contains("advisory"), "commands must be called advisory"); + assert!( + md.contains("NOT OS-locked"), + "commands must be flagged NOT OS-locked" + ); + assert!( + md.contains("ExecuteBash"), + "the rationale must name the ExecuteBash capability" + ); + // The actual rules surface in their respective sections. + assert!(md.contains("`src/**`"), "the file scope is shown"); + assert!(md.contains("`rm *` (prefix)"), "the command matcher is shown"); + // Resolved posture is surfaced. + assert!(md.contains("**Default posture:** Ask")); + } + + #[test] + fn summary_handles_empty_rule_lists_per_section() { + // A policy with only a fallback still renders both sections honestly. + let set = PermissionSet::new(vec![], Posture::Deny); + let eff = resolve(Some(&set), None).unwrap(); + let md = render_permission_summary(Some(&eff)).unwrap(); + assert!(md.contains("no file rule")); + assert!(md.contains("no command rule")); + // The enforcement-boundary wording is present even with no rules. + assert!(md.contains("OS-enforced") && md.contains("NOT OS-locked")); + assert!(md.contains("**Default posture:** Deny")); + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index af49b1c..5050088 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -32,6 +32,7 @@ use crate::events::DomainEvent; use crate::ids::{AgentId, SessionId}; use crate::markdown::MarkdownDoc; use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug}; +use crate::permission::ProjectPermissions; use crate::profile::{AgentProfile, EmbedderProfile}; use crate::project::{Project, ProjectPath}; use crate::remote::RemoteKind; @@ -97,6 +98,10 @@ pub struct SpawnSpec { pub env: Vec<(String, String)>, /// How the context is injected, if any. pub context_plan: Option, + /// OS-sandbox plan to enforce on the spawned process (lot LP4). `None` ⇒ no + /// enforcement (the agent runs natively); the field is wired but unused until + /// the Landlock adapter (LP4-1) and launch path (LP4-2) consume it. + pub sandbox: Option, } /// The agent context prepared for injection (content + the on-disk path it maps @@ -209,6 +214,17 @@ pub enum ReplyEvent { /// Libellé humain-lisible de l'activité. label: String, }, + /// **Preuve de vivacité non terminale** (model-agnostique) : l'agent est + /// toujours en train de travailler. Émis par l'adapter quand le moteur signale + /// un battement de cœur natif **sans contenu utile** (handshake/init, fenêtre de + /// limite de débit, début/fin de tour côté moteur…). Sert à distinguer « agent + /// vivant mais lent » d'« agent bloqué » (readiness/heartbeat, lot 1). + /// + /// **Jamais terminal** : un `Heartbeat` ne clôt **pas** le flux — il s'intercale + /// comme un delta (ignoré par les consommateurs synchrones) et le flux continue + /// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais + /// d'obligation d'en émettre. + Heartbeat, /// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a /// lu le message `result` documenté de la CLI. Porte le contenu final agrégé. /// Après `Final`, le flux se termine (plus aucun événement). @@ -220,8 +236,10 @@ pub enum ReplyEvent { /// Flux borné d'événements de réponse d'UN tour (ARCHITECTURE §17.1). Se termine /// après le [`ReplyEvent::Final`] (ou sur erreur). Calqué sur [`OutputStream`], -/// mais **typé** : deltas de texte → activités d'outil → un `Final` déterministe, -/// plutôt que des octets bruts. +/// mais **typé** : deltas de texte → activités d'outil → battements de cœur* +/// ([`ReplyEvent::Heartbeat`], non terminaux, en nombre quelconque et entrelacés) → +/// **un** `Final` terminal déterministe, plutôt que des octets bruts. Seul le `Final` +/// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux. pub type ReplyStream = Box + Send>; // --------------------------------------------------------------------------- @@ -513,6 +531,13 @@ pub trait AgentSessionFactory: Send + Sync { /// §14.1), avec le contexte déjà préparé ([`PreparedContext`]) et l'intention de /// session ([`SessionPlan`] : neuf / assign / resume — réutilise §15). /// + /// `sandbox` est le plan de sandbox OS **par lancement** (lot LP4-4), déjà + /// compilé (pur, domaine) au launch path et porté jusqu'ici comme il l'est pour + /// le chemin PTY via [`SpawnSpec::sandbox`]. `None` ⇒ aucun plan ⇒ exécution + /// native inchangée (invariant produit : rien posé ⇒ rien projeté). Le plan + /// franchit le port en tant que **valeur domaine** ([`crate::sandbox::SandboxPlan`]) ; + /// l'enforcer concret reste côté infra (injecté par instance dans la fabrique). + /// /// # Errors /// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode /// structuré ne peut s'initialiser. @@ -522,6 +547,7 @@ pub trait AgentSessionFactory: Send + Sync { ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, + sandbox: Option<&crate::sandbox::SandboxPlan>, ) -> Result, AgentSessionError>; } @@ -604,6 +630,21 @@ pub trait FileSystem: Send + Sync { /// [`FsError`] on failure. async fn exists(&self, path: &RemotePath) -> Result; + /// Removes a single file. A **missing** file is treated as success (idempotent + /// delete), so this is safe to call best-effort. + /// + /// Defaults to a **no-op `Ok(())`** so existing adapters and in-memory test + /// doubles keep compiling unchanged; the real adapter overrides it with an + /// actual delete. Callers that rely on the file actually being gone must use an + /// adapter that overrides this (the local FS does). + /// + /// # Errors + /// [`FsError`] on a failure other than not-found. + async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> { + let _ = path; + Ok(()) + } + /// Creates a directory and all missing parents. /// /// # Errors @@ -1050,6 +1091,27 @@ pub trait AgentContextStore: Send + Sync { ) -> Result<(), StoreError>; } +/// Reads/writes a project's `.ideai/permissions.json`. +#[async_trait] +pub trait PermissionStore: Send + Sync { + /// Loads the project's permission document. Missing file returns the default + /// empty document. + /// + /// # Errors + /// [`StoreError`] on I/O or deserialisation failure. + async fn load_permissions(&self, project: &Project) -> Result; + + /// Saves the project's permission document. + /// + /// # Errors + /// [`StoreError`] on I/O or serialisation failure. + async fn save_permissions( + &self, + project: &Project, + permissions: &ProjectPermissions, + ) -> Result<(), StoreError>; +} + /// Git operations for a project. Named `GitPort` to avoid clashing with the /// [`crate::git::GitRepository`] *entity* (state image). #[async_trait] diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index 45505fe..dc8aa8e 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::error::DomainError; use crate::ids::ProfileId; +use crate::permission::ProjectorKey; /// Strategy for injecting an agent's `.md` context into the launched CLI. /// @@ -118,6 +119,61 @@ impl SessionStrategy { } } +/// Réglages de **vivacité** (readiness/heartbeat) d'un profil IA — place ménagée +/// pour le lot 2 (chantier readiness/heartbeat). Donnée **déclarative** (pas de code +/// par CLI — Open/Closed), calquée sur [`SessionStrategy`] / [`McpCapability`]. +/// +/// Deux seuils optionnels : +/// - `stall_after_ms` : délai sans **aucune** preuve de vivacité (delta / activité / +/// `ReplyEvent::Heartbeat`) au bout duquel l'agent est présumé **bloqué** +/// (`ReadinessSignal::Stalled`) — détection au lot 2 ; +/// - `turn_timeout_ms` : durée maximale d'**un tour** avant le garde-fou +/// (`ReadinessSignal::TimedOut`) — remplacement des timeouts en dur au lot 2. +/// +/// **Lot 1 : champs présents mais non consommés** — on ne fait que ménager la place +/// (le modèle de sérialisation et l'API sont figés ici pour éviter une migration au +/// lot 2). `None` (défaut, et valeur des profils existants) ⇒ comportement actuel. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LivenessStrategy { + /// Délai (ms) sans preuve de vivacité avant de présumer l'agent bloqué. + /// `None` ⇒ pas de détection de stagnation (lot 2). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stall_after_ms: Option, + /// Durée maximale (ms) d'un tour avant le garde-fou de timeout. `None` ⇒ pas de + /// garde-fou par profil (lot 2). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_timeout_ms: Option, +} + +impl LivenessStrategy { + /// Construit une stratégie de vivacité validée (parse-don't-validate, comme + /// [`SessionStrategy::new`]). + /// + /// # Errors + /// Renvoie [`DomainError::EmptyField`] si un seuil fourni vaut `0` (un seuil de + /// `0 ms` n'a pas de sens : `None` est la façon d'exprimer « pas de seuil »). + pub const fn new( + stall_after_ms: Option, + turn_timeout_ms: Option, + ) -> Result { + if let Some(0) = stall_after_ms { + return Err(DomainError::EmptyField { + field: "liveness.stallAfterMs", + }); + } + if let Some(0) = turn_timeout_ms { + return Err(DomainError::EmptyField { + field: "liveness.turnTimeoutMs", + }); + } + Ok(Self { + stall_after_ms, + turn_timeout_ms, + }) + } +} + /// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17). /// /// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel @@ -193,6 +249,22 @@ pub enum McpConfigStrategy { /// Nom de la variable d'environnement. var: String, }, + /// Écrire un fichier de conf MCP **TOML** au chemin (relatif au run dir isolé + /// §14.1) attendu par la CLI, et pousser `home_env` (le nom d'une variable + /// d'environnement) vers le **dossier parent** de `target` pour isoler la CLI + /// de sa config globale. C'est le pendant Codex de [`Self::ConfigFile`] : Codex + /// lit ses serveurs MCP dans `$CODEX_HOME/config.toml` (défaut `~/.codex`), table + /// TOML `[mcp_servers.]`. IdeA écrit ce `config.toml` DANS le run dir de + /// l'agent et pointe `CODEX_HOME={runDir}/.codex` pour ne JAMAIS toucher au + /// `~/.codex` global (isolation par agent, miroir du `.mcp.json` de Claude). + TomlConfigHome { + /// Chemin relatif sûr du fichier `config.toml` (convention : + /// `".codex/config.toml"`). + target: String, + /// Nom de la variable d'environnement pointée sur le **dossier parent** de + /// `target` (ex. `"CODEX_HOME"`). + home_env: String, + }, } impl McpConfigStrategy { @@ -227,6 +299,24 @@ impl McpConfigStrategy { crate::validation::valid_env_var(&var)?; Ok(Self::Env { var }) } + + /// Constructeur validé `TomlConfigHome` (pendant Codex de [`Self::config_file`]). + /// + /// # Errors + /// - [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou contient `..` + /// (même validation que [`Self::config_file`]), + /// - [`DomainError::InvalidEnvVar`] si `home_env` n'est pas un identifiant de + /// variable d'environnement valide. + pub fn toml_config_home( + target: impl Into, + home_env: impl Into, + ) -> Result { + let target = target.into(); + let home_env = home_env.into(); + crate::validation::relative_safe(&target)?; + crate::validation::valid_env_var(&home_env)?; + Ok(Self::TomlConfigHome { target, home_env }) + } } /// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI, @@ -253,6 +343,128 @@ impl McpCapability { } } +/// Donnée de **wiring** du serveur MCP IdeA (`command` + `args` + `transport`), +/// factorisée pour servir de **source unique** aux deux sérialisations qui en +/// dérivaient séparément (et risquaient de diverger) : la déclaration `.mcp.json` +/// de Claude (JSON) et la table `[mcp_servers.idea]` de Codex (TOML). +/// +/// Pure (aucune I/O, aucune dépendance) : les deux encodeurs construisent une +/// chaîne à la main, donc le domaine reste sans dépendance (`serde_json`/`toml` +/// non requis). Le contenu (exe, endpoint, …) est calculé par l'appelant — le +/// domaine ne fait que la **mise en forme**. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpServerWiring { + /// Commande à lancer (le binaire IdeA en mode `mcp-server`, ou `"idea"` en + /// déclaration minimale dégradée). + pub command: String, + /// Arguments passés à `command` (ex. `["mcp-server", "--endpoint", …]`). + pub args: Vec, + /// Transport du serveur MCP (surfacé dans les deux formats). + pub transport: McpTransport, +} + +impl McpServerWiring { + /// Construit le wiring depuis ses parties. + #[must_use] + pub const fn new(command: String, args: Vec, transport: McpTransport) -> Self { + Self { + command, + args, + transport, + } + } + + /// Étiquette stable du transport, identique pour les deux formats. + #[must_use] + const fn transport_label(&self) -> &'static str { + match self.transport { + McpTransport::Stdio => "stdio", + McpTransport::Socket => "socket", + } + } + + /// Encode un document **`.mcp.json`** complet (Claude Code et CLIs apparentées) : + /// `{ "mcpServers": { "idea": { command, args, transport } } }`. Chaque chaîne est + /// échappée en littéral JSON (chemins avec espaces/backslash/quotes restent + /// valides). + #[must_use] + pub fn to_mcp_json(&self) -> String { + let command = json_string(&self.command); + let args = if self.args.is_empty() { + String::new() + } else { + let joined = self + .args + .iter() + .map(|a| format!("\n {}", json_string(a))) + .collect::>() + .join(","); + format!("{joined}\n ") + }; + let transport = self.transport_label(); + format!( + r#"{{ + "mcpServers": {{ + "idea": {{ + "command": {command}, + "args": [{args}], + "transport": "{transport}" + }} + }} +}} +"# + ) + } + + /// Encode la table **`[mcp_servers.idea]`** d'un `config.toml` Codex : + /// `command`, `args` (tableau TOML), `transport`. Chaque chaîne est échappée en + /// chaîne basique TOML (équivalent du `json_string` : espaces/backslash/quotes + /// restent valides). + #[must_use] + pub fn to_config_toml(&self) -> String { + let command = toml_string(&self.command); + let args = self + .args + .iter() + .map(|a| toml_string(a)) + .collect::>() + .join(", "); + let transport = self.transport_label(); + format!( + "[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\n" + ) + } +} + +/// Échappe `s` en **littéral de chaîne JSON** (guillemets inclus) pour les chemins +/// exe/endpoint avec espaces, backslash ou quotes. +fn json_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Échappe `s` en **chaîne basique TOML** (guillemets inclus). Les chaînes +/// basiques TOML utilisent les mêmes séquences d'échappement que JSON pour `"`, +/// `\`, et les contrôles. +fn toml_string(s: &str) -> String { + // Le jeu d'échappement requis par une chaîne basique TOML coïncide avec celui de + // JSON pour les caractères qui nous concernent (chemins, flags). + json_string(s) +} + /// Declarative runtime configuration for one AI CLI. /// /// Invariants: @@ -316,6 +528,13 @@ pub struct AgentProfile { /// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard /// comme variante déclarative (Open/Closed) si le besoin se confirme. /// + /// **Rang (chantier readiness/heartbeat, lot 1) : signal de repli n°3.** Depuis + /// l'introduction de la fin-de-tour structurée ([`crate::ports::ReplyEvent::Final`] + /// ⇒ [`crate::readiness::ReadinessSignal::TurnEnded`], signal n°1) et du signal + /// explicite `idea_reply` (n°2), ce sniff littéral est **rétrogradé** au rang de + /// repli : il ne sert plus que pour les agents **TUI/PTY sans adapter structuré**. + /// Conservé tel quel pour la rétro-compat (jamais supprimé). + /// /// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par /// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via /// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste @@ -325,6 +544,51 @@ pub struct AgentProfile { /// un profil sans motif sérialise exactement comme avant. #[serde(default, skip_serializing_if = "Option::is_none")] pub prompt_ready_pattern: Option, + /// Réglages de **vivacité** (readiness/heartbeat, chantier lot 1). `None` (défaut, + /// et valeur des profils existants) ⇒ comportement actuel. **Lot 1** : champ + /// présent mais **non consommé** (place ménagée pour les seuils de stagnation / + /// timeout de tour du lot 2). + /// + /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation : + /// un profil sans cette clé sérialise exactement comme avant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub liveness: Option, + /// Séquence de soumission écrite **après** le texte d'une délégation pour la + /// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front) + /// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de + /// la TUI), puis **cette séquence seule** après un court délai. + /// + /// `None` ⇒ défaut `"\r"` appliqué **au point d'usage** (front), jamais codé + /// en dur dans le domaine (model-agnostic, déclaratif). Une autre TUI peut + /// exiger une séquence différente → c'est précisément pourquoi c'est donnée. + /// + /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** : un profil + /// sans cette clé sérialise exactement comme avant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub submit_sequence: Option, + /// Délai (ms) entre l'écriture du texte et celle de [`Self::submit_sequence`] + /// (§20.3, fix Bug 1). Évite que la TUI absorbe la soumission comme un paste. + /// + /// `None` ⇒ défaut (~60 ms) appliqué **au point d'usage** (front), jamais codé + /// en dur dans le domaine. + /// + /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub submit_delay_ms: Option, + /// Clé du **projecteur de permissions** par-CLI (lot LP3) : QUEL adapter + /// traduit les [`crate::permission::EffectivePermissions`] résolues en config + /// de permission concrète de cette CLI au lancement. Donnée **déclarative** + /// (Open/Closed), calquée sur [`StructuredAdapter`]. + /// + /// `None` (défaut, et valeur des profils existants) ⇒ **aucune** projection : + /// la CLI garde son prompting natif (invariant produit de + /// [`crate::permission::resolve`]). `Some(_)` ⇒ IdeA matérialise la config de + /// permission de cette CLI au lancement. + /// + /// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de + /// sérialisation : un profil sans cette clé sérialise exactement comme avant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub projector: Option, } /// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel). @@ -461,6 +725,10 @@ impl AgentProfile { structured_adapter: None, mcp: None, prompt_ready_pattern: None, + liveness: None, + submit_sequence: None, + submit_delay_ms: None, + projector: None, }) } @@ -491,6 +759,41 @@ impl AgentProfile { self } + /// Builder : fixe la [`LivenessStrategy`] (readiness/heartbeat, lot 1) et renvoie + /// le profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les + /// profils sans réglage de vivacité ne l'appellent simplement pas. + #[must_use] + pub const fn with_liveness(mut self, liveness: LivenessStrategy) -> Self { + self.liveness = Some(liveness); + self + } + + /// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le + /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les + /// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas. + #[must_use] + pub fn with_submit_sequence(mut self, sequence: impl Into) -> Self { + self.submit_sequence = Some(sequence.into()); + self + } + + /// Builder : fixe le [`Self::submit_delay_ms`] (§20.3, fix Bug 1) et renvoie le + /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel). + #[must_use] + pub const fn with_submit_delay_ms(mut self, delay_ms: u32) -> Self { + self.submit_delay_ms = Some(delay_ms); + self + } + + /// Builder : fixe la [`ProjectorKey`] de permissions (lot LP3) et renvoie le + /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les + /// profils sans projection de permission ne l'appellent simplement pas. + #[must_use] + pub const fn with_projector(mut self, projector: ProjectorKey) -> Self { + self.projector = Some(projector); + self + } + /// Indique si ce profil peut être **proposé à la sélection/création** d'un /// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est /// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il @@ -505,6 +808,33 @@ impl AgentProfile { pub fn is_selectable(&self) -> bool { self.structured_adapter.is_some() } + + /// **Source de vérité UNIQUE** de la whitelist des couples (adaptateur structuré + /// × stratégie MCP) qu'IdeA **matérialise réellement** pour exposer les outils + /// `idea_*` à la CLI — donc les seuls couples vers lesquels la délégation + /// inter-agents (`idea_ask_agent`/`idea_reply`) peut router une cible. + /// + /// Un profil déclare bien une [`McpConfigStrategy`], mais ce n'est honoré que si + /// IdeA sait écrire la config que **cette** CLI lit nativement : + /// - `Claude` + `ConfigFile { target == ".mcp.json" }` ⇒ Claude lit `.mcp.json` + /// dans son cwd (run dir isolé) ; + /// - `Codex` + `TomlConfigHome { .. }` ⇒ Codex lit `$CODEX_HOME/config.toml`, + /// qu'IdeA isole dans le run dir via `home_env` ; + /// - tout autre couple (y compris `mcp` absent) ⇒ `false` : repli fichier + /// `.ideai/requests` + prose, le pont natif n'est pas branché. + /// + /// Cette fonction centralise le critère pour que la garde applicative + /// ([`crate`] côté application) et la matérialisation ne puissent pas diverger. + #[must_use] + pub fn materializes_idea_bridge(&self) -> bool { + match (self.structured_adapter, self.mcp.as_ref().map(|c| &c.config)) { + (Some(StructuredAdapter::Claude), Some(McpConfigStrategy::ConfigFile { target })) => { + target == ".mcp.json" + } + (Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => true, + _ => false, + } + } } #[cfg(test)] @@ -611,10 +941,9 @@ mod mcp_tests { #[test] fn mcp_config_strategy_uses_tagged_camel_case() { // The `strategy` tag and camelCase rename are part of the wire contract. - let json = serde_json::to_string( - &McpConfigStrategy::config_file(".mcp.json").expect("valid"), - ) - .expect("serialise"); + let json = + serde_json::to_string(&McpConfigStrategy::config_file(".mcp.json").expect("valid")) + .expect("serialise"); assert!(json.contains("\"strategy\":\"configFile\""), "got: {json}"); } @@ -651,7 +980,12 @@ mod mcp_tests { #[test] fn config_file_accepts_safe_relative_target() { let s = McpConfigStrategy::config_file(".mcp.json").expect("safe relative"); - assert_eq!(s, McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() }); + assert_eq!( + s, + McpConfigStrategy::ConfigFile { + target: ".mcp.json".to_owned() + } + ); } #[test] @@ -663,7 +997,12 @@ mod mcp_tests { #[test] fn flag_accepts_non_empty() { let s = McpConfigStrategy::flag("--mcp-config {path}").expect("non-empty"); - assert_eq!(s, McpConfigStrategy::Flag { flag: "--mcp-config {path}".to_owned() }); + assert_eq!( + s, + McpConfigStrategy::Flag { + flag: "--mcp-config {path}".to_owned() + } + ); } #[test] @@ -675,7 +1014,12 @@ mod mcp_tests { #[test] fn env_accepts_valid_name() { let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid"); - assert_eq!(s, McpConfigStrategy::Env { var: "IDEA_MCP_SERVER".to_owned() }); + assert_eq!( + s, + McpConfigStrategy::Env { + var: "IDEA_MCP_SERVER".to_owned() + } + ); } // -- Lot C5 : prompt_ready_pattern (détection retour-de-prompt) -------------- @@ -721,4 +1065,305 @@ mod mcp_tests { assert_eq!(profile, back); assert_eq!(back.prompt_ready_pattern.as_deref(), Some("\n> ")); } + + // -- §20 : submit_sequence / submit_delay_ms (portail d'écriture) ------------ + + #[test] + fn profile_default_has_no_submit_fields() { + let p = profile_without_mcp(); + assert!(p.submit_sequence.is_none()); + assert!(p.submit_delay_ms.is_none()); + } + + #[test] + fn profile_without_submit_fields_omits_keys_in_json() { + let json = serde_json::to_string(&profile_without_mcp()).expect("serialise"); + assert!( + !json.contains("submitSequence"), + "no submitSequence key when None (zero regression); got: {json}" + ); + assert!( + !json.contains("submitDelayMs"), + "no submitDelayMs key when None (zero regression); got: {json}" + ); + } + + #[test] + fn legacy_json_without_submit_fields_deserialises_to_none() { + let legacy = r#"{ + "id": "00000000-0000-0000-0000-000000000000", + "name": "Dev", + "command": "claude", + "args": [], + "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, + "detect": null, + "cwdTemplate": "{agentRunDir}" + }"#; + let p: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise"); + assert!(p.submit_sequence.is_none()); + assert!(p.submit_delay_ms.is_none()); + } + + #[test] + fn with_submit_fields_set_and_round_trip_camel_case() { + let p = profile_without_mcp() + .with_submit_sequence("\r") + .with_submit_delay_ms(60); + assert_eq!(p.submit_sequence.as_deref(), Some("\r")); + assert_eq!(p.submit_delay_ms, Some(60)); + + let json = serde_json::to_string(&p).expect("serialise"); + assert!(json.contains("submitSequence"), "key present: {json}"); + assert!(json.contains("submitDelayMs"), "key present: {json}"); + + let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(p, back); + assert_eq!(back.submit_sequence.as_deref(), Some("\r")); + assert_eq!(back.submit_delay_ms, Some(60)); + } + + // -- Lot 1 : liveness (readiness/heartbeat) — non-régression de sérialisation -- + + #[test] + fn profile_default_has_no_liveness() { + // Profils existants (via `new`) : aucun réglage de vivacité. + assert!(profile_without_mcp().liveness.is_none()); + } + + #[test] + fn profile_without_liveness_omits_key_in_json() { + let json = serde_json::to_string(&profile_without_mcp()).expect("serialise"); + assert!( + !json.contains("liveness"), + "a profile without liveness must NOT serialise the key (zero regression); got: {json}" + ); + } + + #[test] + fn legacy_json_without_liveness_deserialises_to_none() { + let legacy = r#"{ + "id": "00000000-0000-0000-0000-000000000000", + "name": "Dev", + "command": "claude", + "args": [], + "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, + "detect": null, + "cwdTemplate": "{agentRunDir}" + }"#; + let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise"); + assert!(profile.liveness.is_none()); + } + + #[test] + fn with_liveness_sets_and_round_trips_camel_case() { + let liveness = LivenessStrategy::new(Some(30_000), Some(600_000)).expect("valid liveness"); + let profile = profile_without_mcp().with_liveness(liveness); + assert_eq!(profile.liveness, Some(liveness)); + + let json = serde_json::to_string(&profile).expect("serialise"); + assert!(json.contains("liveness"), "key present: {json}"); + assert!(json.contains("stallAfterMs"), "camelCase field: {json}"); + assert!(json.contains("turnTimeoutMs"), "camelCase field: {json}"); + + let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(profile, back); + assert_eq!(back.liveness, Some(liveness)); + } + + #[test] + fn liveness_omits_unset_thresholds_in_json() { + // Un seul seuil fixé : l'autre est `None` ⇒ sa clé est omise. + let liveness = LivenessStrategy::new(None, Some(600_000)).expect("valid liveness"); + let json = serde_json::to_string(&liveness).expect("serialise"); + assert!( + !json.contains("stallAfterMs"), + "an unset stall threshold must be omitted; got: {json}" + ); + assert!(json.contains("turnTimeoutMs"), "set threshold present: {json}"); + } + + #[test] + fn liveness_new_rejects_zero_thresholds() { + assert!(matches!( + LivenessStrategy::new(Some(0), None).unwrap_err(), + DomainError::EmptyField { .. } + )); + assert!(matches!( + LivenessStrategy::new(None, Some(0)).unwrap_err(), + DomainError::EmptyField { .. } + )); + // Les deux None : valide (= « aucun seuil »). + assert!(LivenessStrategy::new(None, None).is_ok()); + } + + // -- Codex : surface MCP `TomlConfigHome` (pont inter-agents Codex) ---------- + + #[test] + fn toml_config_home_round_trips_with_tagged_strategy() { + let strategy = McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME") + .expect("valid toml config home"); + let json = serde_json::to_string(&strategy).expect("serialise"); + + // Wire contract: tagged enum (`strategy` tag) + camelCase variant & fields. + assert!( + json.contains("\"strategy\":\"tomlConfigHome\""), + "tagged camelCase variant expected; got: {json}" + ); + assert!( + json.contains("\"target\":\".codex/config.toml\""), + "target field expected; got: {json}" + ); + // NB: `rename_all = "camelCase"` on this enum renames *variants*, not the + // fields of a struct variant, so `home_env` stays snake_case on the wire. + assert!( + json.contains("\"home_env\":\"CODEX_HOME\""), + "home_env field expected; got: {json}" + ); + + let back: McpConfigStrategy = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(strategy, back); + } + + #[test] + fn toml_config_home_rejects_absolute_and_parent_target() { + let abs = McpConfigStrategy::toml_config_home("/abs/x", "CODEX_HOME").unwrap_err(); + assert!(matches!(abs, DomainError::PathNotRelativeSafe { .. })); + + let parent = McpConfigStrategy::toml_config_home("../escape", "CODEX_HOME").unwrap_err(); + assert!(matches!(parent, DomainError::PathNotRelativeSafe { .. })); + } + + #[test] + fn toml_config_home_rejects_invalid_home_env() { + // Empty home_env: first char is None ⇒ invalid identifier. + let empty = McpConfigStrategy::toml_config_home(".codex/config.toml", "").unwrap_err(); + assert!(matches!(empty, DomainError::InvalidEnvVar { .. })); + + // Illegal character in the env var name. + let illegal = + McpConfigStrategy::toml_config_home(".codex/config.toml", "BAD-NAME").unwrap_err(); + assert!(matches!(illegal, DomainError::InvalidEnvVar { .. })); + } + + #[test] + fn materializes_idea_bridge_matrix() { + // Claude + `.mcp.json` ConfigFile ⇒ bridge materialised. + let claude = profile_without_mcp() + .with_structured_adapter(StructuredAdapter::Claude) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").expect("valid target"), + McpTransport::Stdio, + )); + assert!(claude.materializes_idea_bridge()); + + // Codex + TomlConfigHome ⇒ bridge materialised (the key point: Codex used to + // be refused before this surface existed). + let codex = profile_without_mcp() + .with_structured_adapter(StructuredAdapter::Codex) + .with_mcp(McpCapability::new( + McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME") + .expect("valid toml config home"), + McpTransport::Stdio, + )); + assert!(codex.materializes_idea_bridge()); + + // Codex WITHOUT any MCP capability ⇒ no bridge. + let codex_no_mcp = + profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex); + assert!(!codex_no_mcp.materializes_idea_bridge()); + + // Codex + wrong strategy (`.mcp.json` ConfigFile, Claude's shape) ⇒ no bridge. + let codex_wrong_strategy = profile_without_mcp() + .with_structured_adapter(StructuredAdapter::Codex) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").expect("valid target"), + McpTransport::Stdio, + )); + assert!(!codex_wrong_strategy.materializes_idea_bridge()); + } + + // -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ---------- + + #[test] + fn profile_default_has_no_projector() { + // Profils existants (via `new`) : aucune clé de projecteur ⇒ prompting natif. + assert!(profile_without_mcp().projector.is_none()); + } + + #[test] + fn profile_without_projector_omits_key_in_json() { + let json = serde_json::to_string(&profile_without_mcp()).expect("serialise"); + assert!( + !json.contains("projector"), + "a profile without a projector must NOT serialise the key (zero regression); got: {json}" + ); + } + + #[test] + fn legacy_json_without_projector_deserialises_to_none() { + // JSON produit avant l'existence du champ `projector` : aucune clé `projector`. + let legacy = r#"{ + "id": "00000000-0000-0000-0000-000000000000", + "name": "Dev", + "command": "claude", + "args": [], + "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, + "detect": null, + "cwdTemplate": "{agentRunDir}" + }"#; + let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise"); + assert!(profile.projector.is_none()); + } + + #[test] + fn with_projector_sets_and_round_trips_camel_case() { + let profile = profile_without_mcp().with_projector(ProjectorKey::Claude); + assert_eq!(profile.projector, Some(ProjectorKey::Claude)); + + let json = serde_json::to_string(&profile).expect("serialise"); + assert!( + json.contains("\"projector\":\"claude\""), + "projector key present in stable wire form: {json}" + ); + + let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(profile, back); + assert_eq!(back.projector, Some(ProjectorKey::Claude)); + } + + #[test] + fn mcp_server_wiring_encodes_expected_toml() { + // A command path with a space and a backslash exercises TOML escaping. + let wiring = McpServerWiring::new( + "/opt/My Apps\\idea".to_owned(), + vec![ + "mcp-server".to_owned(), + "--endpoint".to_owned(), + "/tmp/sock 1".to_owned(), + ], + McpTransport::Stdio, + ); + let toml = wiring.to_config_toml(); + + // Table header present. + assert!( + toml.contains("[mcp_servers.idea]"), + "table header expected; got: {toml}" + ); + // Command path escaped as a TOML basic string (backslash doubled, space kept). + assert!( + toml.contains("command = \"/opt/My Apps\\\\idea\""), + "escaped command path expected; got: {toml}" + ); + // Args preserved in order, as a TOML array. + assert!( + toml.contains("args = [\"mcp-server\", \"--endpoint\", \"/tmp/sock 1\"]"), + "args in order expected; got: {toml}" + ); + // Transport surfaced. + assert!( + toml.contains("transport = \"stdio\""), + "transport expected; got: {toml}" + ); + } } diff --git a/crates/domain/src/readiness.rs b/crates/domain/src/readiness.rs new file mode 100644 index 0000000..71b6559 --- /dev/null +++ b/crates/domain/src/readiness.rs @@ -0,0 +1,111 @@ +//! Politique de **readiness** (« fin-de-tour ») model-agnostique (chantier +//! readiness/heartbeat, lot 1). +//! +//! Objet **pur** (aucune I/O, aucune dépendance externe) qui classe un signal +//! observable d'un tour d'agent en un [`ReadinessSignal`] normalisé. Le but : que +//! l'application puisse décider de marquer un agent `Idle` +//! ([`crate::input::InputMediator::mark_idle`]) sur un **signal déterministe** +//! (`Final` du flux structuré) plutôt que de dépendre uniquement d'un `idea_reply` +//! explicite ou d'un sniff littéral de prompt PTY. +//! +//! # Hiérarchie des signaux de fin-de-tour (rappel cadrage) +//! +//! 1. **Signal n°1 — fin de tour structurée** : [`ReplyEvent::Final`] émis par +//! l'adapter (Claude `type:"result"`, Codex `agent_message`/`item.completed`). +//! Déterministe, model-agnostique ⇒ classé [`ReadinessSignal::TurnEnded`]. +//! 2. **Signal n°2 — `idea_reply` explicite** : l'agent appelle l'outil MCP +//! [`crate::ports`]/délégation. Premier arrivé gagne avec le n°1. +//! 3. **Signal n°3 — repli `prompt_ready_pattern`** : sniff littéral du sigil de +//! prompt dans la sortie PTY ([`crate::profile::AgentProfile::prompt_ready_pattern`]). +//! **Rétrogradé** au rang de repli depuis ce lot : il ne sert que pour les agents +//! TUI/PTY sans adapter structuré (rétro-compat, jamais supprimé). +//! +//! Les variantes [`ReadinessSignal::Stalled`]/[`ReadinessSignal::TimedOut`] sont la +//! place réservée au **lot 2** (détection de stagnation, remplacement des timeouts) : +//! elles existent dans le vocabulaire mais ne sont **pas** produites par +//! [`ReadinessPolicy::classify`] dans ce lot. + +use crate::ports::ReplyEvent; + +/// Signal de readiness normalisé, model-agnostique, qu'une [`ReadinessPolicy`] +/// déduit d'un événement observable du tour. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadinessSignal { + /// Le tour est **déterministiquement terminé** : l'agent a rendu son `Final`. + /// C'est le signal n°1, model-agnostique — il doit réveiller le `pending` et + /// marquer l'agent `Idle`. + TurnEnded, + /// Un `idea_reply` explicite a été observé (signal n°2). N'est **pas** produit + /// par [`ReadinessPolicy::classify`] (qui ne voit que des [`ReplyEvent`]) : il + /// est porté par le chemin de délégation, présent ici pour compléter le + /// vocabulaire et le rendre explicite. + ExplicitReply, + /// Le sigil de prompt PTY (repli n°3) est apparu. Idem : non produit par + /// `classify`, présent pour nommer le signal de repli legacy. + PromptReady, + /// L'agent semble **bloqué** (aucune preuve de vivacité depuis un seuil). Place + /// réservée au **lot 2** — non produit dans ce lot. + Stalled, + /// Le garde-fou de durée de tour a expiré. Place réservée au **lot 2** — non + /// produit dans ce lot. + TimedOut, +} + +/// Politique **pure** de classification d'un événement de tour en +/// [`ReadinessSignal`]. Sans état, sans I/O : un simple `match` sur le contrat de +/// port universel [`ReplyEvent`], pour que la décision « ce tour est-il fini ? » +/// vive dans le **domaine** et reste testable sans process ni réseau. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ReadinessPolicy; + +impl ReadinessPolicy { + /// Classe un [`ReplyEvent`] en signal de readiness. + /// + /// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul + /// événement terminal, il signe la fin de tour déterministe. + /// - [`ReplyEvent::TextDelta`] / [`ReplyEvent::ToolActivity`] / + /// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux + /// continue). Un heartbeat prouve la vivacité mais ne termine pas le tour. + #[must_use] + pub const fn classify(event: &ReplyEvent) -> Option { + match event { + ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded), + ReplyEvent::TextDelta { .. } + | ReplyEvent::ToolActivity { .. } + | ReplyEvent::Heartbeat => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn final_classifies_as_turn_ended() { + let ev = ReplyEvent::Final { + content: "fini".to_owned(), + }; + assert_eq!( + ReadinessPolicy::classify(&ev), + Some(ReadinessSignal::TurnEnded) + ); + } + + #[test] + fn deltas_activities_and_heartbeats_are_non_terminal() { + assert_eq!( + ReadinessPolicy::classify(&ReplyEvent::TextDelta { text: "x".into() }), + None + ); + assert_eq!( + ReadinessPolicy::classify(&ReplyEvent::ToolActivity { label: "lit".into() }), + None + ); + assert_eq!( + ReadinessPolicy::classify(&ReplyEvent::Heartbeat), + None, + "un heartbeat prouve la vivacité mais ne termine JAMAIS le tour" + ); + } +} diff --git a/crates/domain/src/sandbox.rs b/crates/domain/src/sandbox.rs new file mode 100644 index 0000000..9af337e --- /dev/null +++ b/crates/domain/src/sandbox.rs @@ -0,0 +1,715 @@ +//! OS-sandbox **contract & planning** (lot LP4-0, domaine pur). +//! +//! This module owns the *declarative plan* an OS sandbox adapter applies, plus +//! the **pure** translation from the resolved [`EffectivePermissions`] into that +//! plan. It is the domain half of the «sandbox OS» concern that the permission +//! model (`permission.rs`) deliberately left out of scope. +//! +//! Like the rest of `domain`, it is **pure** (ARCHITECTURE dependency rule): no +//! `tokio`, no `std::fs`, no `std::process`, no `landlock`. The concrete Landlock +//! adapter (lot LP4-1) and the launch-path wiring (lot LP4-2) live in +//! `infrastructure`/`application`; here we only define the contract +//! ([`SandboxEnforcer`], [`SandboxPlan`]) and compute the plan +//! ([`compile_sandbox_plan`]). +//! +//! ## Reality bound: Landlock locks **files only** +//! +//! Landlock can only restrict **filesystem** access. The command capability +//! ([`crate::permission::Capability::ExecuteBash`]) cannot be enforced by the OS +//! sandbox — argv matching is not a kernel concept — so command rules stay +//! **advisory** (honoured by the CLI's own prompting via the LP3 projection), +//! never OS-locked. [`compile_sandbox_plan`] therefore only ever derives grants +//! from the three **file** capabilities. +//! +//! ## The fail-closed, per-access-class translation invariant +//! +//! Landlock is **additive only**: a grant opens a whole directory subtree and a +//! sub-path cannot be carved back out. So whenever a `Deny` would fall inside (or +//! over) an `Allow`'s granted root without a directory boundary cleanly +//! separating them, we **drop the allow** rather than grant a root that would +//! leak the denied path. We always prefer losing an allow to leaking a deny — a +//! conservative under-approximation. +//! +//! Crucially this is computed **per access class** (read vs write/delete), never +//! capability-blind: a `Deny Write` fences only the write grants, it never +//! amputates a `Read` allow. This maximises agent autonomy — over-restricting a +//! read because some write was denied would force the agent to keep asking, the +//! opposite of the product goal. It is the same per-`capability`+target deny-wins +//! as [`crate::permission`], lifted to the directory granularity Landlock works +//! at and made fail-closed within each class. Encoded as a testable invariant in +//! [`compile_sandbox_plan`]. + +use serde::{Deserialize, Serialize}; + +use crate::permission::{Capability, Effect, EffectivePermissions, Posture}; + +/// The set of filesystem accesses a [`PathGrant`] opens on its root, as an +/// additive bit set. +/// +/// Hand-rolled (no external `bitflags` crate — the domain forbids extra deps). +/// The three bits are **independent**: [`PathAccess::RW`] does not imply +/// [`PathAccess::RO`]; a grant carries exactly the accesses that were posed. +/// +/// Capability → bit mapping used by [`compile_sandbox_plan`]: +/// [`Capability::Read`] ⇒ [`PathAccess::RO`], [`Capability::Write`] and +/// [`Capability::Delete`] ⇒ [`PathAccess::RW`]. [`PathAccess::EXEC`] is reserved +/// for the adapter/future use — the current permission model carries no file +/// execute capability, so the compiler never emits it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PathAccess(u8); + +impl PathAccess { + /// Read access. + pub const RO: Self = Self(0b001); + /// Write access (create / modify / delete). + pub const RW: Self = Self(0b010); + /// Execute access (reserved; not emitted by [`compile_sandbox_plan`]). + pub const EXEC: Self = Self(0b100); + + /// The empty access set. + #[must_use] + pub const fn empty() -> Self { + Self(0) + } + + /// Whether `self` contains **all** bits of `other`. + #[must_use] + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } + + /// The union of `self` and `other`. + #[must_use] + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + /// Adds the bits of `other` to `self` in place. + pub fn insert(&mut self, other: Self) { + self.0 |= other.0; + } + + /// Whether no bit is set. + #[must_use] + pub const fn is_empty(self) -> bool { + self.0 == 0 + } + + /// The raw bits. + #[must_use] + pub const fn bits(self) -> u8 { + self.0 + } +} + +impl std::ops::BitOr for PathAccess { + type Output = Self; + fn bitor(self, rhs: Self) -> Self { + self.union(rhs) + } +} + +impl std::ops::BitOrAssign for PathAccess { + fn bitor_assign(&mut self, rhs: Self) { + self.insert(rhs); + } +} + +/// One granted directory/file root and the accesses it opens. +/// +/// `abs_root` is an **absolute** path (the project root joined with the glob's +/// static prefix). The adapter applies it as a Landlock `path_beneath` rule. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PathGrant { + /// Absolute root the grant opens (file or directory subtree). + pub abs_root: String, + /// Accesses opened on that root. + pub access: PathAccess, +} + +/// The declarative, OS-neutral plan an OS sandbox adapter enforces. +/// +/// It is a **value** (a plan), never an action: the adapter ([`SandboxEnforcer`]) +/// turns it into a concrete ruleset. `allowed` may legally be empty (a policy that +/// posed a posture but no file allow) — that is **not** the same as «no plan»; +/// `compile_sandbox_plan` returns `None` for «no plan». +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxPlan { + /// The granted roots (deduplicated, deterministic order). + pub allowed: Vec, + /// The default posture for paths matched by no grant (mirrors + /// [`EffectivePermissions::fallback`]). + pub default_posture: Posture, +} + +/// Immutable inputs [`compile_sandbox_plan`] interpolates into the absolute roots +/// it emits. Borrowed: a plan is computed at the launch site, never stored. +pub struct SandboxContext<'a> { + /// Absolute project root (glob static prefixes are joined onto this). + pub project_root: &'a str, + /// Absolute isolated run dir of the agent (`.ideai/run//`). + /// + /// Reserved for the adapter (the run dir must stay reachable by the agent); + /// the pure file-rule translation does not consume it. + pub run_dir: &'a str, +} + +/// Which concrete OS-sandbox mechanism an [`SandboxEnforcer`] implements. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SandboxKind { + /// Linux Landlock LSM. + Landlock, + /// No OS sandbox available on this platform/build. + Unsupported, +} + +/// The outcome of applying a [`SandboxPlan`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SandboxStatus { + /// The plan was fully enforced by the OS. + Enforced, + /// No OS sandbox is available; nothing was enforced (the caller keeps the + /// advisory LP3 projection as its only guard). + Unsupported, + /// The plan was only **partially** enforced; the string explains what was + /// dropped (e.g. an older Landlock ABI lacking a needed access right). + Degraded(String), +} + +/// Errors an [`SandboxEnforcer::enforce`] may return. +/// +/// An adapter raises these only for **fail-closed** situations it must surface to +/// the caller (kernel too old when a `Deny` posture demands enforcement, ruleset +/// application failure). A platform that simply has no sandbox does **not** error: +/// it returns [`SandboxStatus::Unsupported`]. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum SandboxError { + /// The running kernel / Landlock ABI is too old to honour a plan that must be + /// enforced (fail-closed: a required `Deny` cannot be guaranteed). + #[error("kernel/landlock too old to enforce sandbox: {0}")] + KernelTooOld(String), + /// Building or applying the OS ruleset failed. + #[error("failed to apply sandbox ruleset: {0}")] + Ruleset(String), +} + +/// The OS-sandbox **port**: applies a [`SandboxPlan`] to the current process. +/// +/// ## Liskov contract +/// +/// `enforce` is a one-shot, **irreversible** tightening of the *current* process +/// and is meant to be called **after `fork`, before `exec`, in the child** — never +/// on the IdeA process itself. Every implementation must honour: +/// +/// - it only ever **removes** access (a sandbox never grants more than the host); +/// - an [`SandboxKind::Unsupported`] adapter is a valid no-op: its `enforce` +/// returns `Ok(`[`SandboxStatus::Unsupported`]`)` and changes nothing; +/// - `enforce` is total for any well-formed [`SandboxPlan`]; it returns +/// [`SandboxError`] only for the fail-closed cases above. +pub trait SandboxEnforcer: Send + Sync { + /// Which mechanism this enforcer implements. + fn kind(&self) -> SandboxKind; + + /// Applies `plan` to the current process (post-fork / pre-exec, in the child). + /// + /// # Errors + /// [`SandboxError`] only for fail-closed situations (cf. the type docs); an + /// absent sandbox returns `Ok(`[`SandboxStatus::Unsupported`]`)`. + fn enforce(&self, plan: &SandboxPlan) -> Result; +} + +/// Compiles the resolved [`EffectivePermissions`] into a [`SandboxPlan`]. +/// +/// **Pure**: only computes a plan; writes nothing, touches no I/O. +/// +/// ## Invariants +/// +/// 1. **`eff == None ⇒ None`** — nothing posed ⇒ no sandbox ⇒ the agent runs +/// natively (mirrors [`crate::permission::resolve`]'s product invariant). A +/// `Some` result (even with an empty `allowed`) means IdeA has a policy to +/// enforce. +/// 2. Only the three **file** capabilities feed the grants; bash rules are +/// skipped (Landlock cannot lock command execution — they stay advisory). +/// 3. **Per-access-class, fail-closed roots**: grants are computed **separately +/// for each access class** so an agent keeps the widest autonomy possible. +/// - The **RO** class is fed by `Allow Read`; its fences are `Deny Read`. +/// - The **RW** class is fed by `Allow Write` **and** `Allow Delete`; its +/// fences are `Deny Write` **and** `Deny Delete`. +/// +/// Within a class, each `Allow` glob is reduced to its *static prefix* root and +/// **dropped** if a fence **of the same class** overlaps it (equal, ancestor, or +/// descendant) — an additive sandbox cannot carve a sub-path deny out of a +/// granted subtree, so we lose the allow rather than leak the deny. A fence of +/// the **other** class has **no effect**: a `Deny Write` never amputates an +/// `Allow Read` (that would over-restrict and force the agent to keep asking). +/// This is exactly the per-`capability`+target deny-wins of +/// [`crate::permission`], applied at the (coarser) directory granularity Landlock +/// allows, fail-closed inside each class. +/// +/// The surviving roots of both classes are then merged by `abs_root`, unioning +/// their accesses: a root surviving only in RO ⇒ [`PathAccess::RO`]; surviving +/// in both ⇒ `RO | RW`; surviving only in RW ⇒ [`PathAccess::RW`]. +#[must_use] +pub fn compile_sandbox_plan( + eff: Option<&EffectivePermissions>, + ctx: &SandboxContext, +) -> Option { + let eff = eff?; + + // 1. Collect the static-prefix fences of every Deny file rule, **per access + // class** (RO = Deny Read; RW = Deny Write/Delete). A fence only ever + // blocks allows of its own class. Kept relative for the overlap tests. + let mut ro_fences: Vec = Vec::new(); + let mut rw_fences: Vec = Vec::new(); + for rule in eff.rules() { + if rule.effect() != Effect::Deny { + continue; + } + let fences = match access_class(rule.capability()) { + Some(PathAccess::RO) => &mut ro_fences, + Some(PathAccess::RW) => &mut rw_fences, + // Non-file (ExecuteBash) ⇒ no class ⇒ never a filesystem fence. + _ => continue, + }; + for glob in rule.paths().globs() { + fences.push(static_prefix(glob.pattern())); + } + } + + // 2. For each Allow file rule, reduce every glob to its static-prefix root and + // keep it only if no same-class fence overlaps it. Merge by relative root, + // unioning the access bits across classes. + let mut grants: Vec<(String, PathAccess)> = Vec::new(); + for rule in eff.rules() { + if rule.effect() != Effect::Allow { + continue; + } + let Some(access) = access_class(rule.capability()) else { + continue; + }; + let fences: &[String] = if access == PathAccess::RO { + &ro_fences + } else { + &rw_fences + }; + for glob in rule.paths().globs() { + let rel = static_prefix(glob.pattern()); + if fences.iter().any(|d| paths_conflict(&rel, d)) { + // A same-class deny falls inside/over this root ⇒ we cannot grant + // it for this class without leaking the deny. + continue; + } + match grants.iter_mut().find(|(r, _)| *r == rel) { + Some((_, acc)) => acc.insert(access), + None => grants.push((rel, access)), + } + } + } + + // 3. Join the surviving relative roots onto the absolute project root. + let base = ctx.project_root.trim_end_matches('/'); + let allowed = grants + .into_iter() + .map(|(rel, access)| PathGrant { + abs_root: join_root(base, &rel), + access, + }) + .collect(); + + Some(SandboxPlan { + allowed, + default_posture: eff.fallback(), + }) +} + +/// The access **class** a file capability belongs to: [`Capability::Read`] ⇒ +/// [`PathAccess::RO`]; [`Capability::Write`]/[`Capability::Delete`] ⇒ +/// [`PathAccess::RW`]. Non-file capabilities ([`Capability::ExecuteBash`]) have no +/// class (`None`) — they never produce a filesystem grant or fence. +fn access_class(cap: Capability) -> Option { + match cap { + Capability::Read => Some(PathAccess::RO), + Capability::Write | Capability::Delete => Some(PathAccess::RW), + Capability::ExecuteBash => None, + } +} + +/// The **static prefix** of a glob: the leading literal directory path before the +/// first glob metacharacter (`*`, `?`, `[`), with any trailing `/` trimmed. +/// +/// - `"**"` / `"*.rs"` ⇒ `""` (the project root itself), +/// - `"src/**"` / `"src/*.rs"` ⇒ `"src"`, +/// - `"a/b/**/*.rs"` ⇒ `"a/b"`, +/// - `"src/foo.rs"` (no metachar) ⇒ `"src/foo.rs"` (the file itself). +fn static_prefix(pattern: &str) -> String { + let first_meta = pattern.find(['*', '?', '[']); + let literal = match first_meta { + // Cut back to the last '/' *before* the metacharacter: everything up to + // and including that slash is a settled directory path. + Some(idx) => match pattern[..idx].rfind('/') { + Some(slash) => &pattern[..slash], + None => "", + }, + // No metacharacter: the whole pattern is a literal path. + None => pattern, + }; + literal.trim_end_matches('/').to_string() +} + +/// Whether two project-relative roots overlap such that an additive sandbox could +/// not keep them apart: they are equal, or one is a path-ancestor of the other. +fn paths_conflict(a: &str, b: &str) -> bool { + a == b || is_descendant(a, b) || is_descendant(b, a) +} + +/// Whether `child` is a **strict** path-descendant of `ancestor` (component +/// boundary aware). The empty string denotes the project root, an ancestor of +/// every non-empty path. +fn is_descendant(child: &str, ancestor: &str) -> bool { + if ancestor.is_empty() { + return !child.is_empty(); + } + child.len() > ancestor.len() + && child.starts_with(ancestor) + && child.as_bytes()[ancestor.len()] == b'/' +} + +/// Joins a relative root onto the absolute base. An empty `rel` denotes the base +/// itself. +fn join_root(base: &str, rel: &str) -> String { + if rel.is_empty() { + base.to_string() + } else { + format!("{base}/{rel}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::permission::{PathScope, PermissionRule, PermissionSet, resolve}; + + // ---- helpers --------------------------------------------------------- + + const ROOT: &str = "/home/anthony/proj"; + + fn ctx() -> SandboxContext<'static> { + SandboxContext { + project_root: ROOT, + run_dir: "/home/anthony/proj/.ideai/run/agent-x", + } + } + + fn scope(patterns: &[&str]) -> PathScope { + PathScope::new(patterns.iter().map(|s| s.to_string())).unwrap() + } + + fn file_rule(cap: Capability, effect: Effect, patterns: &[&str]) -> PermissionRule { + PermissionRule::file(cap, effect, scope(patterns)).unwrap() + } + + /// Resolves a single-set policy into `EffectivePermissions` (project set only). + fn eff(rules: Vec, fallback: Posture) -> EffectivePermissions { + let set = PermissionSet::new(rules, fallback); + resolve(Some(&set), None).unwrap() + } + + fn grant<'a>(plan: &'a SandboxPlan, abs_root: &str) -> Option<&'a PathGrant> { + plan.allowed.iter().find(|g| g.abs_root == abs_root) + } + + // ---- invariant 1: presence of a policy != non-empty grants ---------- + + #[test] + fn none_eff_yields_no_plan() { + // Nothing posed ⇒ no sandbox ⇒ the agent runs natively. + assert!(compile_sandbox_plan(None, &ctx()).is_none()); + } + + #[test] + fn policy_with_no_allow_still_yields_some_plan() { + // A posed policy with an empty `allowed` is NOT the same as "no plan": + // it must compile to `Some` with an empty grant list. + let e = eff(vec![], Posture::Deny); + let plan = compile_sandbox_plan(Some(&e), &ctx()).expect("a posed policy yields a plan"); + assert!( + plan.allowed.is_empty(), + "no allow rule ⇒ no grant, but still a plan" + ); + + // Same with only a Deny file rule present (still a policy, still no grant). + let e = eff( + vec![file_rule(Capability::Write, Effect::Deny, &["secret/**"])], + Posture::Ask, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).expect("deny-only is still a plan"); + assert!(plan.allowed.is_empty()); + } + + // ---- invariant 2: capability → access mapping; bash never a grant ---- + + #[test] + fn read_maps_to_ro() { + let e = eff( + vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])], + Posture::Ask, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); + let g = grant(&plan, "/home/anthony/proj/src").expect("src granted"); + assert_eq!(g.access, PathAccess::RO); + assert!(!g.access.contains(PathAccess::RW)); + } + + #[test] + fn write_and_delete_map_to_rw() { + let e = eff( + vec![ + file_rule(Capability::Write, Effect::Allow, &["out/**"]), + file_rule(Capability::Delete, Effect::Allow, &["tmp/**"]), + ], + Posture::Ask, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); + assert_eq!( + grant(&plan, "/home/anthony/proj/out").unwrap().access, + PathAccess::RW + ); + assert_eq!( + grant(&plan, "/home/anthony/proj/tmp").unwrap().access, + PathAccess::RW + ); + } + + #[test] + fn bash_only_policy_produces_no_path_grant() { + // ExecuteBash is never translated to a PathGrant (Landlock = files only). + let e = eff( + vec![ + PermissionRule::bash(Effect::Allow, vec![]), + PermissionRule::bash( + Effect::Deny, + vec![crate::permission::CommandRule::new( + crate::permission::CommandMatcher::prefix("rm ").unwrap(), + Effect::Deny, + )], + ), + ], + Posture::Allow, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); + assert!( + plan.allowed.is_empty(), + "a purely-bash policy yields zero PathGrant" + ); + } + + // ---- invariant 3: fail-closed glob → static-prefix translation ------- + + #[test] + fn root_glob_with_same_class_deny_drops_root_grant() { + // Allow Read `**` (root) + a single SAME-CLASS Deny Read file ⇒ the RO root + // grant is abandoned: an additive sandbox cannot carve the denied file back + // out of the granted subtree (fail-closed within the RO class). + let e = eff( + vec![ + file_rule(Capability::Read, Effect::Allow, &["**"]), + file_rule(Capability::Read, Effect::Deny, &["secret.txt"]), + ], + Posture::Ask, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); + assert!( + plan.allowed.is_empty(), + "a same-class deny under the root fence drops the root grant" + ); + } + + #[test] + fn descendant_same_class_deny_drops_overlapping_allow() { + // Allow Read `src/**` (root `src`) + Deny Read `src/secret/**` + // (root `src/secret`): the same-class deny is a descendant of the granted + // root ⇒ the RO grant on `src` is dropped (fail-closed within RO). + let e = eff( + vec![ + file_rule(Capability::Read, Effect::Allow, &["src/**"]), + file_rule(Capability::Read, Effect::Deny, &["src/secret/**"]), + ], + Posture::Ask, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); + assert!( + grant(&plan, "/home/anthony/proj/src").is_none(), + "an inside same-class deny abandons the enclosing allow" + ); + } + + #[test] + fn other_class_deny_does_not_amputate_read_allow() { + // The DUAL of the case above and the key autonomy guarantee: a Deny of a + // DIFFERENT class (Write) over a descendant must NOT touch the Read allow. + // `Allow Read src/**` + `Deny Write src/secret/**` ⇒ `src` keeps its RO. + let e = eff( + vec![ + file_rule(Capability::Read, Effect::Allow, &["src/**"]), + file_rule(Capability::Write, Effect::Deny, &["src/secret/**"]), + ], + Posture::Ask, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); + assert_eq!( + grant(&plan, "/home/anthony/proj/src").map(|g| g.access), + Some(PathAccess::RO), + "a Deny Write must never amputate an Allow Read (per-class autonomy)" + ); + } + + #[test] + fn disjoint_same_class_deny_keeps_allow() { + // Allow Read `src/**` + SAME-CLASS Deny Read `other/**`: disjoint roots ⇒ + // grant `src` kept. Same-class fence so the test proves disjointness, not + // merely a class mismatch. + let e = eff( + vec![ + file_rule(Capability::Read, Effect::Allow, &["src/**"]), + file_rule(Capability::Read, Effect::Deny, &["other/**"]), + ], + Posture::Ask, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); + assert_eq!( + grant(&plan, "/home/anthony/proj/src").unwrap().access, + PathAccess::RO, + "a disjoint same-class deny does not touch the allow" + ); + } + + #[test] + fn ancestor_same_class_deny_also_drops_allow() { + // Deny Read `src/**` (root `src`) is an ancestor of Allow Read `src/sub/**` + // (root `src/sub`) ⇒ same-class overlap ⇒ allow dropped (fence symmetry). + let e = eff( + vec![ + file_rule(Capability::Read, Effect::Allow, &["src/sub/**"]), + file_rule(Capability::Read, Effect::Deny, &["src/**"]), + ], + Posture::Ask, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); + assert!(grant(&plan, "/home/anthony/proj/src/sub").is_none()); + } + + #[test] + fn sibling_prefix_is_not_a_descendant() { + // Component-boundary awareness: `src2` must NOT be treated as inside `src` + // just because the string `src` is a prefix of `src2`. Uses a SAME-CLASS + // (Deny Read) fence so the survival proves boundary-awareness, not a class + // mismatch. + let e = eff( + vec![ + file_rule(Capability::Read, Effect::Allow, &["src2/**"]), + file_rule(Capability::Read, Effect::Deny, &["src/**"]), + ], + Posture::Ask, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); + assert!( + grant(&plan, "/home/anthony/proj/src2").is_some(), + "`src2` is a sibling of `src`, not a descendant" + ); + } + + #[test] + fn accesses_union_on_a_shared_root() { + // Read + Write allows on the same root merge into RO|RW on one grant. + let e = eff( + vec![ + file_rule(Capability::Read, Effect::Allow, &["src/**"]), + file_rule(Capability::Write, Effect::Allow, &["src/**"]), + ], + Posture::Ask, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); + let g = grant(&plan, "/home/anthony/proj/src").expect("single merged grant"); + assert!(g.access.contains(PathAccess::RO)); + assert!(g.access.contains(PathAccess::RW)); + assert_eq!( + plan.allowed + .iter() + .filter(|g| g.abs_root == "/home/anthony/proj/src") + .count(), + 1, + "the two allows merge onto one root, not two grants" + ); + } + + #[test] + fn same_root_drops_rw_but_keeps_ro_under_a_write_deny() { + // Direct proof of per-access-class granularity on a single root: + // `Allow Read src/**` + `Allow Write src/**` + `Deny Write src/secret/**`. + // The RW class is fenced (deny descendant) ⇒ RW dropped; the RO class has no + // fence ⇒ RO survives. The grant on `src` ends up RO-only. + let e = eff( + vec![ + file_rule(Capability::Read, Effect::Allow, &["src/**"]), + file_rule(Capability::Write, Effect::Allow, &["src/**"]), + file_rule(Capability::Write, Effect::Deny, &["src/secret/**"]), + ], + Posture::Ask, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); + let g = grant(&plan, "/home/anthony/proj/src").expect("RO survives the write deny"); + assert_eq!( + g.access, + PathAccess::RO, + "RW class fenced out, RO class preserved on the same root" + ); + assert!(!g.access.contains(PathAccess::RW), "the RW grant was dropped"); + } + + #[test] + fn static_prefix_of_literal_file_is_the_file_itself() { + // A metacharacter-free allow grants exactly that file path. + let e = eff( + vec![file_rule(Capability::Read, Effect::Allow, &["src/lib.rs"])], + Posture::Ask, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); + assert!(grant(&plan, "/home/anthony/proj/src/lib.rs").is_some()); + } + + // ---- invariant 4: residual posture reflected in the plan ------------- + + #[test] + fn default_posture_mirrors_resolved_fallback() { + for posture in [Posture::Allow, Posture::Ask, Posture::Deny] { + let e = eff( + vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])], + posture, + ); + let plan = compile_sandbox_plan(Some(&e), &ctx()).unwrap(); + assert_eq!(plan.default_posture, posture); + } + } + + #[test] + fn trailing_slash_on_project_root_is_normalised() { + let e = eff( + vec![file_rule(Capability::Read, Effect::Allow, &["src/**"])], + Posture::Ask, + ); + let ctx = SandboxContext { + project_root: "/home/anthony/proj/", + run_dir: "/x", + }; + let plan = compile_sandbox_plan(Some(&e), &ctx).unwrap(); + assert!( + grant(&plan, "/home/anthony/proj/src").is_some(), + "the trailing slash must not produce `//`" + ); + } +} diff --git a/crates/domain/tests/agent_profile_a0.rs b/crates/domain/tests/agent_profile_a0.rs index 83e50de..4593a08 100644 --- a/crates/domain/tests/agent_profile_a0.rs +++ b/crates/domain/tests/agent_profile_a0.rs @@ -194,13 +194,19 @@ fn leaf_returns_none_for_grid_node_id() { // L'id de la grille n'est pas une feuille. assert!(grid.leaf(node(50)).is_none()); // La feuille imbriquée est bien retrouvée. - assert_eq!(*grid.leaf(node(3)).expect("nested leaf"), leaf_cell(3, None)); + assert_eq!( + *grid.leaf(node(3)).expect("nested leaf"), + leaf_cell(3, None) + ); } #[test] fn leaf_finds_single_root_leaf() { let tree = LayoutTree::single(leaf_cell(7, Some(70))); - assert_eq!(*tree.leaf(node(7)).expect("root leaf"), leaf_cell(7, Some(70))); + assert_eq!( + *tree.leaf(node(7)).expect("root leaf"), + leaf_cell(7, Some(70)) + ); assert!(tree.leaf(node(8)).is_none()); } diff --git a/crates/domain/tests/layout.rs b/crates/domain/tests/layout.rs index 875c24a..18d4d85 100644 --- a/crates/domain/tests/layout.rs +++ b/crates/domain/tests/layout.rs @@ -837,12 +837,7 @@ fn move_session_preserves_resume_fields_on_both_leaves() { // --------------------------------------------------------------------------- /// Builds an agent-bearing leaf with explicit resume signals. -fn agent_leaf_full( - id: u128, - agent: u128, - conv: Option<&str>, - running: bool, -) -> LeafCell { +fn agent_leaf_full(id: u128, agent: u128, conv: Option<&str>, running: bool) -> LeafCell { LeafCell { id: node(id), session: None, diff --git a/crates/domain/tests/structured_session_d0.rs b/crates/domain/tests/structured_session_d0.rs index ab9a95f..ea136b7 100644 --- a/crates/domain/tests/structured_session_d0.rs +++ b/crates/domain/tests/structured_session_d0.rs @@ -150,7 +150,9 @@ fn profile_with_adapter_roundtrips_and_uses_camel_case() { #[test] fn with_structured_adapter_sets_only_that_field() { let before = pty_profile(); - let after = before.clone().with_structured_adapter(StructuredAdapter::Codex); + let after = before + .clone() + .with_structured_adapter(StructuredAdapter::Codex); // Le seul champ muté : assert_eq!(after.structured_adapter, Some(StructuredAdapter::Codex)); @@ -275,7 +277,10 @@ fn agent_session_error_is_std_error_and_equates() { AgentSessionError::Start("a".into()), AgentSessionError::Start("b".into()) ); - assert_ne!(AgentSessionError::Timeout, AgentSessionError::Io("t".into())); + assert_ne!( + AgentSessionError::Timeout, + AgentSessionError::Io("t".into()) + ); } // --------------------------------------------------------------------------- @@ -335,6 +340,7 @@ impl AgentSessionFactory for FakeFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { Ok(Arc::new(FakeSession { id: SessionId::from_uuid(Uuid::from_u128(7)), @@ -389,7 +395,7 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() { }; let cwd = ProjectPath::new("/srv/run").unwrap(); let session = factory - .start(&structured, &ctx, &cwd, &SessionPlan::None) + .start(&structured, &ctx, &cwd, &SessionPlan::None, None) .await .expect("factory starts a session"); assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7))); diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index 52e05bc..bbfa886 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -22,6 +22,10 @@ serde = { workspace = true } serde_json = { workspace = true } portable-pty = "0.9" git2 = { workspace = true } + +# OS sandbox (lot LP4-1) — Linux Landlock LSM, best-effort/ABI-compat. Linux-only +# target dep so the Windows/macOS builds (and the Noop fallback) compile nothing +# extra. `landlock` is a thin, dependency-light wrapper over the kernel ABI. # Filesystem change notifications used to *wake* the orchestrator poll loop early # (the poll loop remains the robust cross-platform correctness guarantee). notify = "6" @@ -36,6 +40,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus # `vector-onnx` feature; the zero-dependency default compiles nothing extra. fastembed = { version = "5", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"], optional = true } +[target.'cfg(target_os = "linux")'.dependencies] +landlock = "0.4.5" + [features] # Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…). # OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency. diff --git a/crates/infrastructure/src/conversation_log/handoff.rs b/crates/infrastructure/src/conversation_log/handoff.rs index 44d9a98..7a5c2ac 100644 --- a/crates/infrastructure/src/conversation_log/handoff.rs +++ b/crates/infrastructure/src/conversation_log/handoff.rs @@ -191,11 +191,7 @@ impl HandoffStore for FsHandoffStore { deserialize(&content).map(Some) } - async fn save( - &self, - conversation: ConversationId, - handoff: Handoff, - ) -> Result<(), StoreError> { + async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> { let body = serialize(&handoff); let dir = self.conversation_dir(conversation); diff --git a/crates/infrastructure/src/fs/mod.rs b/crates/infrastructure/src/fs/mod.rs index 5197cb0..2f3514d 100644 --- a/crates/infrastructure/src/fs/mod.rs +++ b/crates/infrastructure/src/fs/mod.rs @@ -54,6 +54,15 @@ impl FileSystem for LocalFileSystem { } } + async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> { + match fs::remove_file(path.as_str()).await { + Ok(()) => Ok(()), + // Idempotent best-effort delete: an already-absent file is success. + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(map_io(path, &e)), + } + } + async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { fs::create_dir_all(path.as_str()) .await diff --git a/crates/infrastructure/src/input/mod.rs b/crates/infrastructure/src/input/mod.rs index 7e26fdf..5198ebc 100644 --- a/crates/infrastructure/src/input/mod.rs +++ b/crates/infrastructure/src/input/mod.rs @@ -25,8 +25,8 @@ use std::sync::{Arc, Mutex}; use domain::events::DomainEvent; use domain::ids::AgentId; -use domain::input::{AgentBusyState, InputMediator}; -use domain::mailbox::{AgentMailbox, PendingReply, Ticket}; +use domain::input::{AgentBusyState, AgentLiveness, InputMediator, SubmitConfig}; +use domain::mailbox::{AgentMailbox, PendingReply, Ticket, TicketId}; use domain::ports::{EventBus, PtyHandle, PtyPort}; use crate::mailbox::InMemoryMailbox; @@ -40,23 +40,212 @@ use crate::mailbox::InMemoryMailbox; /// every path (explicit `mark_idle`, prompt-ready match) stays consistent. struct BusyTracker { busy: Mutex>, + /// Per-agent **liveness** bookkeeping (lot 2) : dernier battement observé, seuil de + /// stagnation issu du profil, et état de vivacité courant pour n'émettre + /// `AgentLivenessChanged` qu'**une fois par transition** (pas de spam). + liveness: Mutex>, + /// **Démarrage à froid** (fix race cold-launch) : ensemble des agents fraîchement + /// lancés à froid pour lesquels la livraison du **premier** tour doit être *gatée* + /// sur le prompt-ready watcher. Un agent y est inscrit par + /// [`BusyTracker::mark_starting`] (uniquement quand un watcher est effectivement + /// armé), puis consommé par l'`enqueue` qui démarre le tour : la `DelegationReady` + /// est alors **différée** dans `deferred` au lieu d'être publiée immédiatement (le + /// CLI n'a pas encore affiché son prompt). Vide ⇒ comportement chaud inchangé. + starting: Mutex>, + /// **Tour différé** (fix race cold-launch) : payload de la `DelegationReady` retenue + /// pour un démarrage à froid, publiée par [`BusyTracker::prompt_ready`] à l'apparition + /// du prompt (jamais avant). Absent ⇒ aucun tour en attente de gate. + deferred: Mutex>, events: Option>, + /// Sink de livraison headless (cf. [`HeadlessSink`]). Câblé par + /// [`MediatedInbox::with_pty`]/[`MediatedInbox::with_events`] quand un PTY est + /// présent ; `None` ⇒ toute livraison passe par l'événement (médiateur sans PTY, + /// utilisé par les tests événementiels — comportement historique). + headless_sink: Option, +} + +/// Payload d'une [`DomainEvent::DelegationReady`] **différée** le temps qu'un agent +/// lancé à froid atteigne son prompt (fix race cold-launch). Reconstruite à l'identique +/// quand le prompt-ready watcher déclenche, de sorte que le premier tour soit livré au +/// bon moment (et un seul `DelegationReady`, comme pour un agent chaud). +#[derive(Debug, Clone)] +struct DeferredDelegation { + ticket: TicketId, + text: String, + submit_sequence: Option, + submit_delay_ms: Option, +} + +/// Sink de livraison « headless » optionnel branché sur le [`BusyTracker`]. +/// +/// Appelé pour **chaque** tour à livrer (chemin chaud immédiat comme drains à froid). +/// Reçoit l'agent + le payload de délégation et décide : +/// - `None` ⇒ il a **pris en charge** la livraison (écriture directe dans le PTY d'un +/// agent headless qui n'a pas de cellule frontend) ; +/// - `Some(d)` ⇒ il **rend la main** : le tracker publie alors le `DelegationReady` +/// normal (cellule frontend présente ⇒ c'est le write-portal qui écrira, ou pas de +/// PTY/handle disponible ⇒ repli sur l'événement, comportement historique). +type HeadlessSink = + Arc Option + Send + Sync>; + +/// Délai (ms) entre l'écriture du texte de la tâche et celle de la séquence de +/// soumission lors d'une livraison **headless** (le médiateur écrit lui-même le PTY). +/// Aligné sur le défaut du write-portal frontend (`DEFAULT_SUBMIT_DELAY_MS`) : sépare +/// la soumission du collage pour esquiver la paste-detection des CLI. Utilisé seulement +/// quand le profil de la cible ne fournit pas de `submit_delay_ms`. +const DEFAULT_SUBMIT_DELAY_MS: u32 = 60; + +/// État de vivacité d'un agent maintenu par le [`BusyTracker`] (lot 2). +/// +/// Pur (aucune I/O) : un timestamp `last_seen_ms` rafraîchi à chaque battement, le +/// seuil `stall_after_ms` du profil (cf. [`domain::profile::LivenessStrategy`]) et +/// l'état courant `current` pour décider d'une **transition** (et donc d'un seul +/// événement). `stall_after_ms = None` ⇒ pas de détection (comportement legacy). +#[derive(Debug, Clone, Copy)] +struct LivenessState { + /// Epoch-millis du dernier battement (ou du démarrage du tour). + last_seen_ms: u64, + /// Seuil de stagnation du profil de l'agent. `None` ⇒ détection désactivée. + stall_after_ms: Option, + /// État de vivacité courant (pour n'émettre qu'à la transition). + current: AgentLiveness, } impl BusyTracker { fn new(events: Option>) -> Self { Self { busy: Mutex::new(HashMap::new()), + liveness: Mutex::new(HashMap::new()), + starting: Mutex::new(HashSet::new()), + deferred: Mutex::new(HashMap::new()), events, + headless_sink: None, } } + fn lock_starting(&self) -> std::sync::MutexGuard<'_, HashSet> { + self.starting + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn lock_deferred(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.deferred + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Marque un agent **en démarrage à froid** : son tout premier tour devra être + /// *gaté* sur le prompt-ready watcher (la `DelegationReady` sera différée à + /// l'apparition du prompt). À n'appeler **que** lorsqu'un watcher est effectivement + /// armé (un `prompt_ready_pattern` non vide est configuré), sinon le premier tour + /// resterait bloqué indéfiniment (aucun signal ne viendrait le libérer). Sans cet + /// appel, l'`enqueue` publie la `DelegationReady` immédiatement (chemin chaud, + /// zéro régression). + fn mark_starting(&self, agent: AgentId) { + self.lock_starting().insert(agent); + } + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { self.busy .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } + fn lock_liveness(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.liveness + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Publie un `AgentLivenessChanged` (si un bus est câblé). + fn publish_liveness(&self, agent: AgentId, liveness: AgentLiveness) { + if let Some(events) = &self.events { + events.publish(DomainEvent::AgentLivenessChanged { + agent_id: agent, + liveness, + }); + } + } + + /// Enregistre le seuil de stagnation du profil d'un agent au moment où son tour + /// démarre (depuis l'enqueue) : (re)initialise `last_seen` à `now` et repart d'un + /// état `Alive`. Sans seuil (`None`), l'entrée existe quand même mais le sweep ne + /// la déclarera jamais `Stalled` (zéro régression pour un profil sans liveness). + fn arm_liveness(&self, agent: AgentId, stall_after_ms: Option, now_ms: u64) { + self.lock_liveness().insert( + agent, + LivenessState { + last_seen_ms: now_ms, + stall_after_ms, + current: AgentLiveness::Alive, + }, + ); + } + + /// Rafraîchit le `last_seen` d'un agent (un **battement**) et, s'il était + /// `Stalled`, le ramène à `Alive` en émettant l'unique transition de reprise. No-op + /// si l'agent n'a pas d'entrée de vivacité armée (tour non structuré / legacy). + fn touch(&self, agent: AgentId, now_ms: u64) { + let recovered = { + let mut map = self.lock_liveness(); + let Some(state) = map.get_mut(&agent) else { + return; + }; + state.last_seen_ms = now_ms; + if state.current == AgentLiveness::Stalled { + state.current = AgentLiveness::Alive; + true + } else { + false + } + }; + if recovered { + self.publish_liveness(agent, AgentLiveness::Alive); + } + } + + /// Balaye tous les agents et, pour chacun dont le tour stagne + /// (`now - last_seen > stall_after_ms` et seuil défini), bascule `Alive→Stalled` + /// en émettant **une seule** transition. **Pure et testable sans horloge réelle** : + /// `now_ms` est passé en paramètre. Idempotente : un agent déjà `Stalled` ne ré-émet + /// pas. Un agent sans seuil (`None`) ou redevenu `Idle` n'est jamais déclaré stalled. + fn sweep_stalled(&self, now_ms: u64) { + let newly_stalled: Vec = { + let mut map = self.lock_liveness(); + map.iter_mut() + .filter_map(|(agent, state)| { + let threshold = state.stall_after_ms?; + if state.current != AgentLiveness::Alive { + return None; // déjà Stalled : pas de ré-émission. + } + let elapsed = now_ms.saturating_sub(state.last_seen_ms); + if elapsed > u64::from(threshold) { + state.current = AgentLiveness::Stalled; + Some(*agent) + } else { + None + } + }) + .collect() + }; + for agent in newly_stalled { + self.publish_liveness(agent, AgentLiveness::Stalled); + } + } + + /// Retire l'entrée de vivacité d'un agent (fin de tour). Si l'agent était `Stalled`, + /// la fin de tour est en soi un retour à `Alive` ⇒ on émet la transition de reprise. + fn clear_liveness(&self, agent: AgentId) { + let was_stalled = self + .lock_liveness() + .remove(&agent) + .is_some_and(|s| s.current == AgentLiveness::Stalled); + if was_stalled { + self.publish_liveness(agent, AgentLiveness::Alive); + } + } + fn busy_state(&self, agent: AgentId) -> AgentBusyState { self.lock() .get(&agent) @@ -77,6 +266,62 @@ impl BusyTracker { } } + /// Publie une [`DomainEvent::DelegationReady`] depuis un payload différé (si un bus + /// est câblé). Utilisée pour livrer le **premier** tour d'un agent froid au moment + /// où son prompt apparaît. + fn publish_deferred(&self, agent: AgentId, d: DeferredDelegation) { + // Point de livraison unique (tours chauds immédiats ET drains à froid). Si un + // sink headless est câblé, il a la priorité : pour un agent sans cellule + // frontend il écrit lui-même la tâche dans le PTY et renvoie `None` (pris en + // charge) ; sinon il renvoie `Some(d)` et on retombe sur l'événement. + let d = match &self.headless_sink { + Some(sink) => match sink(agent, d) { + Some(d) => d, + None => return, + }, + None => d, + }; + if let Some(events) = &self.events { + events.publish(DomainEvent::DelegationReady { + agent_id: agent, + ticket: d.ticket, + text: d.text, + submit_sequence: d.submit_sequence, + submit_delay_ms: d.submit_delay_ms, + }); + } + } + + /// Signal **prompt-ready** émis par le watcher à l'apparition du marqueur. + /// + /// - Si un tour de démarrage à froid est **différé** pour cet agent : c'est le + /// moment de le livrer ⇒ on publie la `DelegationReady` retenue et l'agent **reste + /// Busy** (son premier tour court désormais réellement). Le tour ne se terminera + /// que sur un `idea_reply` ou un prochain prompt-ready (qui, lui, fera `mark_idle`). + /// - Sinon : comportement historique ⇒ `mark_idle` (fait avancer la FIFO). + fn prompt_ready(&self, agent: AgentId) { + // On ne retire l'agent de `starting` qu'ici : tant que son premier tour n'a pas + // été livré, un re-arming éventuel doit rester gaté. + self.lock_starting().remove(&agent); + let deferred = self.lock_deferred().remove(&agent); + match deferred { + Some(d) => self.publish_deferred(agent, d), + None => self.mark_idle(agent), + } + } + + /// Libère un premier tour différé sur **readiness de démarrage** (connexion du pont + /// MCP de l'agent). Draine le `DeferredDelegation` retenu s'il existe (et retire + /// l'agent de `starting`) ; sinon no-op. **Pas de `mark_idle`** (signal de démarrage, + /// pas de fin de tour). Idempotent et OR-safe avec `prompt_ready` (le `remove` ne rend + /// `Some` qu'une fois). + fn release_cold_start(&self, agent: AgentId) { + self.lock_starting().remove(&agent); + if let Some(d) = self.lock_deferred().remove(&agent) { + self.publish_deferred(agent, d); + } + } + /// Marks `agent` `Idle`, publishing `AgentBusyChanged{busy:false}` only on a real /// `Busy→Idle` transition. Idempotent: a `mark_idle` on an already-idle agent is a /// no-op and emits nothing. @@ -94,6 +339,9 @@ impl BusyTracker { }); } } + // Fin de tour : retirer l'entrée de vivacité (émet la reprise si l'agent + // était `Stalled`). Indépendant de `was_busy` pour rester idempotent. + self.clear_liveness(agent); } } @@ -122,10 +370,13 @@ impl MillisClock for SystemMillisClock { /// In-memory mediated inbox: one FIFO per agent (the mailbox) plus busy state. /// -/// When a [`PtyPort`] is wired (cadrage C3 §5.2), `enqueue` **delivers** the turn — -/// it writes the prefixed task line into the agent's bound [`PtyHandle`]. This is the -/// single, serialized write path that replaces the orchestrator's former ad-hoc PTY -/// write (no more `[IdeA · tâche …]` line emitted from `ask_agent`, no `\r` band-aid). +/// Since ARCHITECTURE §20, `enqueue` **no longer writes the turn into the PTY** (the +/// `\n` band-aid is gone — it never submitted in raw mode and produced a "double chat"). +/// On the enqueue that starts a turn (Idle→Busy) it publishes a +/// [`DomainEvent::DelegationReady`] carrying the task text + ticket + the target's +/// submit config; the **frontend** write-portal owns the single physical PTY write. A +/// wired [`PtyPort`] is kept only to observe the output stream for prompt-ready +/// detection (lot C5) and to deliver the `preempt` interrupt byte. pub struct MediatedInbox { mailbox: Arc, /// Shared busy/idle authority (also handed to prompt-ready watcher threads, C5). @@ -135,7 +386,23 @@ pub struct MediatedInbox { /// observe an agent's output stream for prompt-ready detection (lot C5). pty: Option>, /// Per-agent live input handle (one stream per agent), fed by `bind_handle`. - handles: Mutex>, + /// `Arc` so the headless delivery sink (wired into the [`BusyTracker`]) can read it + /// to resolve an agent's PTY handle when it must write the turn itself. + handles: Arc>>, + /// Agents qui ont une **cellule terminal frontend montée** (write-portal actif), + /// tenu à jour par [`InputMediator::set_front_attached`]. Quand un agent y figure, + /// la livraison passe par l'événement `DelegationReady` (le front écrit) ; sinon + /// (agent headless / délégué en arrière-plan) le médiateur écrit lui-même le tour + /// dans le PTY. `Arc` car le sink headless du tracker le consulte. + front_owned: Arc>>, + /// Per-agent submit config (target profile's `submit_sequence`/`submit_delay_ms`), + /// stashed at bind time (§20.3) and echoed on the `DelegationReady` event when a + /// turn starts. Absent ⇒ both `None` (the front applies its defaults). + submit: Mutex>, + /// Per-agent stall threshold (`LivenessStrategy::stall_after_ms`, lot 2), stashed by + /// `set_stall_threshold` and consumed to arm a fresh liveness window on the enqueue + /// that starts a turn. Absent ⇒ `None` (no stall detection — legacy behaviour). + stall: Mutex>>, /// Agents whose prompt-ready watcher thread is already armed, so re-binding the same /// handle does not spawn a duplicate watcher (lot C5). Shared (`Arc`) because each /// watcher thread un-arms its own entry on exit. @@ -147,22 +414,103 @@ impl MediatedInbox { /// turn delivery (the orchestrator writes the turn itself). #[must_use] pub fn new(mailbox: Arc, clock: Arc) -> Self { + let handles = Arc::new(Mutex::new(HashMap::new())); + let front_owned = Arc::new(Mutex::new(HashSet::new())); Self { mailbox, - tracker: Arc::new(BusyTracker::new(None)), + tracker: Self::build_tracker(None, None, &handles, &front_owned), clock, pty: None, - handles: Mutex::new(HashMap::new()), + handles, + front_owned, + submit: Mutex::new(HashMap::new()), + stall: Mutex::new(HashMap::new()), watched: Arc::new(Mutex::new(HashSet::new())), } } + /// Construit le [`BusyTracker`] avec, quand un PTY est présent, le **sink headless** + /// branché (cf. [`HeadlessSink`]). Sans PTY ⇒ aucun sink (livraison par événement). + fn build_tracker( + events: Option>, + pty: Option<&Arc>, + handles: &Arc>>, + front_owned: &Arc>>, + ) -> Arc { + let mut tracker = BusyTracker::new(events); + if let Some(pty) = pty { + tracker.headless_sink = Some(Self::make_headless_sink( + Arc::clone(pty), + Arc::clone(handles), + Arc::clone(front_owned), + )); + } + Arc::new(tracker) + } + + /// Fabrique le sink de livraison headless : pour un agent **sans cellule frontend** + /// (absent de `front_owned`), écrit la tâche dans son PTY (texte, puis — après le + /// délai anti-paste-detection — la séquence de soumission) et renvoie `None` (pris + /// en charge). Pour un agent avec cellule, ou sans handle PTY connu, renvoie + /// `Some(d)` ⇒ le tracker publie l'événement `DelegationReady` comme avant. + fn make_headless_sink( + pty: Arc, + handles: Arc>>, + front_owned: Arc>>, + ) -> HeadlessSink { + Arc::new(move |agent: AgentId, d: DeferredDelegation| { + // Cellule frontend montée ⇒ c'est le write-portal qui écrit (et qui sait + // composer avec une saisie humaine en cours). On rend la main. + if front_owned + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&agent) + { + return Some(d); + } + // Agent headless : récupérer son handle PTY. Absent ⇒ repli sur l'événement + // (best-effort, ne devrait pas arriver pour un agent qui livre un tour). + let handle = handles + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&agent) + .cloned(); + let Some(handle) = handle else { + return Some(d); + }; + let pty = Arc::clone(&pty); + let text = d.text; + let submit = d.submit_sequence.unwrap_or_else(|| "\r".to_owned()); + let delay = u64::from(d.submit_delay_ms.unwrap_or(DEFAULT_SUBMIT_DELAY_MS)); + // Écriture sur un thread détaché : ne JAMAIS bloquer l'appelant (thread du + // watcher prompt-ready, tâche tokio du serveur MCP, ou le fil de l'enqueue). + // Texte d'abord, puis la séquence de soumission après le délai — comme le + // write-portal frontend — pour esquiver la détection de coller des CLI. + std::thread::spawn(move || { + let _ = pty.write(&handle, text.as_bytes()); + if delay > 0 { + std::thread::sleep(std::time::Duration::from_millis(delay)); + } + let _ = pty.write(&handle, submit.as_bytes()); + }); + None + }) + } + /// Wires an [`EventBus`] so busy/idle transitions publish /// [`DomainEvent::AgentBusyChanged`] at their source (cadrage C4 §4.2). Builder /// additive: callers that do not wire a bus stay silent. #[must_use] pub fn with_events(mut self, events: Arc) -> Self { - self.tracker = Arc::new(BusyTracker::new(Some(events))); + // Reconstruit le tracker avec le bus ET — si un PTY est déjà câblé (cas + // `with_pty().with_events()` du composition root) — le sink headless, sinon + // l'ordre des builders perdrait la livraison headless. + self.tracker = Self::build_tracker( + Some(events), + self.pty.as_ref(), + &self.handles, + &self.front_owned, + ); self } @@ -175,12 +523,18 @@ impl MediatedInbox { clock: Arc, pty: Arc, ) -> Self { + let handles = Arc::new(Mutex::new(HashMap::new())); + let front_owned = Arc::new(Mutex::new(HashSet::new())); + let pty_opt = Some(pty); Self { mailbox, - tracker: Arc::new(BusyTracker::new(None)), + tracker: Self::build_tracker(None, pty_opt.as_ref(), &handles, &front_owned), clock, - pty: Some(pty), - handles: Mutex::new(HashMap::new()), + pty: pty_opt, + handles, + front_owned, + submit: Mutex::new(HashMap::new()), + stall: Mutex::new(HashMap::new()), watched: Arc::new(Mutex::new(HashSet::new())), } } @@ -188,7 +542,10 @@ impl MediatedInbox { /// Convenience constructor over a fresh mailbox and the wall clock. #[must_use] pub fn in_memory() -> Self { - Self::new(Arc::new(InMemoryMailbox::new()), Arc::new(SystemMillisClock)) + Self::new( + Arc::new(InMemoryMailbox::new()), + Arc::new(SystemMillisClock), + ) } fn handles(&self) -> std::sync::MutexGuard<'_, HashMap> { @@ -197,6 +554,28 @@ impl MediatedInbox { .unwrap_or_else(std::sync::PoisonError::into_inner) } + fn submit(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.submit + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn stall(&self) -> std::sync::MutexGuard<'_, HashMap>> { + self.stall + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// **Détection de stagnation** (lot 2) : balaie les agents `Busy` et bascule + /// `Alive→Stalled` ceux dont le dernier battement remonte à plus de + /// `stall_after_ms`. Émet `AgentLivenessChanged` **une fois par transition**. La + /// logique de décision est **pure** ([`BusyTracker::sweep_stalled`]) : `now_ms` est + /// fourni par l'horloge injectée, donc testable sans horloge réelle. Destinée à + /// être appelée périodiquement par une tâche détenue au composition root. + pub fn sweep_stalled(&self) { + self.tracker.sweep_stalled(self.clock.now_ms()); + } + /// The underlying mailbox (e.g. for `cancel_head` / `resolve` from the orchestrator). #[must_use] pub fn mailbox(&self) -> Arc { @@ -250,12 +629,11 @@ impl MediatedInbox { let mut window: Vec = Vec::with_capacity(keep + 1); for chunk in stream { window.extend_from_slice(&chunk); - if window - .windows(needle.len()) - .any(|w| w == needle.as_slice()) - { - // Prompt-ready: first OR signal wins ⇒ Idle (advances the FIFO). - tracker.mark_idle(agent); + if window.windows(needle.len()).any(|w| w == needle.as_slice()) { + // Prompt-ready: premier signal OR gagnant. Si un premier tour froid + // est différé ⇒ on le **livre** ici (et l'agent reste Busy) ; sinon + // ⇒ `mark_idle` (fait avancer la FIFO). Voir [`BusyTracker::prompt_ready`]. + tracker.prompt_ready(agent); break; } if window.len() > keep { @@ -273,42 +651,80 @@ impl MediatedInbox { } } +/// Composes the line delivered into the target's terminal for a delegated turn. +/// +/// The `[IdeA · tâche de · ticket ]` header is the protocol signal +/// (cadrage B-5, agent context) that marks the message as an IdeA delegation the +/// target must answer via `idea_reply` — echoing `ticket` for multi-thread +/// correlation — rather than as a free-text human prompt. The raw `task` follows on +/// the next line so the agent reads the request unchanged. +fn delegation_preamble(requester: &str, ticket: TicketId, task: &str) -> String { + format!("[IdeA · tâche de {requester} · ticket {ticket}]\n{task}") +} + impl InputMediator for MediatedInbox { fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply { let ticket_id = ticket.id; // If the agent is Idle, this enqueue starts its turn ⇒ go Busy. If already // Busy, we still accept (queue grows; the turn advances on mark_idle) — never // reject the sender (forward fallback). + let now_ms = self.clock.now_ms(); let started_turn = self.tracker.start_turn( agent, AgentBusyState::Busy { ticket: ticket_id, - since_ms: self.clock.now_ms(), + since_ms: now_ms, }, ); - // Publish Busy only on the enqueue that **starts** a turn (Idle→Busy); a - // second enqueue while Busy queues behind without re-announcing (cadrage - // C4 §4.2). Published outside the busy mutex (the tracker released it above). + // Sur l'enqueue qui **démarre** un tour : armer une fenêtre de vivacité fraîche + // avec le seuil de stagnation stashé du profil (lot 2). `last_seen = now`, état + // `Alive`. Un profil sans seuil (`None`) arme une entrée jamais déclarée stalled + // (zéro régression). Un re-enqueue pendant `Busy` ne ré-arme pas (le tour court). + if started_turn { + let stall_after_ms = self.stall().get(&agent).copied().flatten(); + self.tracker.arm_liveness(agent, stall_after_ms, now_ms); + } + // Publish only on the enqueue that **starts** a turn (Idle→Busy); a second + // enqueue while Busy queues behind without re-announcing (cadrage C4 §4.2, + // ARCHITECTURE §20). Published outside the busy mutex (the tracker released it + // above). On a started turn we emit BOTH the advisory busy beacon AND the new + // `DelegationReady` carrying the task text + ticket + the target's submit + // config: the backend NO LONGER writes the turn into the PTY (the `\n` band-aid + // is gone — it never submitted in raw mode). The frontend write-portal owns the + // physical write (text + submit_sequence) through the single PTY writer. if started_turn { if let Some(events) = &self.tracker.events { events.publish(DomainEvent::AgentBusyChanged { agent_id: agent, busy: true, }); - } - } - // Delivery: write the prefixed task line into the agent's bound handle. The - // prefix carries the requester + ticket id so the target replies via - // `idea_reply(result, ticket)`. A best-effort write: a missing handle/port or - // a write error never drops the ticket (the orchestrator's await + timeout - // remain the safety net) — the reply slot is registered regardless. - if let Some(pty) = &self.pty { - if let Some(handle) = self.handles().get(&agent).cloned() { - let line = format!( - "[IdeA · tâche de {} · ticket {}] {}\n", - ticket.requester, ticket_id, ticket.task - ); - let _ = pty.write(&handle, line.as_bytes()); + let submit = self.submit().get(&agent).cloned().unwrap_or_default(); + // Préfixe de délégation : c'est le **signal** qui dit à la cible + // « ceci est une tâche IdeA, réponds via `idea_reply` (jamais en + // texte) en renvoyant ce `ticket` ». Sans lui la cible traite la + // ligne comme une invite humaine et répond dans le terminal, et + // l'agent demandeur reste bloqué jusqu'au timeout. La tâche brute + // reste dans le `Ticket` (mailbox/historique) ; seul le texte livré + // au PTY porte le préfixe. Format aligné sur l'instruction injectée + // dans le contexte de l'agent (`[IdeA · tâche de … · ticket …]`). + let text = delegation_preamble(&ticket.requester, ticket_id, &ticket.task); + // **Fix race cold-launch** : si l'agent est en démarrage à froid (inscrit + // par `mark_starting` quand un watcher est armé), ON DIFFÈRE la + // `DelegationReady` jusqu'au prompt-ready (le CLI n'a pas encore affiché + // son prompt — la livrer maintenant perdrait le premier tour). Le watcher + // la publiera via `prompt_ready`. Agent chaud (pas dans `starting`) ⇒ + // publication immédiate, comportement inchangé. + let deferred = DeferredDelegation { + ticket: ticket_id, + text, + submit_sequence: submit.sequence, + submit_delay_ms: submit.delay_ms, + }; + if self.tracker.lock_starting().contains(&agent) { + self.tracker.lock_deferred().insert(agent, deferred); + } else { + self.tracker.publish_deferred(agent, deferred); + } } } self.mailbox.enqueue(agent, ticket) @@ -323,12 +739,15 @@ impl InputMediator for MediatedInbox { agent: AgentId, handle: PtyHandle, prompt_ready_pattern: Option, + submit: SubmitConfig, ) { - // Register the input handle (delivery path) exactly like `bind_handle`, then arm - // the prompt-ready watcher when the profile declares a literal marker (C5). A - // `None`/empty pattern arms nothing: Idle then comes only from the explicit - // signal or the per-turn timeout (safe fallback, never a false Idle). + // Register the input handle exactly like `bind_handle`, stash the target's + // submit config (echoed on `DelegationReady` at the next turn start, §20.3), + // then arm the prompt-ready watcher when the profile declares a literal marker + // (C5). A `None`/empty pattern arms nothing: Idle then comes only from the + // explicit signal or the per-turn timeout (safe fallback, never a false Idle). self.handles().insert(agent, handle.clone()); + self.submit().insert(agent, submit); if let Some(pattern) = prompt_ready_pattern { self.arm_prompt_watcher(agent, &handle, pattern); } @@ -338,6 +757,30 @@ impl InputMediator for MediatedInbox { self.pty.is_some() && self.handles().get(&agent).is_some() } + fn mark_starting(&self, agent: AgentId) { + // Gate du premier tour d'un agent froid : appelé par l'orchestrateur juste après + // un (re)lancement à froid, AVANT le bind/enqueue, et uniquement si un watcher + // sera armé (cf. doc du trait). Consommé par l'`enqueue` qui démarre le tour + // (diffère la `DelegationReady`) puis par `prompt_ready` (la libère). + self.tracker.mark_starting(agent); + } + + fn release_cold_start(&self, agent: AgentId) { + self.tracker.release_cold_start(agent); + } + + fn set_front_attached(&self, agent: AgentId, attached: bool) { + let mut front = self + .front_owned + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if attached { + front.insert(agent); + } else { + front.remove(&agent); + } + } + fn preempt(&self, agent: AgentId) { // Interrompre: signals the running turn to stop. It is NOT an enqueue and // correlates **no** ticket (we never pop/resolve a pending caller — preempt @@ -355,10 +798,23 @@ impl InputMediator for MediatedInbox { fn mark_idle(&self, agent: AgentId) { // Single authority (also used by the prompt-ready watcher): real Busy→Idle only, - // publishing AgentBusyChanged{busy:false} once. + // publishing AgentBusyChanged{busy:false} once. Also clears the liveness entry + // (fin de tour) — émet la reprise si l'agent était `Stalled`. self.tracker.mark_idle(agent); } + fn mark_alive(&self, agent: AgentId) { + // Un battement (delta / activité / heartbeat) rafraîchit `last_seen` et ramène + // l'agent à `Alive` s'il était `Stalled` (lot 2). + self.tracker.touch(agent, self.clock.now_ms()); + } + + fn set_stall_threshold(&self, agent: AgentId, stall_after_ms: Option) { + // Stashé par l'orchestrateur depuis le profil de la cible AVANT l'enqueue qui + // démarre le tour ; consommé par `arm_liveness` au start_turn (lot 2). + self.stall().insert(agent, stall_after_ms); + } + fn busy_state(&self, agent: AgentId) -> AgentBusyState { self.tracker.busy_state(agent) } @@ -392,7 +848,10 @@ mod tests { } fn inbox_at(now_ms: u64) -> MediatedInbox { - MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(now_ms))) + MediatedInbox::new( + Arc::new(InMemoryMailbox::new()), + Arc::new(FixedClock(now_ms)), + ) } /// Records every [`DomainEvent`] published, for busy/idle assertions. @@ -421,6 +880,32 @@ mod tests { }) .collect() } + + /// The `(ticket, text, submit_sequence, submit_delay_ms)` of every + /// `DelegationReady` published, in order. + #[allow(clippy::type_complexity)] + fn delegation_ready(&self) -> Vec<(TicketId, String, Option, Option)> { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter_map(|e| match e { + DomainEvent::DelegationReady { + ticket, + text, + submit_sequence, + submit_delay_ms, + .. + } => Some(( + *ticket, + text.clone(), + submit_sequence.clone(), + *submit_delay_ms, + )), + _ => None, + }) + .collect() + } } #[test] @@ -436,7 +921,11 @@ mod tests { // Second enqueue while Busy queues behind ⇒ NO new busy event. inbox.enqueue(a, ticket(11, "second")); - assert_eq!(bus.busy_events(), vec![(a, true)], "no re-announce while busy"); + assert_eq!( + bus.busy_events(), + vec![(a, true)], + "no re-announce while busy" + ); // mark_idle on a busy agent ⇒ exactly one Idle(false) event. inbox.mark_idle(a); @@ -459,6 +948,173 @@ mod tests { assert_eq!(bus.busy_events(), vec![(a, true)]); } + // ==================================================================== + // §20 — enqueue no longer PTY-writes; it publishes DelegationReady + // ==================================================================== + + #[test] + fn enqueue_publishes_exactly_one_delegation_ready_on_idle_to_busy() { + let bus = Arc::new(RecordingBus::default()); + let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) + .with_events(Arc::clone(&bus) as Arc); + let a = agent(1); + + // Idle→Busy ⇒ exactly one DelegationReady carrying the task text + ticket. + inbox.enqueue(a, ticket(10, "do the thing")); + let ready = bus.delegation_ready(); + assert_eq!(ready.len(), 1, "exactly one DelegationReady on turn start"); + let tid = TicketId::from_uuid(uuid::Uuid::from_u128(10)); + assert_eq!(ready[0].0, tid); + // The delivered text carries the delegation prefix (the signal to answer via + // `idea_reply`) followed by the raw task on the next line. + assert_eq!(ready[0].1, delegation_preamble("User", tid, "do the thing")); + assert!( + ready[0].1.starts_with("[IdeA · tâche de User · ticket "), + "delivered text must carry the delegation prefix: {:?}", + ready[0].1 + ); + assert!( + ready[0].1.ends_with("\ndo the thing"), + "raw task must follow the prefix line: {:?}", + ready[0].1 + ); + + // A second enqueue while Busy must NOT re-publish (consistent with started_turn). + inbox.enqueue(a, ticket(11, "queued")); + assert_eq!( + bus.delegation_ready().len(), + 1, + "no DelegationReady on a re-enqueue while Busy" + ); + + // After mark_idle, a fresh enqueue starts a new turn ⇒ a second DelegationReady. + inbox.mark_idle(a); + inbox.enqueue(a, ticket(12, "next turn")); + let ready = bus.delegation_ready(); + assert_eq!(ready.len(), 2, "new turn ⇒ a new DelegationReady"); + assert!( + ready[1].1.ends_with("\nnext turn"), + "second turn delivers its own prefixed task: {:?}", + ready[1].1 + ); + } + + #[test] + fn delegation_ready_carries_bound_submit_config() { + let bus = Arc::new(RecordingBus::default()); + let pty = Arc::new(FakePty::new()); + let inbox = MediatedInbox::with_pty( + Arc::new(InMemoryMailbox::new()), + Arc::new(FixedClock(1)), + Arc::clone(&pty) as Arc, + ) + .with_events(Arc::clone(&bus) as Arc); + let a = agent(1); + let h = handle(1); + + // Bind the target's submit config (resolved from its profile by the service). + inbox.bind_handle_with_prompt( + a, + h, + None, + SubmitConfig::new(Some("\r".to_owned()), Some(42)), + ); + // A frontend cell is mounted ⇒ delivery flows through the event (the front + // writes). This test asserts the event carries the bound submit config. + inbox.set_front_attached(a, true); + inbox.enqueue(a, ticket(10, "task")); + + let ready = bus.delegation_ready(); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].2.as_deref(), Some("\r"), "submit_sequence echoed"); + assert_eq!(ready[0].3, Some(42), "submit_delay_ms echoed"); + } + + #[test] + fn enqueue_without_bound_submit_yields_none_fields() { + let bus = Arc::new(RecordingBus::default()); + let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) + .with_events(Arc::clone(&bus) as Arc); + let a = agent(1); + inbox.enqueue(a, ticket(10, "task")); + let ready = bus.delegation_ready(); + assert_eq!(ready.len(), 1); + assert_eq!( + ready[0].2, None, + "no bound submit ⇒ None (front applies default)" + ); + assert_eq!(ready[0].3, None); + } + + #[test] + fn front_attached_agent_is_delivered_via_event_not_backend_write() { + // §20 nominal: a mounted frontend cell owns the physical write. The backend + // publishes DelegationReady and writes NOTHING into the PTY itself. + let pty = Arc::new(FakePty::new()); + let bus = Arc::new(RecordingBus::default()); + let inbox = MediatedInbox::with_pty( + Arc::new(InMemoryMailbox::new()), + Arc::new(FixedClock(1)), + Arc::clone(&pty) as Arc, + ) + .with_events(Arc::clone(&bus) as Arc); + let a = agent(1); + inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default()); + inbox.set_front_attached(a, true); + + inbox.enqueue(a, ticket(10, "do X")); + + assert_eq!( + bus.delegation_ready().len(), + 1, + "front-attached ⇒ exactly one DelegationReady event for the write-portal" + ); + // The headless path is not taken ⇒ no thread is spawned ⇒ no PTY write, ever. + assert!( + pty.writes.lock().unwrap().is_empty(), + "front-attached ⇒ the backend must NOT write the PTY (the front does)" + ); + } + + #[test] + fn headless_agent_without_front_cell_is_written_by_the_backend() { + // Cold/background delegation target: no frontend cell is mounted, so NOBODY + // consumes DelegationReady. The mediator must therefore write the turn into the + // PTY itself (text + submit), else the agent never receives its task — the + // root cause of "a delegated agent never replies". + let pty = Arc::new(FakePty::new()); + let bus = Arc::new(RecordingBus::default()); + let inbox = MediatedInbox::with_pty( + Arc::new(InMemoryMailbox::new()), + Arc::new(FixedClock(1)), + Arc::clone(&pty) as Arc, + ) + .with_events(Arc::clone(&bus) as Arc); + let a = agent(1); + // No set_front_attached ⇒ headless. + inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default()); + + inbox.enqueue(a, ticket(10, "do X")); + + assert!( + wait_until(|| pty.writes.lock().unwrap().len() >= 2), + "headless delivery writes the task text + submit sequence into the PTY" + ); + let writes = pty.writes.lock().unwrap().clone(); + assert!( + String::from_utf8_lossy(&writes[0]).contains("do X"), + "first write carries the task text" + ); + assert_eq!( + writes[1], b"\r", + "second write is the default submit sequence" + ); + assert!( + bus.delegation_ready().is_empty(), + "headless delivery takes over ⇒ no DelegationReady event is published" + ); + } + #[tokio::test] async fn enqueue_returns_pending_reply_resolved_via_mailbox() { let inbox = inbox_at(5); @@ -490,7 +1146,7 @@ mod tests { let a = agent(1); inbox.enqueue(a, ticket(10, "first")); inbox.enqueue(a, ticket(11, "second")); // accepted, queues behind - // Still busy on the FIRST ticket (turn unchanged), both queued in the mailbox. + // Still busy on the FIRST ticket (turn unchanged), both queued in the mailbox. assert_eq!( inbox.busy_state(a).ticket(), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))) @@ -562,9 +1218,9 @@ mod tests { // Lot C5 — prompt-ready detection on the PTY output stream // ==================================================================== + use domain::ids::SessionId; use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle as Handle, SpawnSpec}; use domain::terminal::PtySize; - use domain::ids::SessionId; /// A fake [`PtyPort`] whose `subscribe_output` replays a fixed list of chunks then /// ends (EOF), so the watcher thread sees a deterministic, finite stream. `write` is @@ -659,7 +1315,7 @@ mod tests { assert!(inbox.busy_state(a).is_busy(), "enqueue starts a turn"); // Arm prompt detection with the literal marker "\n> " (a stable prompt sigil). - inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned())); + inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); assert!( wait_until(|| !inbox.busy_state(a).is_busy()), @@ -676,7 +1332,7 @@ mod tests { pty.seed(&h, vec![b"out REA".to_vec(), b"DY done".to_vec()]); let inbox = inbox_with(Arc::clone(&pty)); inbox.enqueue(a, ticket(10, "t")); - inbox.bind_handle_with_prompt(a, h, Some("READY".to_owned())); + inbox.bind_handle_with_prompt(a, h, Some("READY".to_owned()), SubmitConfig::default()); assert!( wait_until(|| !inbox.busy_state(a).is_busy()), "a marker split across chunks must still flip to Idle" @@ -692,7 +1348,7 @@ mod tests { let inbox = inbox_with(Arc::clone(&pty)); inbox.enqueue(a, ticket(10, "t")); // No pattern: bind without arming a watcher (the safe fallback). - inbox.bind_handle_with_prompt(a, h, None); + inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default()); // Give any (erroneously spawned) watcher a chance to fire — it must not. std::thread::sleep(std::time::Duration::from_millis(80)); assert!( @@ -701,7 +1357,11 @@ mod tests { ); // The FIFO still accepts a second enqueue (forward, never reject). inbox.enqueue(a, ticket(11, "second")); - assert_eq!(inbox.mailbox().pending(&a), 2, "enqueue accepted while Busy"); + assert_eq!( + inbox.mailbox().pending(&a), + 2, + "enqueue accepted while Busy" + ); } #[test] @@ -713,7 +1373,7 @@ mod tests { pty.seed(&h, vec![b"still thinking, no prompt here\n".to_vec()]); let inbox = inbox_with(Arc::clone(&pty)); inbox.enqueue(a, ticket(10, "t")); - inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned())); + inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); // Even after the stream ends, no match ⇒ stays Busy (timeout is the ultimate // guard, exercised at the orchestrator layer). std::thread::sleep(std::time::Duration::from_millis(80)); @@ -737,7 +1397,7 @@ mod tests { pty.seed(&h, vec![b"anything\n> ".to_vec()]); let inbox = inbox_with(Arc::clone(&pty)); inbox.enqueue(a, ticket(10, "t")); - inbox.bind_handle_with_prompt(a, h, Some(String::new())); + inbox.bind_handle_with_prompt(a, h, Some(String::new()), SubmitConfig::default()); std::thread::sleep(std::time::Duration::from_millis(60)); assert!( inbox.busy_state(a).is_busy(), @@ -757,15 +1417,19 @@ mod tests { inbox.enqueue(a, ticket(11, "second")); assert_eq!( inbox.busy_state(a).ticket(), - Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(10))) + Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128( + 10 + ))) ); - inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned())); + inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); // Prompt-ready ⇒ Idle ⇒ the next enqueue can start a fresh turn. assert!(wait_until(|| !inbox.busy_state(a).is_busy())); inbox.enqueue(a, ticket(12, "third")); assert_eq!( inbox.busy_state(a).ticket(), - Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(12))), + Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128( + 12 + ))), "after prompt-ready Idle, a new enqueue starts the next turn" ); } @@ -781,9 +1445,459 @@ mod tests { inbox.enqueue(a, ticket(10, "t")); // Arm twice in a row; the second must be a no-op while the first is live (no // panic, no double-subscribe). After EOF the agent is un-armed and stays Busy. - inbox.bind_handle_with_prompt(a, h.clone(), Some("ZZZ".to_owned())); - inbox.bind_handle_with_prompt(a, h, Some("ZZZ".to_owned())); + inbox.bind_handle_with_prompt( + a, + h.clone(), + Some("ZZZ".to_owned()), + SubmitConfig::default(), + ); + inbox.bind_handle_with_prompt(a, h, Some("ZZZ".to_owned()), SubmitConfig::default()); std::thread::sleep(std::time::Duration::from_millis(60)); assert!(inbox.busy_state(a).is_busy(), "no match ⇒ stays Busy"); } + + // ==================================================================== + // Fix race cold-launch — `mark_starting` gate le PREMIER tour sur le prompt-ready + // ==================================================================== + + /// (a) Cold-launch : un agent marqué `mark_starting` ne livre PAS sa + /// `DelegationReady` à l'enqueue ; elle est différée jusqu'à l'apparition du prompt + /// dans la sortie (le watcher la publie alors), au bon moment. L'agent reste Busy + /// pendant tout le gate. + #[test] + fn cold_launch_defers_first_turn_until_prompt_ready() { + let bus = Arc::new(RecordingBus::default()); + let pty = Arc::new(FakePty::new()); + let a = agent(1); + let h = handle(20); + // Sortie de boot : le prompt "\n> " n'apparaît que dans le second chunk. + pty.seed( + &h, + vec![b"booting CLI...\n".to_vec(), b"ready\n> ".to_vec()], + ); + let inbox = MediatedInbox::with_pty( + Arc::new(InMemoryMailbox::new()), + Arc::new(FixedClock(1)), + Arc::clone(&pty) as Arc, + ) + .with_events(Arc::clone(&bus) as Arc); + + // Cellule front montée : ce test observe la livraison via l'événement (chemin + // write-portal), orthogonal au gate cold-launch testé ici. + inbox.set_front_attached(a, true); + // Démarrage à froid : gate armé AVANT bind/enqueue (ordre de l'orchestrateur). + inbox.mark_starting(a); + inbox.enqueue(a, ticket(10, "cold task")); + // L'enqueue a démarré le tour (Busy) MAIS la DelegationReady est différée. + assert!(inbox.busy_state(a).is_busy(), "le tour démarre (Busy)"); + assert!( + bus.delegation_ready().is_empty(), + "cold-launch ⇒ pas de DelegationReady tant que le prompt n'est pas prêt" + ); + + // On arme le watcher (le prompt "\n> " finira par apparaître dans la sortie). + inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); + + // À l'apparition du prompt : la DelegationReady différée est publiée (1 seule), + // et l'agent reste Busy (le premier tour court désormais réellement). + assert!( + wait_until(|| bus.delegation_ready().len() == 1), + "prompt-ready ⇒ la DelegationReady différée est livrée" + ); + let ready = bus.delegation_ready(); + assert_eq!( + ready.len(), + 1, + "exactement une DelegationReady (pas de doublon)" + ); + assert!( + ready[0].1.ends_with("\ncold task"), + "le texte différé porte la tâche brute: {:?}", + ready[0].1 + ); + assert!( + inbox.busy_state(a).is_busy(), + "après livraison du 1er tour froid, l'agent reste Busy (idle viendra d'idea_reply)" + ); + } + + /// Readiness de démarrage MCP : un 1er tour différé (`mark_starting` + enqueue) reste + /// retenu tant que rien ne le libère ; `release_cold_start` (signal connexion MCP) le + /// draine ⇒ exactement UNE `DelegationReady`. Calque du gate prompt-ready, mais libéré + /// par le signal MCP (pas de watcher armé ici). + #[test] + fn release_cold_start_delivers_deferred_first_turn() { + let bus = Arc::new(RecordingBus::default()); + let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) + .with_events(Arc::clone(&bus) as Arc); + let a = agent(1); + + // Démarrage à froid : gate armé AVANT l'enqueue (ordre de l'orchestrateur). + inbox.mark_starting(a); + inbox.enqueue(a, ticket(10, "cold task")); + assert!(inbox.busy_state(a).is_busy(), "le tour démarre (Busy)"); + assert!( + bus.delegation_ready().is_empty(), + "cold-launch ⇒ pas de DelegationReady tant que rien ne libère" + ); + + // Connexion du pont MCP ⇒ libère le 1er tour différé. + inbox.release_cold_start(a); + let ready = bus.delegation_ready(); + assert_eq!( + ready.len(), + 1, + "release_cold_start ⇒ exactement une DelegationReady" + ); + assert!( + ready[0].1.ends_with("\ncold task"), + "le texte différé porte la tâche brute: {:?}", + ready[0].1 + ); + } + + /// `release_cold_start` sans tour différé ⇒ no-op : aucune `DelegationReady` et, + /// surtout, PAS de transition Idle (signal de DÉMARRAGE, pas de fin de tour). + #[test] + fn release_cold_start_is_noop_without_deferred() { + let bus = Arc::new(RecordingBus::default()); + let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) + .with_events(Arc::clone(&bus) as Arc); + let a = agent(1); + + // Démarre un tour normal (Busy), SANS gate cold-launch (pas de mark_starting). + inbox.enqueue(a, ticket(10, "task")); + let busy_before = bus.busy_events(); + + inbox.release_cold_start(a); + assert!( + bus.delegation_ready().len() == 1, + "pas de DelegationReady supplémentaire (la seule est celle de l'enqueue)" + ); + assert_eq!( + bus.busy_events(), + busy_before, + "release_cold_start ne marque PAS Idle (aucun AgentBusyChanged{{busy:false}})" + ); + } + + /// `release_cold_start` est idempotent : un second appel ne re-draine rien ⇒ une seule + /// `DelegationReady` au total. + #[test] + fn release_cold_start_is_idempotent() { + let bus = Arc::new(RecordingBus::default()); + let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) + .with_events(Arc::clone(&bus) as Arc); + let a = agent(1); + + inbox.mark_starting(a); + inbox.enqueue(a, ticket(10, "cold task")); + + inbox.release_cold_start(a); + inbox.release_cold_start(a); + assert_eq!( + bus.delegation_ready().len(), + 1, + "deux release_cold_start ⇒ une seule DelegationReady" + ); + } + + /// (b) Agent chaud (non `mark_starting`) : la `DelegationReady` est publiée + /// IMMÉDIATEMENT à l'enqueue — non-régression du chemin existant. + #[test] + fn warm_agent_delivers_first_turn_immediately() { + let bus = Arc::new(RecordingBus::default()); + let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) + .with_events(Arc::clone(&bus) as Arc); + let a = agent(1); + + // Pas de mark_starting ⇒ agent chaud. + inbox.enqueue(a, ticket(10, "warm task")); + let ready = bus.delegation_ready(); + assert_eq!( + ready.len(), + 1, + "agent chaud ⇒ DelegationReady immédiate (zéro régression)" + ); + assert!(ready[0].1.ends_with("\nwarm task")); + } + + /// (c) Cas limite : si on ne marque PAS `starting` (l'orchestrateur n'arme pas le + /// gate quand aucun `prompt_ready_pattern` n'est configuré), l'enqueue livre la + /// `DelegationReady` immédiatement — donc PAS de blocage indéfini du premier tour. + #[test] + fn no_prompt_pattern_no_starting_gate_delivers_immediately() { + let bus = Arc::new(RecordingBus::default()); + let pty = Arc::new(FakePty::new()); + let a = agent(1); + let h = handle(21); + pty.seed(&h, vec![b"output without any prompt marker\n".to_vec()]); + let inbox = MediatedInbox::with_pty( + Arc::new(InMemoryMailbox::new()), + Arc::new(FixedClock(1)), + Arc::clone(&pty) as Arc, + ) + .with_events(Arc::clone(&bus) as Arc); + + // Cellule front montée ⇒ livraison observable via l'événement. + inbox.set_front_attached(a, true); + // Cold launch SANS pattern ⇒ l'orchestrateur N'APPELLE PAS mark_starting. + inbox.enqueue(a, ticket(10, "task")); + // Premier tour livré tout de suite : aucun watcher ne viendrait le débloquer. + assert_eq!( + bus.delegation_ready().len(), + 1, + "sans gate ⇒ DelegationReady immédiate (pas de blocage indéfini)" + ); + // Bind sans pattern : aucun watcher armé (fallback sûr). + inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default()); + std::thread::sleep(std::time::Duration::from_millis(40)); + // Reste Busy (idle viendra d'idea_reply / timeout), mais le tour A bien été livré. + assert!(inbox.busy_state(a).is_busy()); + assert_eq!( + bus.delegation_ready().len(), + 1, + "toujours une seule livraison" + ); + } + + /// Après livraison du 1er tour froid, un `mark_idle` (idea_reply) puis un nouvel + /// enqueue (agent désormais chaud) repart en livraison immédiate. + #[test] + fn after_cold_first_turn_subsequent_enqueue_is_immediate() { + let bus = Arc::new(RecordingBus::default()); + let pty = Arc::new(FakePty::new()); + let a = agent(1); + let h = handle(22); + pty.seed(&h, vec![b"ready\n> ".to_vec()]); + let inbox = MediatedInbox::with_pty( + Arc::new(InMemoryMailbox::new()), + Arc::new(FixedClock(1)), + Arc::clone(&pty) as Arc, + ) + .with_events(Arc::clone(&bus) as Arc); + + // Cellule front montée ⇒ livraison observable via l'événement. + inbox.set_front_attached(a, true); + inbox.mark_starting(a); + inbox.enqueue(a, ticket(10, "first")); + inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default()); + assert!(wait_until(|| bus.delegation_ready().len() == 1)); + + // Fin du 1er tour (idea_reply) puis nouvel enqueue ⇒ livraison immédiate. + inbox.mark_idle(a); + inbox.enqueue(a, ticket(11, "second")); + let ready = bus.delegation_ready(); + assert_eq!( + ready.len(), + 2, + "second tour livré immédiatement (plus de gate)" + ); + assert!(ready[1].1.ends_with("\nsecond")); + } + + // ==================================================================== + // Lot 2 — détection de stagnation (last_seen / sweep_stalled / liveness) + // ==================================================================== + + use domain::input::AgentLiveness; + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Horloge millis **mutable** (atomique) pour piloter le temps dans les tests de + /// stagnation sans horloge réelle. + struct MutClock(AtomicU64); + impl MutClock { + fn new(start: u64) -> Arc { + Arc::new(Self(AtomicU64::new(start))) + } + fn set(&self, now: u64) { + self.0.store(now, Ordering::SeqCst); + } + } + impl MillisClock for MutClock { + fn now_ms(&self) -> u64 { + self.0.load(Ordering::SeqCst) + } + } + + /// Construit un inbox sur l'horloge mutable + un bus enregistreur, renvoie les deux. + fn inbox_with_clock(clock: Arc) -> (MediatedInbox, Arc) { + let bus = Arc::new(RecordingBus::default()); + let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), clock) + .with_events(Arc::clone(&bus) as Arc); + (inbox, bus) + } + + impl RecordingBus { + /// Toutes les transitions de vivacité publiées, dans l'ordre. + fn liveness_events(&self) -> Vec<(AgentId, AgentLiveness)> { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter_map(|e| match e { + DomainEvent::AgentLivenessChanged { agent_id, liveness } => { + Some((*agent_id, *liveness)) + } + _ => None, + }) + .collect() + } + } + + // (a) absence de battement > stall_after_ms ⇒ Stalled + #[test] + fn no_heartbeat_past_threshold_marks_stalled() { + let clock = MutClock::new(1_000); + let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); + let a = agent(1); + + // Profil : seuil de stagnation à 30_000 ms. Armé avant le tour. + inbox.set_stall_threshold(a, Some(30_000)); + inbox.enqueue(a, ticket(10, "task")); // démarre le tour à t=1_000 ⇒ last_seen=1_000 + + // Dans la fenêtre : pas de transition. + clock.set(1_000 + 30_000); + inbox.sweep_stalled(); + assert_eq!( + bus.liveness_events(), + vec![], + "à la limite exacte, pas encore stalled" + ); + + // Au-delà du seuil : exactement une transition Stalled. + clock.set(1_000 + 30_001); + inbox.sweep_stalled(); + assert_eq!(bus.liveness_events(), vec![(a, AgentLiveness::Stalled)]); + + // Idempotent : un second sweep ne ré-émet pas. + clock.set(1_000 + 60_000); + inbox.sweep_stalled(); + assert_eq!( + bus.liveness_events(), + vec![(a, AgentLiveness::Stalled)], + "déjà stalled ⇒ pas de spam" + ); + } + + // (b) un battement dans la fenêtre réinitialise last_seen (pas de stall) + #[test] + fn heartbeat_within_window_resets_last_seen() { + let clock = MutClock::new(1_000); + let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); + let a = agent(1); + inbox.set_stall_threshold(a, Some(30_000)); + inbox.enqueue(a, ticket(10, "task")); // last_seen=1_000 + + // Battement à t=20_000 ⇒ last_seen=20_000. + clock.set(20_000); + inbox.mark_alive(a); + + // À t=45_000 : 45_000 - 20_000 = 25_000 < 30_000 ⇒ toujours Alive. + clock.set(45_000); + inbox.sweep_stalled(); + assert_eq!( + bus.liveness_events(), + vec![], + "un battement a réinitialisé la fenêtre ⇒ pas de stall" + ); + } + + // (d) AgentLivenessChanged émis une seule fois par transition (Alive→Stalled→Alive) + #[test] + fn liveness_event_once_per_transition() { + let clock = MutClock::new(0); + let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); + let a = agent(1); + inbox.set_stall_threshold(a, Some(10_000)); + inbox.enqueue(a, ticket(10, "t")); // last_seen=0 + + // Stall. + clock.set(10_001); + inbox.sweep_stalled(); + // Sweeps répétés : pas de ré-émission. + inbox.sweep_stalled(); + inbox.sweep_stalled(); + assert_eq!(bus.liveness_events(), vec![(a, AgentLiveness::Stalled)]); + + // Battement tardif ⇒ une seule reprise Alive. + clock.set(20_000); + inbox.mark_alive(a); + inbox.mark_alive(a); // second battement : déjà Alive ⇒ rien. + assert_eq!( + bus.liveness_events(), + vec![(a, AgentLiveness::Stalled), (a, AgentLiveness::Alive)] + ); + + // Re-stall possible après reprise (nouvelle transition). + clock.set(20_000 + 10_001); + inbox.sweep_stalled(); + assert_eq!( + bus.liveness_events(), + vec![ + (a, AgentLiveness::Stalled), + (a, AgentLiveness::Alive), + (a, AgentLiveness::Stalled), + ] + ); + } + + // mark_idle (fin de tour) sur un agent Stalled émet la reprise et retire l'entrée. + #[test] + fn mark_idle_on_stalled_emits_recovery_and_clears() { + let clock = MutClock::new(0); + let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); + let a = agent(1); + inbox.set_stall_threshold(a, Some(5_000)); + inbox.enqueue(a, ticket(10, "t")); + clock.set(5_001); + inbox.sweep_stalled(); + assert_eq!(bus.liveness_events(), vec![(a, AgentLiveness::Stalled)]); + + inbox.mark_idle(a); // fin de tour ⇒ reprise Alive + entrée retirée. + assert_eq!( + bus.liveness_events(), + vec![(a, AgentLiveness::Stalled), (a, AgentLiveness::Alive)] + ); + + // Plus d'entrée : un sweep ultérieur ne ré-émet rien (même très tard). + clock.set(1_000_000); + inbox.sweep_stalled(); + assert_eq!( + bus.liveness_events(), + vec![(a, AgentLiveness::Stalled), (a, AgentLiveness::Alive)] + ); + } + + // (e) agent sans LivenessStrategy (stall_after_ms = None) ⇒ comportement legacy : + // jamais Stalled, aucun AgentLivenessChanged. + #[test] + fn agent_without_threshold_is_never_stalled() { + let clock = MutClock::new(0); + let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); + let a = agent(1); + // Pas de set_stall_threshold (ou None) : armé sans seuil au start_turn. + inbox.enqueue(a, ticket(10, "t")); + clock.set(10_000_000); // très loin dans le futur. + inbox.sweep_stalled(); + assert_eq!( + bus.liveness_events(), + vec![], + "sans seuil ⇒ jamais stalled (zéro régression)" + ); + // Et un mark_alive sur un agent jamais stalled n'émet rien non plus. + inbox.mark_alive(a); + assert_eq!(bus.liveness_events(), vec![]); + } + + // Un battement n'a aucun effet sur un agent dont aucune entrée n'est armée. + #[test] + fn mark_alive_on_unarmed_agent_is_noop() { + let clock = MutClock::new(0); + let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); + let a = agent(1); + inbox.mark_alive(a); // jamais de tour démarré. + inbox.sweep_stalled(); + assert_eq!(bus.liveness_events(), vec![]); + } } diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index b458629..df9e914 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -24,10 +24,12 @@ pub mod input; pub mod inspector; pub mod mailbox; pub mod orchestrator; +pub mod permission; pub mod process; pub mod pty; pub mod remote; pub mod runtime; +pub mod sandbox; pub mod session; pub mod store; @@ -38,10 +40,10 @@ pub use conversation_log::{ }; pub use eventbus::TokioBroadcastEventBus; pub use fileguard::RwFileGuard; -pub use input::{MediatedInbox, MillisClock, SystemMillisClock}; pub use fs::LocalFileSystem; pub use git::Git2Repository; pub use id::UuidGenerator; +pub use input::{MediatedInbox, MillisClock, SystemMillisClock}; pub use inspector::ClaudeTranscriptInspector; pub use mailbox::InMemoryMailbox; pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport}; @@ -49,10 +51,14 @@ pub use orchestrator::{ process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle, REQUESTS_SUBDIR, }; +pub use permission::{ClaudePermissionProjector, CodexPermissionProjector}; pub use process::LocalProcessSpawner; pub use pty::PortablePtyAdapter; pub use remote::{remote_host, LocalHost}; pub use runtime::CliAgentRuntime; +#[cfg(target_os = "linux")] +pub use sandbox::LandlockSandbox; +pub use sandbox::{default_enforcer, NoopSandbox}; pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory}; #[cfg(feature = "vector-onnx")] pub use store::OnnxEmbedder; @@ -61,8 +67,8 @@ pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT}; pub use store::{ embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector, AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, - FsMemoryStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, HashEmbedder, - IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall, - DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, - VECTOR_ONNX_ENABLED, + FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, + FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, + StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, + RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; diff --git a/crates/infrastructure/src/mailbox/mod.rs b/crates/infrastructure/src/mailbox/mod.rs index 2b4a9f4..dba9095 100644 --- a/crates/infrastructure/src/mailbox/mod.rs +++ b/crates/infrastructure/src/mailbox/mod.rs @@ -192,13 +192,19 @@ mod tests { let p1 = mb.enqueue(a, ticket(10, "first")); let p2 = mb.enqueue(a, ticket(11, "second")); assert_eq!(mb.pending(&a), 2); - assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))); + assert_eq!( + mb.head_ticket(&a), + Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))) + ); // First resolve goes to the FIRST (head) ticket. mb.resolve(a, "r1".to_owned()).unwrap(); assert_eq!(p1.await.unwrap(), "r1"); // Now the second is at the head. - assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11)))); + assert_eq!( + mb.head_ticket(&a), + Some(TicketId::from_uuid(uuid::Uuid::from_u128(11))) + ); mb.resolve(a, "r2".to_owned()).unwrap(); assert_eq!(p2.await.unwrap(), "r2"); } @@ -238,7 +244,10 @@ mod tests { // Caller of ticket 10 timed out: retire exactly its head ticket. mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(10))); assert_eq!(mb.pending(&a), 1); - assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11)))); + assert_eq!( + mb.head_ticket(&a), + Some(TicketId::from_uuid(uuid::Uuid::from_u128(11))) + ); // The cancelled pending resolves to Cancelled (its sender was dropped). assert_eq!(p1.await, Err(MailboxError::Cancelled)); @@ -256,6 +265,9 @@ mod tests { // Try to cancel a ticket that is NOT the head ⇒ nothing retired. mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(99))); assert_eq!(mb.pending(&a), 1); - assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))); + assert_eq!( + mb.head_ticket(&a), + Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))) + ); } } diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index a837681..af6e2ae 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -18,10 +18,12 @@ //! duplicated here. use std::sync::Arc; +use std::time::Duration; use application::OrchestratorService; use domain::{DomainEvent, OrchestrationSource, Project}; use serde_json::{json, Value}; +use tokio::sync::mpsc; use super::jsonrpc::{ error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, @@ -32,6 +34,18 @@ use super::tools::{self, ToolMapError}; /// The MCP protocol version this server speaks (advertised on `initialize`). const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; +/// **Server-side safety net** bounding the synchronous `idea_ask_agent` rendezvous. +/// +/// `idea_ask_agent` is the only tool whose `tools/call` *blocks* awaiting another +/// agent's `idea_reply` (a synchronous rendezvous, resolved deep in the application +/// layer). The application layer already bounds the rendezvous itself, but this +/// adapter adds its own **finite** outer bound so a wedged target can never park a +/// `tools/call` task forever: on expiry the call returns a clean JSON-RPC error +/// instead of hanging. Generous on purpose (delegated turns can be very long), but +/// never infinite. Every other tool (`idea_reply`, `idea_list_agents`, …) is left +/// untouched — they don't rendezvous. +const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60); + /// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`]. /// /// Cheap to clone the dependencies it holds; one instance serves one project's @@ -53,6 +67,18 @@ pub struct McpServer { /// frozen `"mcp"` placeholder; empty ⇒ the legacy `"mcp"` label (back-compat for /// M2 callers and connections that arrive without a requester). requester: String, + /// Sink optionnel de **readiness de démarrage** : appelé avec le `requester` brut + /// (handshake) la première fois que le peer émet `initialize` (son CLI est up et + /// parle MCP). La composition root y branche + /// `OrchestratorService::release_agent_cold_start` pour livrer un 1er tour différé + /// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la + /// composition root parse le `requester`. `None` ⇒ no-op. + ready_sink: Option>, + /// Finite outer bound applied **only** to the `idea_ask_agent` rendezvous (see + /// [`ASK_RENDEZVOUS_TIMEOUT`]). Carried per instance so tests can shrink it to a + /// few milliseconds without polluting the public API; production code always uses + /// the default. + ask_rendezvous_timeout: Duration, } impl McpServer { @@ -66,6 +92,8 @@ impl McpServer { project, events: None, requester: String::new(), + ready_sink: None, + ask_rendezvous_timeout: ASK_RENDEZVOUS_TIMEOUT, } } @@ -79,6 +107,17 @@ impl McpServer { self } + /// Attache un sink de **readiness de démarrage** : appelé avec l'id (handshake + /// `requester`) de l'agent connecté la première fois qu'il émet `initialize` (son CLI + /// est up et parle MCP). La composition root y branche + /// `OrchestratorService::release_agent_cold_start` pour livrer un éventuel 1er tour + /// différé (fix race cold-launch, signal MCP). Additif : sans sink, no-op. + #[must_use] + pub fn with_ready_sink(mut self, ready_sink: Arc) -> Self { + self.ready_sink = Some(ready_sink); + self + } + /// Returns a per-connection clone of this server tagged with the connected /// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4). /// @@ -94,9 +133,22 @@ impl McpServer { project: self.project.clone(), events: self.events.clone(), requester: requester.into(), + ready_sink: self.ready_sink.clone(), + ask_rendezvous_timeout: self.ask_rendezvous_timeout, } } + /// **Test seam** (doc-hidden): shrinks the `idea_ask_agent` rendezvous bound + /// (see [`ASK_RENDEZVOUS_TIMEOUT`]) so a wedged-target test need not wait the + /// full production timeout. Doc-hidden so it does not widen the documented public + /// surface; production code never calls it. + #[doc(hidden)] + #[must_use] + pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self { + self.ask_rendezvous_timeout = timeout; + self + } + /// Serves JSON-RPC messages from `transport`, tagging every processed /// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4). /// @@ -113,19 +165,76 @@ impl McpServer { /// Every inbound line is handled in isolation: a malformed line or an unknown /// method yields a JSON-RPC error response, **never a panic** and never a /// dropped connection. Notifications (no `id`) are processed without a reply. + /// + /// **Full-duplex / non-blocking (anti-wedge).** A request whose handling blocks — + /// notably `idea_ask_agent`, which awaits another agent's `idea_reply` in a + /// synchronous rendezvous — must **not** stall the read loop: otherwise a single + /// in-flight ask parks the whole connection and every later call (even a + /// rendezvous-free `idea_list_agents`) is never even read. So each inbound message + /// is handled on its own spawned task that owns a cheap per-call clone of the + /// server; finished responses are funnelled back through an `mpsc` channel and + /// written by the same loop. Responses carry their JSON-RPC `id`, so out-of-order + /// completion is fine (the full-duplex bridge correlates by id). Notifications + /// (`handle_raw` ⇒ `None`) emit nothing. pub async fn serve(&self, transport: &mut T) { + let (tx, mut rx) = mpsc::unbounded_channel::>>(); + // Number of spawned handler tasks not yet observed on `rx`. While `> 0`, an + // EOF on the read side must NOT exit immediately: we keep draining `rx` so + // already-computed responses still reach the transport (graceful shutdown). + // A response-less notification decrements without ever sending — see below. + let mut in_flight: usize = 0; + // Once the peer closed its read side, stop accepting new inbound; only drain. + let mut reading = true; loop { - let raw = match transport.recv().await { - Ok(bytes) => bytes, - Err(TransportError::Closed) => break, - Err(TransportError::Io(_)) => break, - }; - if let Some(response) = self.handle_raw(&raw).await { - let Ok(bytes) = serde_json::to_vec(&response) else { - continue; - }; - if transport.send(&bytes).await.is_err() { - break; + tokio::select! { + // Drain ready responses first so a flood of inbound never starves + // writes; correctness does not depend on the bias. + biased; + + outbound = rx.recv() => { + // `tx` is held by the loop, so `recv` only yields `None` once it + // is dropped — which never happens before the loop returns. + let Some(slot) = outbound else { break }; + in_flight -= 1; + if let Some(bytes) = slot { + if transport.send(&bytes).await.is_err() { + break; + } + } + // Peer gone and every spawned task accounted for ⇒ done. + if !reading && in_flight == 0 { + break; + } + } + + incoming = transport.recv(), if reading => { + let raw = match incoming { + Ok(bytes) => bytes, + // Peer closed / I/O error: stop reading but keep draining the + // responses of tasks still in flight before returning. + Err(TransportError::Closed) | Err(TransportError::Io(_)) => { + reading = false; + if in_flight == 0 { + break; + } + continue; + } + }; + // Own a cheap clone (Arc/String/Project) so the task is `'static` + // and never borrows the transport. Every spawned task sends + // exactly one slot back (`Some(bytes)` for a reply, `None` for a + // notification) so `in_flight` is always reconciled. + in_flight += 1; + let server = self.for_requester(self.requester.clone()); + let tx = tx.clone(); + tokio::spawn(async move { + let slot = match server.handle_raw(&raw).await { + Some(response) => serde_json::to_vec(&response).ok(), + None => None, + }; + // Receiver dropped ⇒ the loop has stopped; discard. + let _ = tx.send(slot); + }); } } } @@ -176,7 +285,10 @@ impl McpServer { params: Option, ) -> Result { match method { - "initialize" => Ok(self.initialize_result()), + "initialize" => { + self.notify_ready(); + Ok(self.initialize_result()) + } "tools/list" => Ok(self.tools_list_result()), "tools/call" => self.tools_call(params.unwrap_or(Value::Null)).await, other => Err(JsonRpcError::new( @@ -186,6 +298,16 @@ impl McpServer { } } + /// Notifie le sink de readiness de démarrage avec l'identité du peer connecté + /// (handshake `requester`). No-op si pas de sink ou requester vide (peer legacy/anonyme). + fn notify_ready(&self) { + if let Some(sink) = &self.ready_sink { + if !self.requester.is_empty() { + sink(&self.requester); + } + } + } + /// The `initialize` result: protocol version, server identity, and the fact /// that we expose tools. fn initialize_result(&self) -> Value { @@ -219,9 +341,7 @@ impl McpServer { let name = params .get("name") .and_then(Value::as_str) - .ok_or_else(|| { - JsonRpcError::new(error_codes::INVALID_PARAMS, "missing tool `name`") - })? + .ok_or_else(|| JsonRpcError::new(error_codes::INVALID_PARAMS, "missing tool `name`"))? .to_owned(); let arguments = params.get("arguments").cloned().unwrap_or(json!({})); @@ -230,7 +350,25 @@ impl McpServer { let command = tools::map_tool_call(&name, &arguments, &self.requester).map_err(map_err_to_jsonrpc)?; - let result = self.service.dispatch(&self.project, command).await; + // `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous + // (the target's `idea_reply`). Bound *only* that path with a finite outer + // timeout (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`]): on expiry + // return a clean JSON-RPC error instead of parking the task forever. Every + // other tool dispatches unbounded — they never rendezvous. + let dispatch = self.service.dispatch(&self.project, command); + let result = if name == "idea_ask_agent" { + match tokio::time::timeout(self.ask_rendezvous_timeout, dispatch).await { + Ok(result) => result, + Err(_elapsed) => { + return Err(JsonRpcError::new( + error_codes::INTERNAL_ERROR, + "idea_ask_agent : aucune réponse de la cible avant le timeout du rendezvous", + )); + } + } + } else { + dispatch.await + }; // Surface the processed delegation on the bus, tagged as the MCP door — the // twin of the file watcher's publish. `ok` mirrors the dispatch outcome; the // action is the tool name. No-op when no sink is attached. @@ -238,7 +376,10 @@ impl McpServer { match result { Ok(outcome) => { // The text the agent sees: the reply for an `ask`, else the detail. - let text = outcome.reply.clone().unwrap_or_else(|| outcome.detail.clone()); + let text = outcome + .reply + .clone() + .unwrap_or_else(|| outcome.detail.clone()); Ok(tool_result_text(&text, false)) } // A failed IdeA command is reported as a tool execution error diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 99b18ed..1bb6df2 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -242,9 +242,8 @@ pub fn map_tool_call( .ok_or_else(|| ToolMapError::BadArguments(name.to_owned()))?; // Small helpers to pull optional string fields out of the arguments object. - let s = |key: &str| -> Option { - args.get(key).and_then(Value::as_str).map(str::to_owned) - }; + let s = + |key: &str| -> Option { args.get(key).and_then(Value::as_str).map(str::to_owned) }; // Translate the tool into the wire-level request shape the watcher already // uses (v2 `type`), then let `validate` enforce the invariants once. @@ -361,7 +360,9 @@ fn base() -> OrchestratorRequest { /// missing its node, with a precise field error). fn parse_node_id(value: Option<&Value>) -> Option { let raw = value?.as_str()?; - uuid::Uuid::parse_str(raw).ok().map(domain::NodeId::from_uuid) + uuid::Uuid::parse_str(raw) + .ok() + .map(domain::NodeId::from_uuid) } #[cfg(test)] @@ -413,7 +414,9 @@ mod tests { fn stop_update_and_skill_map_to_their_commands() { assert_eq!( map("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(), - OrchestratorCommand::StopAgent { name: "Dev".to_owned() } + OrchestratorCommand::StopAgent { + name: "Dev".to_owned() + } ); assert_eq!( map( diff --git a/crates/infrastructure/src/orchestrator/mcp/transport.rs b/crates/infrastructure/src/orchestrator/mcp/transport.rs index 4626f38..2c25040 100644 --- a/crates/infrastructure/src/orchestrator/mcp/transport.rs +++ b/crates/infrastructure/src/orchestrator/mcp/transport.rs @@ -108,9 +108,7 @@ impl MemoryTransport { /// signal EOF. Returns the transport and a receiver of everything the server /// `send`s back. #[must_use] - pub fn scripted( - messages: Vec>, - ) -> (Self, mpsc::UnboundedReceiver>) { + pub fn scripted(messages: Vec>) -> (Self, mpsc::UnboundedReceiver>) { let (tx, rx) = mpsc::unbounded_channel(); ( Self { diff --git a/crates/infrastructure/src/permission/claude.rs b/crates/infrastructure/src/permission/claude.rs new file mode 100644 index 0000000..1bc2487 --- /dev/null +++ b/crates/infrastructure/src/permission/claude.rs @@ -0,0 +1,362 @@ +//! Claude Code permission projector (lot LP3-2). +//! +//! Produces the `.claude/settings.local.json` seed written into an agent run dir: +//! full project autonomy (`bypassPermissions` + broad Read/Edit/Write/Bash) with +//! the project root granted as an additional working directory, while keeping +//! destructive/out-of-project commands denied. The translation is extracted +//! verbatim from the former `claude_settings_seed` in `lifecycle.rs`. + +use domain::permission::{ + Capability, CommandMatcher, Effect, EffectivePermissions, PermissionProjection, + PermissionProjector, PermissionRule, Posture, ProjectedFile, ProjectionContext, ProjectorKey, +}; + +use super::json_escape; + +/// Run-dir-relative path of the owned Claude settings seed. +const SETTINGS_REL_PATH: &str = ".claude/settings.local.json"; + +/// Projects [`EffectivePermissions`] into Claude Code's `settings.local.json`. +/// +/// Pure: `project` only computes the JSON; the launch path materialises it. A +/// `Replace`-owned file (clobbered at launch, removed on swap-away). +#[derive(Debug, Default, Clone, Copy)] +pub struct ClaudePermissionProjector; + +impl PermissionProjector for ClaudePermissionProjector { + fn key(&self) -> ProjectorKey { + ProjectorKey::Claude + } + + fn project( + &self, + eff: Option<&EffectivePermissions>, + ctx: &ProjectionContext, + ) -> PermissionProjection { + // Product invariant: nothing posed ⇒ nothing projected (native prompting). + let Some(_) = eff else { + return PermissionProjection::empty(); + }; + let contents = claude_settings_seed(ctx.project_root, eff); + PermissionProjection { + files: vec![ProjectedFile::Replace { + rel_path: SETTINGS_REL_PATH.to_owned(), + contents, + }], + args: Vec::new(), + env: Vec::new(), + } + } + + fn owned_replace_paths(&self) -> Vec { + vec![SETTINGS_REL_PATH.to_owned()] + } +} + +/// Builds the Claude Code permission seed. `project_root` is embedded verbatim +/// (JSON-escaped) and granted as an additional working directory, since the cwd is +/// the run dir and the agent works on the root above it. +fn claude_settings_seed(project_root: &str, permissions: Option<&EffectivePermissions>) -> String { + let root = json_escape(project_root); + let default_mode = match permissions.map(EffectivePermissions::fallback) { + Some(Posture::Deny) => "plan", + Some(Posture::Ask) => "acceptEdits", + Some(Posture::Allow) | None => "bypassPermissions", + }; + let allow = claude_permission_entries(permissions, Effect::Allow); + let deny = claude_permission_entries(permissions, Effect::Deny); + let default_allow = [ + "Read".to_owned(), + "Edit".to_owned(), + "Write".to_owned(), + "Bash".to_owned(), + ]; + let allow = json_string_array(if allow.is_empty() { + &default_allow + } else { + &allow + }); + let deny = json_string_array(&merge_default_deny(deny)); + format!( + r#"{{ + "permissions": {{ + "defaultMode": "{default_mode}", + "additionalDirectories": [ + "{root}" + ], + "allow": {allow}, + "deny": {deny} + }}, + "skipDangerousModePermissionPrompt": true, + "enabledMcpjsonServers": ["idea"], + "sandbox": {{ + "enabled": false + }} +}} +"# + ) +} + +fn merge_default_deny(mut deny: Vec) -> Vec { + for item in [ + "Bash(sudo *)", + "Bash(rm -rf /)", + "Bash(rm -rf /*)", + "Bash(rm -rf ~)", + "Bash(rm -rf ~/)", + "Bash(rm -rf ~/*)", + "Bash(rm -rf $HOME*)", + "Bash(mkfs*)", + "Bash(dd if=*)", + "Bash(shutdown*)", + "Bash(reboot*)", + ] { + if !deny.iter().any(|existing| existing == item) { + deny.push(item.to_owned()); + } + } + deny +} + +fn claude_permission_entries( + permissions: Option<&EffectivePermissions>, + effect: Effect, +) -> Vec { + let Some(permissions) = permissions else { + return Vec::new(); + }; + let mut out = Vec::new(); + for rule in permissions.rules() { + if rule.effect() != effect { + continue; + } + match rule.capability() { + Capability::Read => push_path_entries(&mut out, "Read", rule), + Capability::Write => { + push_path_entries(&mut out, "Edit", rule); + push_path_entries(&mut out, "Write", rule); + } + Capability::Delete => push_delete_entries(&mut out, rule), + Capability::ExecuteBash => push_bash_entries(&mut out, rule), + } + } + out +} + +fn push_path_entries(out: &mut Vec, capability: &str, rule: &PermissionRule) { + if rule.paths().is_empty() { + out.push(capability.to_owned()); + return; + } + for glob in rule.paths().globs() { + out.push(format!("{capability}({})", glob.pattern())); + } +} + +fn push_delete_entries(out: &mut Vec, rule: &PermissionRule) { + if rule.paths().is_empty() { + out.push("Bash(rm *)".to_owned()); + return; + } + for glob in rule.paths().globs() { + out.push(format!("Bash(rm {})", glob.pattern())); + } +} + +fn push_bash_entries(out: &mut Vec, rule: &PermissionRule) { + if rule.commands().is_empty() { + out.push("Bash".to_owned()); + return; + } + for cmd in rule.commands() { + if cmd.effect != rule.effect() { + continue; + } + out.push(format!("Bash({})", command_matcher_pattern(&cmd.matcher))); + } +} + +fn command_matcher_pattern(matcher: &CommandMatcher) -> String { + match matcher { + CommandMatcher::Exact(value) => value.clone(), + CommandMatcher::Prefix(value) => format!("{value}*"), + CommandMatcher::Glob(glob) => glob.pattern().to_owned(), + } +} + +fn json_string_array(items: &[String]) -> String { + if items.is_empty() { + return "[]".to_owned(); + } + let body = items + .iter() + .map(|item| format!(" \"{}\"", json_escape(item))) + .collect::>() + .join(",\n"); + format!("[\n{body}\n ]") +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::permission::{resolve, PathScope, PermissionSet}; + use serde_json::Value; + + fn ctx<'a>(root: &'a str, run_dir: &'a str) -> ProjectionContext<'a> { + ProjectionContext { + project_root: root, + run_dir, + } + } + + fn path_scope(patterns: &[&str]) -> PathScope { + PathScope::new(patterns.iter().map(ToString::to_string)).unwrap() + } + + /// Builds an [`EffectivePermissions`] from a single (project) set via the + /// domain API, exactly like the `permission` unit tests do. + fn eff_with(rules: Vec, fallback: Posture) -> EffectivePermissions { + resolve(Some(&PermissionSet::new(rules, fallback)), None).unwrap() + } + + /// Projects and returns the parsed `settings.local.json` value, asserting the + /// projection's structural contract (1 Replace file, no args/env) along the way. + fn project_json(eff: &EffectivePermissions, root: &str) -> Value { + let proj = ClaudePermissionProjector.project(Some(eff), &ctx(root, "/run/agent")); + assert!(proj.args.is_empty(), "Claude projection carries no args"); + assert!(proj.env.is_empty(), "Claude projection carries no env"); + assert_eq!(proj.files.len(), 1, "exactly one file projected"); + match &proj.files[0] { + ProjectedFile::Replace { rel_path, contents } => { + assert_eq!(rel_path, SETTINGS_REL_PATH); + serde_json::from_str(contents).expect("the produced settings is valid JSON") + } + ProjectedFile::MergeToml { .. } => panic!("Claude must emit a Replace file"), + } + } + + fn str_array(value: &Value) -> Vec { + value + .as_array() + .expect("array") + .iter() + .map(|v| v.as_str().expect("string").to_owned()) + .collect() + } + + // ---- product invariant + ownership ---------------------------------- + + #[test] + fn project_none_is_empty() { + let proj = ClaudePermissionProjector.project(None, &ctx("/proj", "/run")); + assert!(proj.files.is_empty()); + assert!(proj.args.is_empty()); + assert!(proj.env.is_empty()); + } + + #[test] + fn owned_replace_paths_is_the_settings_file() { + assert_eq!( + ClaudePermissionProjector.owned_replace_paths(), + vec![SETTINGS_REL_PATH.to_owned()] + ); + } + + // ---- (1) posture → defaultMode -------------------------------------- + + #[test] + fn default_mode_maps_each_posture() { + for (posture, mode) in [ + (Posture::Allow, "bypassPermissions"), + (Posture::Ask, "acceptEdits"), + (Posture::Deny, "plan"), + ] { + let json = project_json(&eff_with(vec![], posture), "/proj"); + assert_eq!( + json["permissions"]["defaultMode"], mode, + "posture {posture:?} should map to defaultMode {mode}" + ); + } + } + + // ---- (2) deny-wins: deny entry surfaces in the deny list ------------- + + #[test] + fn specific_deny_with_broad_allow_appears_in_deny_list() { + let rules = vec![ + PermissionRule::file(Capability::Write, Effect::Deny, path_scope(&[".ideai/**"])) + .unwrap(), + PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["**"])).unwrap(), + ]; + let json = project_json(&eff_with(rules, Posture::Allow), "/proj"); + + let deny = str_array(&json["permissions"]["deny"]); + // A Write capability fans out to both Edit(..) and Write(..) entries. + assert!(deny.contains(&"Edit(.ideai/**)".to_owned()), "deny={deny:?}"); + assert!(deny.contains(&"Write(.ideai/**)".to_owned()), "deny={deny:?}"); + + let allow = str_array(&json["permissions"]["allow"]); + assert!( + allow.contains(&"Edit(**)".to_owned()) && allow.contains(&"Write(**)".to_owned()), + "the broad allow stays in the allow list; allow={allow:?}" + ); + } + + // ---- (3) additionalDirectories carries the (escaped) project root ---- + + #[test] + fn additional_directories_contains_project_root_escaped() { + // A Windows-ish path with a backslash AND a quote exercises JSON escaping; + // parsing it back must yield the original raw path verbatim. + let root = r#"C:\Users\a"b\proj"#; + let json = project_json(&eff_with(vec![], Posture::Allow), root); + let dirs = str_array(&json["permissions"]["additionalDirectories"]); + assert_eq!(dirs, vec![root.to_owned()]); + } + + // ---- (4) hard-coded destructive guardrails -------------------------- + + #[test] + fn default_deny_guardrails_are_present() { + let json = project_json(&eff_with(vec![], Posture::Allow), "/proj"); + let deny = str_array(&json["permissions"]["deny"]); + for guard in [ + "Bash(sudo *)", + "Bash(rm -rf /)", + "Bash(rm -rf ~)", + "Bash(rm -rf $HOME*)", + "Bash(mkfs*)", + "Bash(dd if=*)", + "Bash(shutdown*)", + "Bash(reboot*)", + ] { + assert!(deny.contains(&guard.to_owned()), "missing guardrail {guard}; deny={deny:?}"); + } + } + + // ---- (5) valid JSON + expected static shape ------------------------- + + #[test] + fn produced_settings_has_expected_static_shape() { + // `project_json` already proved the document parses; assert the fixed keys. + let json = project_json(&eff_with(vec![], Posture::Ask), "/proj"); + assert_eq!(json["enabledMcpjsonServers"][0], "idea"); + assert_eq!(json["skipDangerousModePermissionPrompt"], true); + assert_eq!(json["sandbox"]["enabled"], false); + } + + #[test] + fn empty_rules_fall_back_to_broad_default_allow() { + let json = project_json(&eff_with(vec![], Posture::Allow), "/proj"); + let allow = str_array(&json["permissions"]["allow"]); + assert_eq!( + allow, + vec![ + "Read".to_owned(), + "Edit".to_owned(), + "Write".to_owned(), + "Bash".to_owned() + ] + ); + } +} diff --git a/crates/infrastructure/src/permission/codex.rs b/crates/infrastructure/src/permission/codex.rs new file mode 100644 index 0000000..8e994a1 --- /dev/null +++ b/crates/infrastructure/src/permission/codex.rs @@ -0,0 +1,190 @@ +//! Codex CLI permission projector (lot LP3-2). +//! +//! Produces the **permission-relevant** part of Codex's `config.toml` +//! (`sandbox_mode` / `approval_policy`) plus the matching launch args +//! (`--sandbox` / `--ask-for-approval`). The posture→mode derivation is extracted +//! verbatim from the former `codex_sandbox_mode` / `codex_approval_policy` / +//! `apply_codex_cli_permission_args` in `lifecycle.rs`. +//! +//! Unlike Claude's seed, Codex's `config.toml` is **co-owned** (it also carries the +//! `mcp_servers.idea` table and the `projects.*` trust entries, which are MCP/trust +//! concerns, not permissions). The projector therefore emits a +//! [`ProjectedFile::MergeToml`] limited to the two permission keys it manages — +//! everything else in the file is preserved by the fold, and the file is **never** +//! deleted on swap (hence an empty `owned_replace_paths`). + +use domain::permission::{ + EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile, + ProjectionContext, ProjectorKey, +}; + +use super::toml_string; + +/// Run-dir-relative path of Codex's `config.toml`. Codex reads its config from +/// `$CODEX_HOME/config.toml`; IdeA isolates `CODEX_HOME` to `{runDir}/.codex`, so +/// the file lives at `.codex/config.toml` relative to the run dir. +const CONFIG_REL_PATH: &str = ".codex/config.toml"; + +/// The two top-level keys this projector manages in `config.toml`. Everything else +/// (MCP table, trust entries, user keys) is preserved by the merge. +const MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"]; + +/// Projects [`EffectivePermissions`] into Codex's sandbox/approval config + args. +/// +/// Pure: `project` only computes the plan; the launch path merges the TOML fragment +/// and appends the args. +#[derive(Debug, Default, Clone, Copy)] +pub struct CodexPermissionProjector; + +impl PermissionProjector for CodexPermissionProjector { + fn key(&self) -> ProjectorKey { + ProjectorKey::Codex + } + + fn project( + &self, + eff: Option<&EffectivePermissions>, + _ctx: &ProjectionContext, + ) -> PermissionProjection { + // Product invariant: nothing posed ⇒ nothing projected. Codex keeps its + // native sandbox/approval defaults (no args, no managed keys written). + let Some(permissions) = eff else { + return PermissionProjection::empty(); + }; + let sandbox = codex_sandbox_mode(permissions); + let approval = codex_approval_policy(permissions); + + // Permission-only TOML fragment (escaped exactly like the former + // `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a + // permission concern and stay with the MCP wiring (LP3-3). + let contents = format!( + "sandbox_mode = {}\napproval_policy = {}\n", + toml_string(sandbox), + toml_string(approval), + ); + + PermissionProjection { + files: vec![ProjectedFile::MergeToml { + rel_path: CONFIG_REL_PATH.to_owned(), + managed_tables: Vec::new(), + managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(), + contents, + }], + args: vec![ + "--sandbox".to_owned(), + sandbox.to_owned(), + "--ask-for-approval".to_owned(), + approval.to_owned(), + ], + env: Vec::new(), + } + } + + fn owned_replace_paths(&self) -> Vec { + // config.toml is co-owned (MergeToml), never an owned Replace file. + Vec::new() + } +} + +fn codex_sandbox_mode(permissions: &EffectivePermissions) -> &'static str { + match permissions.fallback() { + Posture::Deny => "read-only", + Posture::Ask | Posture::Allow => "workspace-write", + } +} + +fn codex_approval_policy(permissions: &EffectivePermissions) -> &'static str { + match permissions.fallback() { + Posture::Allow => "never", + Posture::Ask | Posture::Deny => "on-request", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::permission::{resolve, PermissionSet}; + + fn ctx<'a>() -> ProjectionContext<'a> { + ProjectionContext { + project_root: "/proj", + run_dir: "/run/agent", + } + } + + /// Builds an [`EffectivePermissions`] with the given fallback posture via the + /// domain API (only the fallback drives Codex's sandbox/approval derivation). + fn eff(fallback: Posture) -> EffectivePermissions { + resolve(Some(&PermissionSet::new(vec![], fallback)), None).unwrap() + } + + // ---- product invariant + ownership ---------------------------------- + + #[test] + fn project_none_is_empty() { + let proj = CodexPermissionProjector.project(None, &ctx()); + assert!(proj.files.is_empty()); + assert!(proj.args.is_empty()); + assert!(proj.env.is_empty()); + } + + #[test] + fn owned_replace_paths_is_empty() { + // config.toml is co-owned (MergeToml) ⇒ nothing to clean up on swap. + assert!(CodexPermissionProjector.owned_replace_paths().is_empty()); + } + + // ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents + // coherence + MergeToml shape ------------------------------- + + #[test] + fn posture_maps_sandbox_and_approval_in_file_and_args() { + for (posture, sandbox, approval) in [ + (Posture::Deny, "read-only", "on-request"), + (Posture::Ask, "workspace-write", "on-request"), + (Posture::Allow, "workspace-write", "never"), + ] { + let proj = CodexPermissionProjector.project(Some(&eff(posture)), &ctx()); + assert!(proj.env.is_empty(), "Codex projection carries no env"); + + // -- The single MergeToml file, with the two managed permission keys. + assert_eq!(proj.files.len(), 1, "exactly one file projected"); + match &proj.files[0] { + ProjectedFile::MergeToml { + rel_path, + managed_tables, + managed_keys, + contents, + } => { + assert_eq!(rel_path, CONFIG_REL_PATH); + assert!(managed_tables.is_empty(), "no managed tables"); + assert_eq!( + managed_keys, + &vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()] + ); + assert!( + contents.contains(&format!("sandbox_mode = \"{sandbox}\"")), + "posture {posture:?}: contents={contents:?}" + ); + assert!( + contents.contains(&format!("approval_policy = \"{approval}\"")), + "posture {posture:?}: contents={contents:?}" + ); + } + ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"), + } + + // -- (7) Args reflect the SAME values as the TOML, in CLI order. + assert_eq!( + proj.args, + vec![ + "--sandbox".to_owned(), + sandbox.to_owned(), + "--ask-for-approval".to_owned(), + approval.to_owned(), + ], + "posture {posture:?}: args must mirror the TOML values" + ); + } + } +} diff --git a/crates/infrastructure/src/permission/mod.rs b/crates/infrastructure/src/permission/mod.rs new file mode 100644 index 0000000..b067680 --- /dev/null +++ b/crates/infrastructure/src/permission/mod.rs @@ -0,0 +1,46 @@ +//! Per-CLI **permission projectors** (lot LP3-2). +//! +//! Concrete implementations of the domain port +//! [`domain::permission::PermissionProjector`]: they translate the resolved +//! [`domain::permission::EffectivePermissions`] into a CLI-specific +//! [`domain::permission::PermissionProjection`] — a **plan** (files + args + env), +//! never an action. Writing/merging the plan into the agent run dir is the launch +//! path's job (lot LP3-3); the projectors here stay **pure** (no `FileSystem`, no +//! I/O), exactly like the domain trait demands. +//! +//! This is an **extraction**: the translation rules (postures → allow/deny/ask +//! lists for Claude, posture → sandbox/approval modes for Codex) are moved here +//! verbatim from `application/src/agent/lifecycle.rs`, only reshaped to return a +//! `PermissionProjection`. The rules themselves are unchanged. + +mod claude; +mod codex; + +pub use claude::ClaudePermissionProjector; +pub use codex::CodexPermissionProjector; + +/// Minimal JSON string escaper for embedding a filesystem path / permission entry +/// in a settings document (handles the characters that actually occur in paths: +/// backslash, quote, control chars). Extracted verbatim from `lifecycle.rs`. +pub(crate) fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} + +/// Escapes `s` as a TOML basic string (quotes included). The escape set required +/// by a TOML basic string coincides with JSON's for the characters that concern +/// us (paths, mode keywords). Extracted verbatim from `lifecycle.rs`. +pub(crate) fn toml_string(s: &str) -> String { + format!("\"{}\"", json_escape(s)) +} diff --git a/crates/infrastructure/src/pty/mod.rs b/crates/infrastructure/src/pty/mod.rs index 9322cbe..fd7af8d 100644 --- a/crates/infrastructure/src/pty/mod.rs +++ b/crates/infrastructure/src/pty/mod.rs @@ -42,9 +42,10 @@ use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use async_trait::async_trait; -use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem}; +use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem, SlavePty}; use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec}; +use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::terminal::PtySize; use domain::SessionId; @@ -143,16 +144,32 @@ struct LivePty { #[derive(Default)] pub struct PortablePtyAdapter { sessions: Mutex>, + /// Optional OS-sandbox enforcer (lot LP4-1). `None` ⇒ no sandboxing at all + /// (the default, zero behaviour change). When set **and** a [`SpawnSpec`] + /// carries a [`SandboxPlan`], the plan is enforced on the child at spawn + /// (see [`spawn_command_sandboxed`]). + sandbox_enforcer: Option>, } impl PortablePtyAdapter { - /// Creates an empty adapter. + /// Creates an empty adapter (no OS sandbox). #[must_use] pub fn new() -> Self { Self { sessions: Mutex::new(HashMap::new()), + sandbox_enforcer: None, } } + + /// Additive builder: wires an OS-sandbox [`SandboxEnforcer`] (lot LP4-1). With + /// it set, any [`SpawnSpec`] whose `sandbox` is `Some` has that plan enforced on + /// the spawned child. Without it (the default), no spawn is ever sandboxed — + /// `new()`'s behaviour is unchanged. + #[must_use] + pub fn with_sandbox_enforcer(mut self, enforcer: Arc) -> Self { + self.sandbox_enforcer = Some(enforcer); + self + } } /// Maps the domain [`PtySize`] to the `portable-pty` one. @@ -165,6 +182,54 @@ fn to_pty_size(size: PtySize) -> portable_pty::PtySize { } } +/// Spawns the child **under an OS sandbox** (lot LP4-1). +/// +/// ## Why a dedicated thread instead of a `pre_exec` hook +/// +/// `portable-pty` 0.9 exposes **no** user-injectable `pre_exec` closure: its +/// `CommandBuilder` has no such method, and `SlavePty::spawn_command` installs its +/// *own* internal `pre_exec` (for `setsid` / controlling-tty) before exec, with no +/// extension point. So we cannot run `enforcer.enforce(plan)` in the child's +/// `pre_exec` directly. +/// +/// Instead we lean on a guaranteed kernel property: **a Landlock domain is +/// inherited across `fork` and preserved across `execve`**. We therefore restrict a +/// **dedicated throwaway thread** with `enforce`, then call `spawn_command` *from +/// that thread*. `std::process::Command` (which `spawn_command` drives) forks from +/// the calling thread — and because `portable-pty` always sets a `pre_exec`, std is +/// forced down the `fork`+`exec` path (never `posix_spawn`) — so the child inherits +/// the restricted thread's domain. The throwaway thread then dies, taking its +/// (irreversible) restriction with it, leaving IdeA's other threads untouched. +/// +/// On `enforce` returning `Err` (fail-closed, e.g. a `Deny` posture on a kernel +/// without Landlock) the spawn is failed and **no child runs**. `Ok(Unsupported / +/// Degraded)` proceeds (best-effort). +/// +/// Everything is **moved** into the thread (no borrow), so no `Sync` bound is +/// required of the slave; the resulting `Child` is `Send` and returned to the +/// caller. +fn spawn_command_sandboxed( + slave: Box, + cmd: CommandBuilder, + enforcer: Arc, + plan: SandboxPlan, +) -> Result, PtyError> { + let join = std::thread::spawn(move || -> Result, PtyError> { + // Restrict THIS thread; the forked child inherits the domain. + if let Err(e) = enforcer.enforce(&plan) { + return Err(PtyError::Spawn(format!("sandbox enforcement failed: {e}"))); + } + let child = slave + .spawn_command(cmd) + .map_err(|e| PtyError::Spawn(e.to_string()))?; + // The slave is held by the child; drop our copy so EOF propagates on exit. + drop(slave); + Ok(child) + }); + join.join() + .map_err(|_| PtyError::Spawn("sandbox spawn thread panicked".to_owned()))? +} + /// Builds the `portable-pty` command from a domain [`SpawnSpec`]. fn to_command(spec: &SpawnSpec) -> CommandBuilder { let mut cmd = CommandBuilder::new(&spec.command); @@ -185,12 +250,22 @@ impl PtyPort for PortablePtyAdapter { .map_err(|e| PtyError::Spawn(e.to_string()))?; let cmd = to_command(&spec); - let child = pair - .slave - .spawn_command(cmd) - .map_err(|e| PtyError::Spawn(e.to_string()))?; - // The slave is held by the child; drop our copy so EOF propagates on exit. - drop(pair.slave); + // Move the slave out of the pair (the master stays usable) so it can be + // either spawned directly or handed to the sandboxed-spawn thread. + let slave = pair.slave; + let child = match (spec.sandbox.clone(), self.sandbox_enforcer.clone()) { + // Sandboxed spawn: a plan AND an enforcer are present. + (Some(plan), Some(enforcer)) => spawn_command_sandboxed(slave, cmd, enforcer, plan)?, + // No plan or no enforcer ⇒ plain spawn (the default path, unchanged). + _ => { + let child = slave + .spawn_command(cmd) + .map_err(|e| PtyError::Spawn(e.to_string()))?; + // The slave is held by the child; drop our copy so EOF propagates. + drop(slave); + child + } + }; let writer = pair .master @@ -371,3 +446,178 @@ mod tests { assert_eq!(hub.snapshot(), b"abc".to_vec()); } } + +/// **End-to-end PTY × Landlock enforcement (lot LP4-3).** +/// +/// These tests prove the OS sandbox is *really live on the raw PTY path*: a real +/// [`PortablePtyAdapter`] wired with the Landlock enforcer, a [`SpawnSpec`] whose +/// `sandbox = Some(plan)`, and a harmless `sh -c` that tries to write **inside** the +/// granted root (must succeed) and **outside** it (must be blocked by the kernel). +/// No AI CLI is launched — zero tokens. The whole chain enforcer → spec.sandbox → +/// `spawn` → `spawn_command_sandboxed` → restricted thread → fork/exec is exercised. +#[cfg(all(test, target_os = "linux"))] +mod sandbox_e2e_tests { + use super::*; + use crate::sandbox::LandlockSandbox; + use domain::sandbox::{PathAccess, PathGrant, SandboxPlan, SandboxStatus}; + use domain::terminal::PtySize; + use domain::Posture; + use std::path::{Path, PathBuf}; + use std::time::{Duration, Instant}; + + /// A unique temp dir for one test (no external tempfile dep), mirroring the + /// helper used by the Landlock adapter tests. + fn fresh_dir(tag: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let p = std::env::temp_dir().join(format!("idea-pty-sandbox-{tag}-{nanos}")); + std::fs::create_dir_all(&p).unwrap(); + p + } + + /// Probes whether the running kernel actually enforces Landlock, exactly the way + /// the launch path would observe it: enforce a tiny RW plan on a throwaway thread + /// (the restriction is irreversible, so it must not run on the test thread) and + /// report `false` when the adapter returns [`SandboxStatus::Unsupported`]. Lets us + /// skip cleanly on CI / old kernels without a Landlock LSM, like the adapter tests. + fn landlock_is_enforced() -> bool { + let probe = fresh_dir("probe"); + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: probe.to_string_lossy().into_owned(), + access: PathAccess::RW, + }], + default_posture: Posture::Ask, + }; + let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan)) + .join() + .unwrap() + .expect("enforce must not error under an Ask posture"); + let _ = std::fs::remove_dir_all(&probe); + !matches!(status, SandboxStatus::Unsupported) + } + + /// Waits (bounded) for `path` to appear, returning whether it showed up in time. + fn wait_for(path: &Path, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if path.exists() { + return true; + } + std::thread::sleep(Duration::from_millis(25)); + } + path.exists() + } + + /// THE high-fidelity end-to-end proof. With the enforcer wired and a plan that + /// grants RW on `allowed` only, a child `sh` writing to **both** an out-of-grant + /// path and the granted path must have its out-of-grant write blocked while the + /// in-grant write lands on disk. The script writes the denied target *first*, so + /// once the allowed marker exists we know the denied write was already attempted + /// (and fenced) — making the assertion deterministic without racing the child. + #[tokio::test] + async fn pty_spawn_enforces_sandbox_plan_end_to_end() { + if !landlock_is_enforced() { + eprintln!("skipping pty_spawn_enforces_sandbox_plan_end_to_end: \ + Landlock not available/enforced on this kernel"); + return; + } + + let allowed = fresh_dir("allowed"); + let denied = fresh_dir("denied"); + let allowed_marker = allowed.join("in.txt"); + let denied_marker = denied.join("out.txt"); + + // RW on `allowed` only ⇒ the write class is handled and fenced to that root; + // reads stay globally unrestricted so `sh`/libc still load normally. + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: allowed.to_string_lossy().into_owned(), + access: PathAccess::RW, + }], + default_posture: Posture::Ask, + }; + + // Denied write FIRST, then the allowed write: the allowed marker appearing is + // a happens-after signal that the denied attempt already ran. + let script = format!( + "echo outside > '{}'; echo inside > '{}'", + denied_marker.display(), + allowed_marker.display() + ); + let spec = SpawnSpec { + command: "sh".to_owned(), + args: vec!["-c".to_owned(), script], + cwd: domain::project::ProjectPath::new(allowed.to_string_lossy().into_owned()) + .expect("temp dir is absolute"), + env: vec![], + context_plan: None, + sandbox: Some(plan), + }; + + let adapter = + PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer()); + let handle = adapter + .spawn(spec, PtySize { rows: 24, cols: 80 }) + .await + .expect("sandboxed spawn must succeed (Ask posture never fails closed)"); + + // Wait for the in-grant write to land (the child's last action). + assert!( + wait_for(&allowed_marker, Duration::from_secs(5)), + "in-grant write must succeed: {allowed_marker:?} never appeared — \ + the sandbox may have wrongly fenced the granted root" + ); + + // THE GUARANTEE: the out-of-grant write was attempted before the marker and + // must have been blocked by Landlock, so the file must not exist. + assert!( + !denied_marker.exists(), + "SANDBOX BREACH: out-of-grant write succeeded ({denied_marker:?} exists) — \ + the Landlock plan was NOT enforced on the PTY child" + ); + + let _ = adapter.kill(&handle).await; + let _ = std::fs::remove_dir_all(&allowed); + let _ = std::fs::remove_dir_all(&denied); + } + + /// Companion sanity check: with the **same** adapter+enforcer but a [`SpawnSpec`] + /// carrying `sandbox = None`, the out-of-grant write must succeed — proving the + /// previous test's block comes from the *enforced plan*, not from some ambient PTY + /// restriction. (Guards against a false-positive where writes fail for unrelated + /// reasons.) `denied` here is just an ordinary writable temp dir. + #[tokio::test] + async fn pty_spawn_without_plan_does_not_sandbox() { + let denied = fresh_dir("noplan"); + let marker = denied.join("out.txt"); + let script = format!("echo outside > '{}'", marker.display()); + + let spec = SpawnSpec { + command: "sh".to_owned(), + args: vec!["-c".to_owned(), script], + cwd: domain::project::ProjectPath::new(denied.to_string_lossy().into_owned()) + .expect("temp dir is absolute"), + env: vec![], + context_plan: None, + sandbox: None, + }; + + let adapter = + PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer()); + let handle = adapter + .spawn(spec, PtySize { rows: 24, cols: 80 }) + .await + .expect("plain spawn must succeed"); + + assert!( + wait_for(&marker, Duration::from_secs(5)), + "without a sandbox plan the write must succeed: {marker:?} never appeared" + ); + + let _ = adapter.kill(&handle).await; + let _ = std::fs::remove_dir_all(&denied); + } +} diff --git a/crates/infrastructure/src/remote/mod.rs b/crates/infrastructure/src/remote/mod.rs index 86ff8af..589e19a 100644 --- a/crates/infrastructure/src/remote/mod.rs +++ b/crates/infrastructure/src/remote/mod.rs @@ -38,7 +38,9 @@ impl LocalHost { Self { fs: Arc::new(LocalFileSystem::new()), spawner: Arc::new(LocalProcessSpawner::new()), - pty: Arc::new(PortablePtyAdapter::new()), + pty: Arc::new( + PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer()), + ), } } } diff --git a/crates/infrastructure/src/runtime/mod.rs b/crates/infrastructure/src/runtime/mod.rs index b17d1a2..0e2a730 100644 --- a/crates/infrastructure/src/runtime/mod.rs +++ b/crates/infrastructure/src/runtime/mod.rs @@ -77,6 +77,7 @@ impl CliAgentRuntime { cwd, env: Vec::new(), context_plan: None, + sandbox: None, }) } @@ -219,6 +220,7 @@ impl AgentRuntime for CliAgentRuntime { cwd: resolved_cwd, env: Vec::new(), context_plan: Some(plan), + sandbox: None, }) } } diff --git a/crates/infrastructure/src/sandbox/landlock.rs b/crates/infrastructure/src/sandbox/landlock.rs new file mode 100644 index 0000000..ebad8aa --- /dev/null +++ b/crates/infrastructure/src/sandbox/landlock.rs @@ -0,0 +1,479 @@ +//! [`LandlockSandbox`] — the Linux [`SandboxEnforcer`] backed by the kernel +//! Landlock LSM (lot LP4-1), via the `landlock` crate, **best-effort / ABI-compat**. +//! +//! # What it does +//! +//! [`SandboxEnforcer::enforce`] translates a domain [`SandboxPlan`] into a Landlock +//! ruleset and restricts the **current thread** (the contract says `enforce` runs +//! post-fork / pre-exec in the child, so the restriction is inherited by the exec'd +//! program — Landlock domains survive `execve`). +//! +//! # Per-access-class handling (mirrors the LP4-0 compile invariant) +//! +//! Landlock works by *handling* a set of access rights — everything **not** granted +//! is then denied **for those handled rights only**. We therefore handle a right +//! class **only if the plan actually posed a grant of that class**: +//! +//! - some `RO` grant present ⇒ handle the **read** class (`from_read`): reads become +//! restricted to the granted roots; +//! - some `RW` grant present ⇒ handle the **write** class (`from_write`): writes / +//! create / delete become restricted to the granted roots. +//! +//! A write-only policy thus leaves **reads globally unrestricted** (so the agent can +//! still load `libc`, read `/etc`, …) and only fences writes — exactly the autonomy +//! the per-access-class compile guarantees. The flip side: a policy that restricts +//! reads governs *all* reads, so the compiled plan must include the system read +//! paths the program needs; that enrichment is the launch-path's concern (LP4-2), +//! not this adapter's — here we translate the plan faithfully. +//! +//! # Fallback policy +//! +//! On a kernel/ABI without (sufficient) Landlock, `restrict_self` reports +//! [`RulesetStatus::NotEnforced`]. We then apply the product fallback: **fail-open** +//! ([`SandboxStatus::Unsupported`]) for any posture **except** [`Posture::Deny`], +//! where we **fail-closed** with [`SandboxError::KernelTooOld`] (a `Deny` posture +//! cannot be silently dropped). Partial enforcement (older ABI missing some rights) +//! maps to [`SandboxStatus::Degraded`]. + +use domain::sandbox::{ + PathAccess, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus, +}; +use domain::Posture; + +// Leading `::` selects the extern `landlock` crate (this module is also named +// `landlock`, so the disambiguation matters). +use ::landlock::{ + path_beneath_rules, AccessFs, BitFlags, CompatLevel, Compatible, Ruleset, RulesetAttr, + RulesetCreatedAttr, RulesetStatus, ABI, +}; + +/// The Landlock ABI we author rules against. `ABI::V1` (Linux ≥ 5.13) is the broad +/// floor; `CompatLevel::BestEffort` lets newer kernels run it unchanged and older / +/// absent support degrade rather than hard-error. +const TARGET_ABI: ABI = ABI::V1; + +/// The Linux Landlock [`SandboxEnforcer`]. +#[derive(Debug, Default, Clone, Copy)] +pub struct LandlockSandbox; + +impl LandlockSandbox { + /// Builds the Landlock enforcer. + #[must_use] + pub fn new() -> Self { + Self + } +} + +/// The Landlock access rights a single grant opens, intersected later with the +/// handled set. An `RW` grant also opens the read rights (writing a tree usually +/// implies reading it) — harmless when reads are ungoverned, correct when they are. +fn grant_access(access: PathAccess, abi: ABI) -> BitFlags { + let mut acc = BitFlags::::empty(); + if access.contains(PathAccess::RO) { + acc |= AccessFs::from_read(abi); + } + if access.contains(PathAccess::RW) { + acc |= AccessFs::from_read(abi) | AccessFs::from_write(abi); + } + if access.contains(PathAccess::EXEC) { + // The domain never emits EXEC today; honoured for completeness. + acc |= AccessFs::Execute; + } + acc +} + +impl SandboxEnforcer for LandlockSandbox { + fn kind(&self) -> SandboxKind { + SandboxKind::Landlock + } + + fn enforce(&self, plan: &SandboxPlan) -> Result { + let abi = TARGET_ABI; + + // Landlock is an **additive allowlist** — it can only *remove* access, i.e. + // it is deny-by-default. A `Posture::Allow` fallback means the opposite, + // **allow-by-default**: paths matched by no grant must stay reachable. An + // allowlist cannot express that (it would lock everything down to the granted + // roots, closing the CLI binary, its libs and `~/.` home — the very + // "failed to open terminal" symptom). So under an Allow fallback we enforce + // nothing: the policy's only teeth are explicit Denies, which are deny-islands + // under an allow-all that an additive sandbox cannot carve out — they remain + // advisory via the LP3 projection. This is a property of the allowlist + // mechanism, hence it lives in the adapter, not the pure plan. + if plan.default_posture == Posture::Allow { + return Ok(SandboxStatus::Enforced); + } + + // Handle a right class only if the plan posed a grant of that class, so an + // unposed class stays globally unrestricted (per-access-class autonomy). + let mut handled = BitFlags::::empty(); + for grant in &plan.allowed { + if grant.access.contains(PathAccess::RO) { + handled |= AccessFs::from_read(abi); + } + if grant.access.contains(PathAccess::RW) { + handled |= AccessFs::from_write(abi); + } + if grant.access.contains(PathAccess::EXEC) { + handled |= AccessFs::Execute; + } + } + + if handled.is_empty() { + // No file restriction to apply (empty / bash-only plan): nothing for + // Landlock to enforce. A `Deny` posture's teeth, if any, then live only + // in the advisory LP3 projection — not this adapter's concern. + return Ok(SandboxStatus::Enforced); + } + + let mut ruleset = Ruleset::default() + .set_compatibility(CompatLevel::BestEffort) + .handle_access(handled) + .and_then(Ruleset::create) + .map_err(|e| SandboxError::Ruleset(e.to_string()))?; + + for grant in &plan.allowed { + let access = grant_access(grant.access, abi) & handled; + if access.is_empty() { + continue; + } + // `path_beneath_rules` silently skips a path it cannot open (a grant on + // a not-yet-existing root just adds no rule — best-effort, never fatal). + ruleset = ruleset + .add_rules(path_beneath_rules([grant.abs_root.as_str()], access)) + .map_err(|e: ::landlock::RulesetError| SandboxError::Ruleset(e.to_string()))?; + } + + let status = ruleset + .restrict_self() + .map_err(|e| SandboxError::Ruleset(e.to_string()))?; + + status_from_ruleset(status.ruleset, plan.default_posture) + } +} + +/// Maps the kernel's [`RulesetStatus`] (after `restrict_self`) plus the plan's +/// fallback [`Posture`] to the domain outcome. **Pure** (no I/O, no kernel): the +/// security-critical decision — including the *fail-closed only under `Deny`* +/// branch — lives here so it is unit-testable without a Landlock-incapable kernel. +/// +/// - [`RulesetStatus::FullyEnforced`] ⇒ `Ok(`[`SandboxStatus::Enforced`]`)` +/// - [`RulesetStatus::PartiallyEnforced`] ⇒ `Ok(`[`SandboxStatus::Degraded`]`)` +/// - [`RulesetStatus::NotEnforced`] + [`Posture::Deny`] ⇒ +/// `Err(`[`SandboxError::KernelTooOld`]`)` (fail-closed) +/// - [`RulesetStatus::NotEnforced`] + any other posture ⇒ +/// `Ok(`[`SandboxStatus::Unsupported`]`)` (fail-open + warning) +fn status_from_ruleset( + status: RulesetStatus, + posture: Posture, +) -> Result { + match status { + RulesetStatus::FullyEnforced => Ok(SandboxStatus::Enforced), + RulesetStatus::PartiallyEnforced => Ok(SandboxStatus::Degraded( + "landlock ABI compatibility reduced the enforced access rights (best-effort)" + .to_owned(), + )), + RulesetStatus::NotEnforced => { + if posture == Posture::Deny { + Err(SandboxError::KernelTooOld( + "landlock unavailable on this kernel but the policy posture is Deny \ + (fail-closed)" + .to_owned(), + )) + } else { + Ok(SandboxStatus::Unsupported) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::sandbox::{PathGrant, SandboxPlan}; + use std::path::PathBuf; + + /// A unique temp dir for one test (no external tempfile dep). + fn fresh_dir(tag: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let p = std::env::temp_dir().join(format!("idea-landlock-{tag}-{nanos}")); + std::fs::create_dir_all(&p).unwrap(); + p + } + + /// Real-kernel integration test: a write-only plan that grants RW on one dir + /// must let writes there succeed and block writes elsewhere with EACCES, while + /// leaving reads unrestricted. Runs the enforcement on a dedicated thread (the + /// restriction is irreversible for that thread) and **skips** if the running + /// kernel has no Landlock (CI / old kernels), like the SSH/WSL gated tests. + #[test] + fn landlock_write_only_plan_fences_writes_to_the_grant() { + let allowed = fresh_dir("allowed"); + let denied = fresh_dir("denied"); + let allowed_for_thread = allowed.clone(); + let denied_for_thread = denied.clone(); + + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: allowed.to_string_lossy().into_owned(), + access: PathAccess::RW, + }], + default_posture: Posture::Ask, + }; + + let outcome = std::thread::spawn(move || { + let status = LandlockSandbox::new() + .enforce(&plan) + .expect("enforce must not error under an Ask posture"); + // The thread is now sandboxed: try a write inside and outside the grant. + let ok_write = std::fs::write(allowed_for_thread.join("inside.txt"), b"hi"); + let ko_write = std::fs::write(denied_for_thread.join("outside.txt"), b"nope"); + (status, ok_write, ko_write.map_err(|e| e.kind())) + }) + .join() + .unwrap(); + + let (status, ok_write, ko_write) = outcome; + + if status == SandboxStatus::Unsupported { + eprintln!("skipping: Landlock not available on this kernel"); + // Clean up best-effort and bail (no enforcement happened). + let _ = std::fs::remove_dir_all(&allowed); + let _ = std::fs::remove_dir_all(&denied); + return; + } + + assert!( + matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)), + "expected an enforced/degraded sandbox, got {status:?}" + ); + assert!( + ok_write.is_ok(), + "write inside the granted root must succeed, got {ok_write:?}" + ); + assert_eq!( + ko_write, + Err(std::io::ErrorKind::PermissionDenied), + "write outside the granted root must be blocked (EACCES)" + ); + + let _ = std::fs::remove_dir_all(&allowed); + let _ = std::fs::remove_dir_all(&denied); + } + + /// An empty plan (no file grant) has nothing to enforce ⇒ `Enforced`, no error. + #[test] + fn empty_plan_is_a_noop_enforced() { + let plan = SandboxPlan { + allowed: vec![], + default_posture: Posture::Ask, + }; + // Run on a throwaway thread to avoid restricting the test runner thread. + let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan)) + .join() + .unwrap() + .unwrap(); + assert_eq!(status, SandboxStatus::Enforced); + } + + /// **Bash-only / empty plan under a `Deny` posture.** A bash-only policy compiles + /// (LP4-0) to an empty `allowed`; even combined with `Posture::Deny` the adapter + /// has no file right to *handle*, so it must early-return `Enforced` WITHOUT + /// restricting anything — and crucially WITHOUT `KernelTooOld`: the fail-closed + /// branch is only ever reached once a class was actually handled. + #[test] + fn empty_plan_under_deny_posture_is_enforced_without_restriction() { + let plan = SandboxPlan { + allowed: vec![], + default_posture: Posture::Deny, + }; + let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan)) + .join() + .unwrap() + .expect("an empty plan never fails closed (nothing handled)"); + assert_eq!(status, SandboxStatus::Enforced); + } + + /// **Allow fallback ⇒ no OS restriction even with grants posed.** Setting the + /// policy to allow-by-default (and, in the UI, every option to Allow) emits RO|RW + /// grants scoped to the project root. An allowlist sandbox would then close every + /// read/write OUTSIDE that root — locking out the CLI binary, its libs and home, + /// the exact "failed to open terminal" bug. Under `Posture::Allow` the adapter must + /// instead enforce nothing: from the (would-be) sandboxed thread, a read AND a + /// write OUTSIDE the only grant must both still succeed. + #[test] + fn allow_fallback_does_not_restrict_even_with_grants() { + let granted = fresh_dir("allow-granted"); + let outside = fresh_dir("allow-outside"); + std::fs::write(outside.join("pre.txt"), b"pre").unwrap(); + let outside_t = outside.clone(); + + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: granted.to_string_lossy().into_owned(), + access: PathAccess::RO.union(PathAccess::RW), + }], + default_posture: Posture::Allow, + }; + + let (status, read_outside, write_outside) = std::thread::spawn(move || { + let status = LandlockSandbox::new() + .enforce(&plan) + .expect("an Allow fallback never fails closed"); + let read = std::fs::read(outside_t.join("pre.txt")); + let write = + std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind()); + (status, read, write) + }) + .join() + .unwrap(); + + assert_eq!( + status, + SandboxStatus::Enforced, + "Allow fallback is a no-op enforcement, not an error or a lock-down" + ); + assert!( + read_outside.is_ok(), + "Allow fallback must keep reads outside the grant open, got {read_outside:?}" + ); + assert_eq!( + write_outside, + Ok(()), + "Allow fallback must keep writes outside the grant open" + ); + + let _ = std::fs::remove_dir_all(&granted); + let _ = std::fs::remove_dir_all(&outside); + } + + /// **CRITICAL SAFETY PROPERTY — IdeA is never sandboxed.** The adapter restricts + /// the *current thread*; the launch path runs it on a throwaway thread. This test + /// proves the Landlock domain is confined to that throwaway thread and does **not** + /// leak onto the thread that spawned it (which simulates the IdeA process): after + /// the child has sandboxed itself, the parent thread must still read/write OUTSIDE + /// the plan's roots. A leak here would be catastrophic (IdeA itself locked down). + #[test] + fn enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed() { + let granted = fresh_dir("safety-granted"); + let outside = fresh_dir("safety-outside"); + let granted_t = granted.clone(); + let outside_t = outside.clone(); + + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: granted.to_string_lossy().into_owned(), + access: PathAccess::RW, + }], + default_posture: Posture::Ask, + }; + + // Enforce on a dedicated thread, exactly as the launch path does, and report + // whether the sandbox was actually live there (write outside must be blocked). + let (status, child_outside_write) = std::thread::spawn(move || { + let status = LandlockSandbox::new() + .enforce(&plan) + .expect("enforce must not error under an Ask posture"); + let _ = granted_t; // (writable in the child; not asserted here) + let child_outside = + std::fs::write(outside_t.join("from-child.txt"), b"x").map_err(|e| e.kind()); + (status, child_outside) + }) + .join() + .unwrap(); + + if status == SandboxStatus::Unsupported { + eprintln!("skipping: Landlock not available on this kernel"); + let _ = std::fs::remove_dir_all(&granted); + let _ = std::fs::remove_dir_all(&outside); + return; + } + + // Sanity: the sandbox really was active on the throwaway thread. + assert_eq!( + child_outside_write, + Err(std::io::ErrorKind::PermissionDenied), + "the throwaway thread must be sandboxed (write outside the grant blocked)" + ); + + // THE GUARANTEE: the parent thread (≈ the IdeA process) is not a descendant of + // the throwaway thread, so the domain must not have leaked onto it. + let idea_write = std::fs::write(outside.join("from-idea.txt"), b"idea"); + assert!( + idea_write.is_ok(), + "SAFETY VIOLATION: IdeA's own thread was sandboxed — write outside the plan \ + failed ({idea_write:?}); the Landlock domain leaked off the throwaway thread" + ); + let idea_read = std::fs::read(outside.join("from-idea.txt")); + assert!( + idea_read.is_ok(), + "IdeA must still read outside the plan roots, got {idea_read:?}" + ); + + let _ = std::fs::remove_dir_all(&granted); + let _ = std::fs::remove_dir_all(&outside); + } + + /// **READ (RO) dimension.** A plan with a single RO grant: from the sandboxed + /// child, reading a file UNDER the granted root succeeds, while reading a file + /// OUTSIDE is denied (EACCES). This also documents the behaviour DevBackend + /// flagged: as soon as *any* RO grant is posed the read class is handled, so + /// **every** read outside the granted roots is closed — a realistic read-restricted + /// plan must therefore include the needed system paths (an LP4-2 concern). + #[test] + fn read_only_plan_fences_reads_to_the_grant() { + let granted = fresh_dir("ro-granted"); + let outside = fresh_dir("ro-outside"); + // Files must exist before enforcement (creation is a write). + std::fs::write(granted.join("in.txt"), b"inside").unwrap(); + std::fs::write(outside.join("out.txt"), b"outside").unwrap(); + let granted_t = granted.clone(); + let outside_t = outside.clone(); + + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: granted.to_string_lossy().into_owned(), + access: PathAccess::RO, + }], + default_posture: Posture::Ask, + }; + + let (status, ok_read, ko_read) = std::thread::spawn(move || { + let status = LandlockSandbox::new() + .enforce(&plan) + .expect("enforce must not error under an Ask posture"); + let ok = std::fs::read(granted_t.join("in.txt")); + let ko = std::fs::read(outside_t.join("out.txt")).map_err(|e| e.kind()); + (status, ok, ko) + }) + .join() + .unwrap(); + + if status == SandboxStatus::Unsupported { + eprintln!("skipping: Landlock not available on this kernel"); + let _ = std::fs::remove_dir_all(&granted); + let _ = std::fs::remove_dir_all(&outside); + return; + } + + assert!( + matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)), + "expected an enforced/degraded sandbox, got {status:?}" + ); + assert!( + ok_read.is_ok(), + "read inside the granted RO root must succeed, got {ok_read:?}" + ); + assert_eq!( + ko_read, + Err(std::io::ErrorKind::PermissionDenied), + "once a RO grant is posed, reads outside the granted roots are closed (EACCES)" + ); + + let _ = std::fs::remove_dir_all(&granted); + let _ = std::fs::remove_dir_all(&outside); + } +} diff --git a/crates/infrastructure/src/sandbox/mod.rs b/crates/infrastructure/src/sandbox/mod.rs new file mode 100644 index 0000000..d489b70 --- /dev/null +++ b/crates/infrastructure/src/sandbox/mod.rs @@ -0,0 +1,82 @@ +//! OS-sandbox adapters (lot LP4-1) — concrete [`domain::sandbox::SandboxEnforcer`] +//! implementations and the cfg-selected default constructor. +//! +//! - [`LandlockSandbox`] (Linux only) enforces a [`domain::sandbox::SandboxPlan`] +//! via the kernel Landlock LSM. +//! - [`NoopSandbox`] (everywhere) enforces nothing — the non-Linux / fallback path. +//! +//! [`default_enforcer`] returns the right one for the build target as a +//! `Arc`, ready for the composition root (LP4-3) to inject. +//! Nothing here is wired into the launch path yet (that is LP4-2): until then the +//! enforcer stays unused and `SpawnSpec.sandbox` stays `None`, so there is zero +//! runtime behaviour change. + +use std::sync::Arc; + +use domain::sandbox::SandboxEnforcer; + +mod noop; +pub use noop::NoopSandbox; + +#[cfg(target_os = "linux")] +mod landlock; +#[cfg(target_os = "linux")] +pub use landlock::LandlockSandbox; + +/// The default OS-sandbox enforcer for the current build target: [`LandlockSandbox`] +/// on Linux, [`NoopSandbox`] everywhere else. +#[cfg(target_os = "linux")] +#[must_use] +pub fn default_enforcer() -> Arc { + Arc::new(LandlockSandbox::new()) +} + +/// The default OS-sandbox enforcer for the current build target: [`NoopSandbox`] +/// on non-Linux platforms (no OS sandbox wired yet). +#[cfg(not(target_os = "linux"))] +#[must_use] +pub fn default_enforcer() -> Arc { + Arc::new(NoopSandbox::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::sandbox::{SandboxKind, SandboxPlan, SandboxStatus}; + use domain::Posture; + + #[test] + fn noop_enforcer_is_unsupported_and_never_errors() { + use domain::sandbox::PathGrant; + + let noop = NoopSandbox::new(); + assert_eq!(noop.kind(), SandboxKind::Unsupported); + + // Across every posture — and even with grants present — the no-op enforces + // nothing and never fails closed: there is no OS sandbox here to fail closed + // against (the Deny posture's teeth live only in the advisory LP3 projection). + for posture in [Posture::Allow, Posture::Ask, Posture::Deny] { + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: "/whatever".to_owned(), + access: domain::sandbox::PathAccess::RW, + }], + default_posture: posture, + }; + assert_eq!( + noop.enforce(&plan).unwrap(), + SandboxStatus::Unsupported, + "no-op must return Unsupported (never Err) under posture {posture:?}" + ); + } + } + + #[test] + fn default_enforcer_matches_the_build_target() { + let enforcer = default_enforcer(); + #[cfg(target_os = "linux")] + assert_eq!(enforcer.kind(), SandboxKind::Landlock); + #[cfg(not(target_os = "linux"))] + assert_eq!(enforcer.kind(), SandboxKind::Unsupported); + } +} diff --git a/crates/infrastructure/src/sandbox/noop.rs b/crates/infrastructure/src/sandbox/noop.rs new file mode 100644 index 0000000..5d87dba --- /dev/null +++ b/crates/infrastructure/src/sandbox/noop.rs @@ -0,0 +1,33 @@ +//! [`NoopSandbox`] — the no-op [`SandboxEnforcer`] for platforms without an OS +//! sandbox (Windows, macOS) and the universal fallback (lot LP4-1). +//! +//! It compiles **everywhere** and never restricts anything: `enforce` always +//! returns [`SandboxStatus::Unsupported`] and never errors. The caller keeps the +//! advisory LP3 projection as its only guard. + +use domain::sandbox::{SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus}; + +/// A [`SandboxEnforcer`] that enforces nothing (non-Linux / fallback). +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopSandbox; + +impl NoopSandbox { + /// Builds the no-op enforcer. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl SandboxEnforcer for NoopSandbox { + fn kind(&self) -> SandboxKind { + SandboxKind::Unsupported + } + + fn enforce(&self, _plan: &SandboxPlan) -> Result { + // No OS sandbox here: nothing enforced, never an error (the fail-closed + // posture handling is a Landlock-only concern; on an unsupported platform + // there is nothing to fail closed *against*). + Ok(SandboxStatus::Unsupported) + } +} diff --git a/crates/infrastructure/src/session/claude.rs b/crates/infrastructure/src/session/claude.rs index 6ffeb15..8b00329 100644 --- a/crates/infrastructure/src/session/claude.rs +++ b/crates/infrastructure/src/session/claude.rs @@ -13,12 +13,13 @@ //! (2026-06-09) ; **seule [`parse_event`] (et la composition de la commande) porte //! le format**, pas la machinerie ni le reste de l'adapter. -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; use serde_json::Value; use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; +use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::SessionId; use super::process::{run_turn, SpawnLine}; @@ -47,10 +48,10 @@ pub struct ParsedLine { /// Le flux est du **JSONL** (un objet JSON par ligne). Types réels : /// /// - `{"type":"system","subtype":"init","session_id":"","cwd":…,"tools":…,…}` -/// ⇒ capture le `session_id` (= id de conversation pour la reprise), **aucun** -/// événement émis. +/// ⇒ capture le `session_id` (= id de conversation pour la reprise) **et** émet un +/// [`ReplyEvent::Heartbeat`] (preuve de vivacité non terminale : la CLI a démarré). /// - `{"type":"rate_limit_event","rate_limit_info":{…},"session_id":"…"}` -/// ⇒ **ignoré** (comme tout `type` inconnu). +/// ⇒ [`ReplyEvent::Heartbeat`] (pas de contenu, mais le moteur est vivant). /// - `{"type":"assistant","message":{"role":"assistant","content":[ /// {"type":"text","text":"…"} | {"type":"tool_use","name":"…", …} /// ], …},"session_id":"…","parent_tool_use_id":null}` @@ -81,7 +82,12 @@ pub fn parse_event(line: &str) -> Result { .map(str::to_owned); let events = match value.get("type").and_then(Value::as_str) { - Some("system") => Vec::new(), // init/handshake : on ne capte que le session_id. + // init/handshake : on capte le session_id ET on émet un battement de cœur + // (preuve de vivacité non terminale : la CLI a démarré et répond). + Some("system") => vec![ReplyEvent::Heartbeat], + // Fenêtre de limite de débit : pas de contenu, mais le moteur est vivant ⇒ + // battement de cœur (readiness/heartbeat lot 1), plus ignoré. + Some("rate_limit_event") => vec![ReplyEvent::Heartbeat], Some("assistant") => assistant_events(&value), Some("result") => value .get("result") @@ -92,7 +98,7 @@ pub fn parse_event(line: &str) -> Result { }] }) .unwrap_or_default(), - _ => Vec::new(), // type inconnu / non pertinent (rate_limit_event, …) : ignoré. + _ => Vec::new(), // type inconnu / non pertinent : ignoré (robustesse). }; Ok(ParsedLine { events, session_id }) @@ -149,24 +155,36 @@ pub struct ClaudeSdkSession { cwd: String, /// Id de conversation **du moteur** Claude, capté au premier tour, `None` avant. conversation_id: Mutex>, + /// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque + /// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif). + sandbox: Option, + /// Enforcer OS **par instance** (lot LP4-4), passé à [`run_turn`]. `None` ⇒ pas + /// de sandboxing même si un plan est présent (cohérent avec le chemin PTY). + sandbox_enforcer: Option>, } impl ClaudeSdkSession { /// Construit l'adapter. `command` est le binaire à lancer (injecté ⇒ testable /// avec un fake CLI) ; `seed_conversation_id` amorce la reprise (`SessionPlan:: - /// Resume` côté factory) ou reste `None` pour une conversation neuve. + /// Resume` côté factory) ou reste `None` pour une conversation neuve. `sandbox` / + /// `sandbox_enforcer` (lot LP4-4) pilotent le sandboxing OS du tour ; `None`/`None` + /// ⇒ chemin natif inchangé. #[must_use] pub fn new( id: SessionId, command: impl Into, cwd: impl Into, seed_conversation_id: Option, + sandbox: Option, + sandbox_enforcer: Option>, ) -> Self { Self { id, command: command.into(), cwd: cwd.into(), conversation_id: Mutex::new(seed_conversation_id), + sandbox, + sandbox_enforcer, } } @@ -196,6 +214,7 @@ impl ClaudeSdkSession { cwd: self.cwd.clone(), env: Vec::new(), stdin: None, + sandbox: self.sandbox.clone(), } } } @@ -212,17 +231,26 @@ impl AgentSession for ClaudeSdkSession { async fn send(&self, prompt: &str) -> Result { let spec = self.build_spawn_line(prompt); - let raw_lines = run_turn(&spec, None).await?; + let raw_lines = run_turn(&spec, None, self.sandbox_enforcer.as_ref()).await?; let mut events = Vec::new(); let mut captured_id = None; - for line in &raw_lines { + 'lines: for line in &raw_lines { let parsed = parse_event(line)?; if let Some(id) = parsed.session_id { captured_id = Some(id); } // Aplatit : une ligne `assistant` multi-blocs rend plusieurs événements. - events.extend(parsed.events); + // Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès + // qu'on l'a vu, pour qu'aucun heartbeat de fin (`rate_limit_event` tardif…) + // ne le suive dans le flux. + for event in parsed.events { + let is_final = matches!(event, ReplyEvent::Final { .. }); + events.push(event); + if is_final { + break 'lines; + } + } } // Persiste le session_id capté (pivot de reprise) avant de rendre le flux. if let Some(id) = captured_id { diff --git a/crates/infrastructure/src/session/codex.rs b/crates/infrastructure/src/session/codex.rs index b3c683b..703bbe7 100644 --- a/crates/infrastructure/src/session/codex.rs +++ b/crates/infrastructure/src/session/codex.rs @@ -10,12 +10,13 @@ //! le format. **Seule [`parse_event`] (et la composition de la commande) porte le //! format Codex** ; la machinerie reste inchangée. -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; use serde_json::Value; use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; +use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::SessionId; use super::process::{run_turn, SpawnLine}; @@ -43,13 +44,13 @@ pub struct ParsedLine { /// /// - `{"type":"thread.started","thread_id":""}` ⇒ capte le `thread_id` /// (= id de conversation pour la reprise), **aucun** événement émis. -/// - `{"type":"turn.started"}` ⇒ ignoré. +/// - `{"type":"turn.started"}` ⇒ [`ReplyEvent::Heartbeat`] (vivacité non terminale). /// - `{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"…"}}` /// ⇒ si `item.type=="agent_message"` ⇒ [`ReplyEvent::Final`] (`content` = `item.text`, /// c'est la réponse) ; sinon (`reasoning`/`command`/autre) ⇒ /// [`ReplyEvent::ToolActivity`] (`label` = `item.type`). -/// - `{"type":"turn.completed","usage":{…}}` ⇒ ignoré (le `Final` vient de -/// l'`agent_message`). +/// - `{"type":"turn.completed","usage":{…}}` ⇒ [`ReplyEvent::Heartbeat`] (le `Final` +/// vient de l'`agent_message`, pas de `turn.completed`). /// /// Ligne vide ⇒ ignorée ; type inconnu ⇒ ignoré sans erreur ; JSON illisible ⇒ /// [`AgentSessionError::Decode`] (jamais de JSON brut propagé). @@ -75,6 +76,10 @@ pub fn parse_event(line: &str) -> Result { .and_then(Value::as_str) .map(str::to_owned); } + // Début/fin de tour côté moteur : pas de contenu, mais preuve de vivacité ⇒ + // battement de cœur non terminal (readiness/heartbeat lot 1). Le `Final` vient + // toujours de l'`agent_message`, jamais de `turn.completed`. + Some("turn.started") | Some("turn.completed") => events.push(ReplyEvent::Heartbeat), Some("item.completed") => { if let Some(item) = value.get("item") { match item.get("type").and_then(Value::as_str) { @@ -94,7 +99,7 @@ pub fn parse_event(line: &str) -> Result { } } } - // turn.started / turn.completed / type inconnu : ignoré (robustesse). + // type inconnu : ignoré (robustesse). _ => {} } @@ -119,23 +124,35 @@ pub struct CodexExecSession { cwd: String, /// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant. conversation_id: Mutex>, + /// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque + /// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif). + sandbox: Option, + /// Enforcer OS **par instance** (lot LP4-4), passé à [`run_turn`]. `None` ⇒ pas + /// de sandboxing même si un plan est présent (cohérent avec le chemin PTY). + sandbox_enforcer: Option>, } impl CodexExecSession { /// Construit l'adapter. `command` est injecté (⇒ testable avec un fake CLI) ; /// `seed_conversation_id` amorce la reprise ou reste `None` (conversation neuve). + /// `sandbox` / `sandbox_enforcer` (lot LP4-4) pilotent le sandboxing OS ; + /// `None`/`None` ⇒ chemin natif inchangé. #[must_use] pub fn new( id: SessionId, command: impl Into, cwd: impl Into, seed_conversation_id: Option, + sandbox: Option, + sandbox_enforcer: Option>, ) -> Self { Self { id, command: command.into(), cwd: cwd.into(), conversation_id: Mutex::new(seed_conversation_id), + sandbox, + sandbox_enforcer, } } @@ -172,6 +189,7 @@ impl CodexExecSession { cwd: self.cwd.clone(), env: Vec::new(), stdin: None, + sandbox: self.sandbox.clone(), } } } @@ -188,16 +206,25 @@ impl AgentSession for CodexExecSession { async fn send(&self, prompt: &str) -> Result { let spec = self.build_spawn_line(prompt); - let raw_lines = run_turn(&spec, None).await?; + let raw_lines = run_turn(&spec, None, self.sandbox_enforcer.as_ref()).await?; let mut events = Vec::new(); let mut captured_id = None; - for line in &raw_lines { + 'lines: for line in &raw_lines { let parsed = parse_event(line)?; if let Some(id) = parsed.conversation_id { captured_id = Some(id); } - events.extend(parsed.events); + // Le `Final` est **terminal** (contrat de port) : on arrête d'émettre dès + // qu'on l'a vu, pour qu'aucun heartbeat de fin (`turn.completed` postérieur) + // ne le suive dans le flux. + for event in parsed.events { + let is_final = matches!(event, ReplyEvent::Final { .. }); + events.push(event); + if is_final { + break 'lines; + } + } } if let Some(id) = captured_id { *self.conversation_id.lock().expect("mutex sain") = Some(id); diff --git a/crates/infrastructure/src/session/conformance.rs b/crates/infrastructure/src/session/conformance.rs index a3b2ad7..416beb4 100644 --- a/crates/infrastructure/src/session/conformance.rs +++ b/crates/infrastructure/src/session/conformance.rs @@ -84,6 +84,7 @@ impl FakeCli { cwd: "/".to_owned(), env: Vec::new(), stdin: None, + sandbox: None, } } } @@ -208,14 +209,17 @@ pub(crate) mod harness { } other => panic!("le dernier événement doit être Final, vu: {other:?}"), } - // Les événements avant le Final ne sont que des deltas / activités. + // Les événements avant le Final ne sont que des deltas / activités / heartbeats + // (tous **non terminaux** ; le heartbeat est une preuve de vivacité, lot 1). for e in &events[..events.len() - 1] { assert!( matches!( e, - ReplyEvent::TextDelta { .. } | ReplyEvent::ToolActivity { .. } + ReplyEvent::TextDelta { .. } + | ReplyEvent::ToolActivity { .. } + | ReplyEvent::Heartbeat ), - "avant le Final, seuls deltas/activités sont permis, vu: {e:?}" + "avant le Final, seuls deltas/activités/heartbeats sont permis, vu: {e:?}" ); } diff --git a/crates/infrastructure/src/session/factory.rs b/crates/infrastructure/src/session/factory.rs index 93b7211..5eedb3e 100644 --- a/crates/infrastructure/src/session/factory.rs +++ b/crates/infrastructure/src/session/factory.rs @@ -16,6 +16,7 @@ use domain::ports::{ }; use domain::profile::{AgentProfile, StructuredAdapter}; use domain::project::ProjectPath; +use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::SessionId; use super::claude::ClaudeSdkSession; @@ -23,17 +24,36 @@ use super::codex::CodexExecSession; /// Fabrique infra des sessions structurées, sélectionnée par le profil. /// -/// Sans état : elle instancie l'adapter au vol depuis le profil (le binaire à +/// Quasi sans état : elle instancie l'adapter au vol depuis le profil (le binaire à /// lancer = `profile.command`), de sorte qu'un seul exemplaire injecté au -/// composition root sert tous les agents (jumeau de `CliAgentRuntime`). -#[derive(Debug, Default, Clone, Copy)] -pub struct StructuredSessionFactory; +/// composition root sert tous les agents (jumeau de `CliAgentRuntime`). Le seul état +/// porté est l'**enforcer de sandbox OS** optionnel (lot LP4-4), injecté **par +/// instance** au composition root (jumeau de `PortablePtyAdapter::with_sandbox_enforcer`) +/// et apparié au plan **par lancement** dans [`start`](AgentSessionFactory::start). +#[derive(Clone, Default)] +pub struct StructuredSessionFactory { + /// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun + /// sandboxing (chemin natif inchangé, zéro régression). + sandbox_enforcer: Option>, +} impl StructuredSessionFactory { - /// Construit la fabrique. + /// Construit la fabrique (sans enforcer : chemin natif). #[must_use] - pub const fn new() -> Self { - Self + pub fn new() -> Self { + Self { + sandbox_enforcer: None, + } + } + + /// Builder additif : câble un [`SandboxEnforcer`] OS (lot LP4-4). Jumeau exact de + /// [`crate::PortablePtyAdapter::with_sandbox_enforcer`]. Avec lui, tout lancement + /// structuré dont le plan (`SpawnSpec.sandbox`) est `Some` voit ce plan appliqué + /// sur l'enfant. Sans lui (défaut), aucun tour n'est sandboxé. + #[must_use] + pub fn with_sandbox_enforcer(mut self, enforcer: Arc) -> Self { + self.sandbox_enforcer = Some(enforcer); + self } } @@ -62,6 +82,7 @@ impl AgentSessionFactory for StructuredSessionFactory { _ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, + sandbox: Option<&SandboxPlan>, ) -> Result, AgentSessionError> { let adapter = profile.structured_adapter.ok_or_else(|| { AgentSessionError::Start(format!( @@ -75,14 +96,25 @@ impl AgentSessionFactory for StructuredSessionFactory { let cwd = cwd.as_str().to_owned(); let seed = seed_conversation_id(session); + // Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par + // instance** (champ). Tous deux sont relayés à l'adapter, qui remplira + // `SpawnLine.sandbox` et passera l'enforcer à `run_turn`. `plan == None` ⇒ + // l'adapter reste sur le drain async natif. + let plan = sandbox.cloned(); + let enforcer = self.sandbox_enforcer.clone(); + // NOTE : le contexte (`_ctx`) est injecté par `LaunchAgent` (D3) via le // convention file dans le run dir *avant* l'appel à la factory (le `.md` est // déjà écrit) ; l'adapter n'a donc qu'à lancer la CLI dans ce cwd. Aucune // injection supplémentaire n'incombe ici en mode structuré (la CLI lit son // fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd). let session: Arc = match adapter { - StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(id, command, cwd, seed)), - StructuredAdapter::Codex => Arc::new(CodexExecSession::new(id, command, cwd, seed)), + StructuredAdapter::Claude => { + Arc::new(ClaudeSdkSession::new(id, command, cwd, seed, plan, enforcer)) + } + StructuredAdapter::Codex => { + Arc::new(CodexExecSession::new(id, command, cwd, seed, plan, enforcer)) + } }; Ok(session) } diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index de4084d..0d6a3b9 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -25,6 +25,11 @@ pub mod conformance; pub mod factory; pub mod process; +/// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4), +/// Linux uniquement (pair du module `pty::sandbox_e2e_tests` du lot LP4-3). +#[cfg(all(test, target_os = "linux"))] +mod sandbox_e2e; + pub use claude::ClaudeSdkSession; pub use codex::CodexExecSession; pub use conformance::FakeCli; @@ -84,7 +89,7 @@ mod tests { #[tokio::test] async fn run_turn_drains_every_line_in_order() { let fake = FakeCli::printing(&["ligne-1", "ligne-2", "ligne-3"]); - let lines = run_turn(&fake.spawn_line(), None) + let lines = run_turn(&fake.spawn_line(), None, None) .await .expect("run_turn réussit"); assert_eq!(lines, vec!["ligne-1", "ligne-2", "ligne-3"]); @@ -98,31 +103,33 @@ mod tests { cwd: "/".to_owned(), env: Vec::new(), stdin: None, + sandbox: None, }; - let err = run_turn(&spec, None).await.expect_err("doit échouer"); + let err = run_turn(&spec, None, None).await.expect_err("doit échouer"); assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}"); } // -- parse_event Claude (format RÉEL vérifié 2026-06-09) -------------- #[test] - fn claude_parse_init_captures_session_id_without_event() { + fn claude_parse_init_captures_session_id_and_heartbeats() { let parsed = claude::parse_event( r#"{"type":"system","subtype":"init","session_id":"conv-123","cwd":"/tmp","tools":[],"model":"claude-opus-4-8"}"#, ) .expect("parse ok"); assert_eq!(parsed.session_id.as_deref(), Some("conv-123")); - assert!(parsed.events.is_empty()); + // L'init capte le session_id ET émet un heartbeat (vivacité non terminale, lot 1). + assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]); } #[test] - fn claude_parse_rate_limit_event_is_ignored() { + fn claude_parse_rate_limit_event_is_heartbeat() { let parsed = claude::parse_event( r#"{"type":"rate_limit_event","rate_limit_info":{"x":1},"session_id":"conv-123"}"#, ) .expect("parse ok"); - // Type inconnu/non pertinent : ignoré (mais session_id tout de même capté). - assert!(parsed.events.is_empty()); + // Plus ignoré : preuve de vivacité ⇒ heartbeat (mais session_id tout de même capté). + assert_eq!(parsed.events, vec![ReplyEvent::Heartbeat]); assert_eq!(parsed.session_id.as_deref(), Some("conv-123")); } @@ -165,7 +172,9 @@ mod tests { assert_eq!( parsed.events, vec![ - ReplyEvent::TextDelta { text: "un".to_owned() }, + ReplyEvent::TextDelta { + text: "un".to_owned() + }, ReplyEvent::ToolActivity { label: "Read".to_owned() }, @@ -216,13 +225,18 @@ mod tests { #[test] fn codex_parse_thread_started_message_and_final() { - let sess = codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#) - .expect("ok"); + let sess = + codex::parse_event(r#"{"type":"thread.started","thread_id":"cx-9"}"#).expect("ok"); assert_eq!(sess.conversation_id.as_deref(), Some("cx-9")); + // Le handshake ne capte que le thread_id, sans événement (pas un heartbeat). assert!(sess.events.is_empty()); + // turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1). let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok"); - assert!(started.events.is_empty()); + assert_eq!(started.events, vec![ReplyEvent::Heartbeat]); + let completed = + codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok"); + assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]); let msg = codex::parse_event( r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"fini"}}"#, @@ -234,11 +248,6 @@ mod tests { content: "fini".to_owned() }] ); - - let completed = - codex::parse_event(r#"{"type":"turn.completed","usage":{"input_tokens":10}}"#) - .expect("ok"); - assert!(completed.events.is_empty()); } #[test] @@ -281,6 +290,8 @@ mod tests { fake.command(), "/", None, + None, + None, )); assert_agent_session_contract(session, "claude-conv-1", "réponse Claude").await; } @@ -293,6 +304,8 @@ mod tests { fake.command(), "/", None, + None, + None, )); assert_agent_session_contract(session, "codex-conv-1", "réponse Codex").await; } @@ -302,7 +315,7 @@ mod tests { #[tokio::test] async fn stream_is_closed_after_final() { let fake = FakeCli::printing(&claude_script()); - let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None); + let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None); let stream = session.send("x").await.expect("send ok"); let events: Vec<_> = stream.collect(); let after_final = events @@ -320,7 +333,7 @@ mod tests { r#"{"type":"system","subtype":"init","session_id":"c"}"#, "{ ceci n'est pas du json", ]); - let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None); + let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None); match session.send("x").await { Err(AgentSessionError::Decode(_)) => {} Err(other) => panic!("attendu Decode, vu: {other:?}"), @@ -371,7 +384,11 @@ mod tests { expected.insert("gemini", false); expected.insert("aider", false); - assert_eq!(profiles.len(), 4, "catalogue has the four reference profiles"); + assert_eq!( + profiles.len(), + 4, + "catalogue has the four reference profiles" + ); for profile in &profiles { let selectable = profile.is_selectable(); assert_eq!( @@ -397,7 +414,7 @@ mod tests { // Claude : la session démarre et respecte le contrat via le fake CLI. let claude = structured_profile(StructuredAdapter::Claude, &fake.command()); let session = factory - .start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None) + .start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None, None) .await .expect("start Claude ok"); let content = drain_final(session.as_ref()).await; @@ -407,7 +424,7 @@ mod tests { let fake_cx = FakeCli::printing(&codex_script()); let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command()); let session_cx = factory - .start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None) + .start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None, None) .await .expect("start Codex ok"); let content_cx = drain_final(session_cx.as_ref()).await; @@ -427,6 +444,7 @@ mod tests { &SessionPlan::Resume { conversation_id: "repris-42".to_owned(), }, + None, ) .await .expect("start resume ok"); @@ -518,8 +536,9 @@ mod tests { cwd: "/".to_owned(), env: Vec::new(), stdin: None, + sandbox: None, }; - let err = run_turn(&spec, Some(Duration::from_millis(50))) + let err = run_turn(&spec, Some(Duration::from_millis(50)), None) .await .expect_err("doit expirer"); assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}"); @@ -673,10 +692,9 @@ mod tests { label: "reasoning".into() }] ); - let c = codex::parse_event( - r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#, - ) - .unwrap(); + let c = + codex::parse_event(r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#) + .unwrap(); assert_eq!( c.events, vec![ReplyEvent::ToolActivity { @@ -707,7 +725,12 @@ mod tests { r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message"}}"#, ) .unwrap(); - assert_eq!(m.events, vec![ReplyEvent::Final { content: String::new() }]); + assert_eq!( + m.events, + vec![ReplyEvent::Final { + content: String::new() + }] + ); } /// `thread.started` capte le `thread_id` ; pas d'événement. @@ -718,19 +741,13 @@ mod tests { assert!(p.events.is_empty()); } - /// Ligne vide / type inconnu / turn.started / turn.completed ⇒ ignorés sans erreur. + /// Ligne vide / type inconnu ⇒ ignorés sans erreur. (turn.started/completed sont + /// désormais des heartbeats : couverts par `codex_parse_thread_started_message_and_final`.) #[test] fn codex_empty_and_unknown_ignored() { assert_eq!(codex::parse_event("").unwrap(), Default::default()); - assert!(codex::parse_event(r#"{"type":"heartbeat"}"#) - .unwrap() - .events - .is_empty()); - assert!(codex::parse_event(r#"{"type":"turn.started"}"#) - .unwrap() - .events - .is_empty()); - assert!(codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#) + // Un `type` inconnu reste ignoré (robustesse), pas un heartbeat. + assert!(codex::parse_event(r#"{"type":"telemetry"}"#) .unwrap() .events .is_empty()); @@ -738,8 +755,12 @@ mod tests { // ---- Machinerie process via FakeCli --------------------------------- - /// Deltas PUIS Final : le flux ne contient rien après le `Final` (déjà couvert - /// pour Claude ; ici on le prouve aussi pour Codex, substituabilité Liskov). + /// Deltas PUIS Final : **exactement un** `Final`, et aucun autre `Final` après lui + /// (substituabilité Liskov). Note (lot 1) : un `turn.completed` postérieur émet un + /// `Heartbeat` non terminal — légitimement après le `Final` —, donc on ne teste plus + /// « rien après le Final » mais « pas de second Final, et seul un heartbeat peut + /// suivre ». Le rendez-vous synchrone (`drain_to_final`) s'arrête de toute façon au + /// premier `Final`. #[tokio::test] async fn codex_stream_closed_after_final() { let fake = FakeCli::printing(&[ @@ -748,14 +769,21 @@ mod tests { r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#, r#"{"type":"turn.completed","usage":{}}"#, ]); - let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None); + let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None, None, None); let events: Vec<_> = s.send("x").await.expect("send").collect(); - let after = events + let finals = events + .iter() + .filter(|e| matches!(e, ReplyEvent::Final { .. })) + .count(); + assert_eq!(finals, 1, "exactement un Final"); + // Après le Final, seuls des événements non terminaux (heartbeat) peuvent suivre. + let after_final_terminals = events .iter() .skip_while(|e| !matches!(e, ReplyEvent::Final { .. })) .skip(1) + .filter(|e| matches!(e, ReplyEvent::Final { .. })) .count(); - assert_eq!(after, 0); + assert_eq!(after_final_terminals, 0, "aucun second Final après le premier"); } /// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au @@ -769,7 +797,7 @@ mod tests { r#"{"type":"system","subtype":"init","session_id":"c"}"#, r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#, ]); - let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None); + let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None); let events: Vec<_> = s.send("x").await.expect("send ok").collect(); let finals = events .iter() @@ -785,7 +813,7 @@ mod tests { #[tokio::test] async fn run_turn_empty_output_is_ok() { let fake = FakeCli::printing(&[]); - let lines = run_turn(&fake.spawn_line(), None).await.expect("ok"); + let lines = run_turn(&fake.spawn_line(), None, None).await.expect("ok"); assert!(lines.is_empty()); } @@ -795,7 +823,7 @@ mod tests { let fake = FakeCli::printing(&["pong"]); let mut spec = fake.spawn_line(); spec.stdin = Some("ping".to_owned()); - let lines = run_turn(&spec, None).await.expect("ok"); + let lines = run_turn(&spec, None, None).await.expect("ok"); assert_eq!(lines, vec!["pong"]); } @@ -856,8 +884,9 @@ mod tests { cwd: "/".to_owned(), env: Vec::new(), stdin: None, + sandbox: None, }; - let err = run_turn(&spec, Some(Duration::from_millis(50))) + let err = run_turn(&spec, Some(Duration::from_millis(50)), None) .await .expect_err("doit expirer"); assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}"); @@ -878,6 +907,8 @@ mod tests { cmd.clone(), "/", Some("resume-id".to_owned()), + None, + None, ); // conversation_id amorcé avant tout tour. assert_eq!(session.conversation_id().as_deref(), Some("resume-id")); @@ -922,6 +953,8 @@ mod tests { cmd.clone(), "/", Some("cx-id".to_owned()), + None, + None, ); let _ = session.send("vas-y").await.expect("send ok"); let recorded = std::fs::read_to_string(&argv).expect("argv"); @@ -944,7 +977,7 @@ mod tests { r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#, r#"{"type":"result","subtype":"success","result":"r","session_id":"captured-1"}"#, ]); - let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None); + let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None); assert_eq!(session.conversation_id(), None); let _ = session.send("t1").await.expect("t1"); assert_eq!(session.conversation_id().as_deref(), Some("captured-1")); @@ -983,6 +1016,7 @@ mod tests { &SessionPlan::Resume { conversation_id: "cx-resume".to_owned(), }, + None, ) .await .expect("start resume codex"); @@ -1005,6 +1039,7 @@ mod tests { &SessionPlan::Assign { conversation_id: "ignored-by-engine".to_owned(), }, + None, ) .await .expect("start assign"); @@ -1029,7 +1064,7 @@ mod tests { ) .expect("profil valide"); match factory - .start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None) + .start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, None) .await { Err(AgentSessionError::Start(_)) => {} @@ -1052,6 +1087,8 @@ mod tests { fake.command(), "/", None, + None, + None, )); assert_agent_session_contract(session, "cx-0", "direct").await; } @@ -1080,11 +1117,13 @@ mod tests { r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"T"},{"type":"text","text":"b"}]},"session_id":"flow-1","parent_tool_use_id":null}"#, r#"{"type":"result","subtype":"success","is_error":false,"result":"final-ok","session_id":"flow-1","num_turns":1}"#, ]); - let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None); + let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None); let events: Vec = session.send("x").await.expect("send ok").collect(); assert_eq!( events, vec![ + // L'init `system` émet un heartbeat (vivacité non terminale, lot 1). + ReplyEvent::Heartbeat, ReplyEvent::TextDelta { text: "a".into() }, ReplyEvent::ToolActivity { label: "T".into() }, ReplyEvent::TextDelta { text: "b".into() }, @@ -1092,7 +1131,7 @@ mod tests { content: "final-ok".into() }, ], - "le flot complet doit aplatir les 3 blocs PUIS un seul Final" + "heartbeat d'init, puis les 3 blocs aplatis, PUIS un seul Final" ); // Un seul Final, en dernière position (redondant mais explicite). assert_eq!( @@ -1115,7 +1154,7 @@ mod tests { r#"{"type":"system","subtype":"init","session_id":"new-1"}"#, r#"{"type":"result","subtype":"success","result":"r","session_id":"new-1"}"#, ]); - let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None); + let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None); let _ = session.send("bonjour").await.expect("send ok"); let recorded = std::fs::read_to_string(&argv).expect("argv"); let args: Vec<&str> = recorded.lines().collect(); @@ -1147,7 +1186,7 @@ mod tests { r#"{"type":"thread.started","thread_id":"cx-new"}"#, r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#, ]); - let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None); + let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None); let _ = session.send("salut").await.expect("send ok"); let recorded = std::fs::read_to_string(&argv).expect("argv"); let args: Vec<&str> = recorded.lines().collect(); @@ -1160,7 +1199,11 @@ mod tests { assert!(args.contains(&"--skip-git-repo-check"), "vu: {args:?}"); assert!(args.contains(&"salut"), "vu: {args:?}"); // `exec` est bien la 1re sous-commande (position 0 de l'argv). - assert_eq!(args.first(), Some(&"exec"), "exec doit ouvrir l'argv, vu: {args:?}"); + assert_eq!( + args.first(), + Some(&"exec"), + "exec doit ouvrir l'argv, vu: {args:?}" + ); let _ = std::fs::remove_file(&cmd); let _ = std::fs::remove_file(&argv); } @@ -1185,7 +1228,7 @@ mod tests { r#"{"type":"thread.started","thread_id":"cx-new"}"#, r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#, ]); - let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None); + let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None); let _ = session.send("salut").await.expect("send ok"); let recorded = std::fs::read_to_string(&argv).expect("argv"); let args: Vec<&str> = recorded.lines().collect(); @@ -1219,6 +1262,8 @@ mod tests { cmd.clone(), "/", Some("cx-id".to_owned()), + None, + None, ); let _ = session.send("vas-y").await.expect("send ok"); let recorded = std::fs::read_to_string(&argv).expect("argv"); diff --git a/crates/infrastructure/src/session/process.rs b/crates/infrastructure/src/session/process.rs index 1de58cf..0b82d37 100644 --- a/crates/infrastructure/src/session/process.rs +++ b/crates/infrastructure/src/session/process.rs @@ -28,12 +28,14 @@ use std::io; use std::process::Stdio; +use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; use domain::ports::AgentSessionError; +use domain::sandbox::{SandboxEnforcer, SandboxPlan}; /// Une invocation orientée lignes : binaire + arguments + cwd + env + prompt à /// pousser sur stdin (le cas échéant). **Paramétrable par la commande** : c'est ce @@ -51,6 +53,11 @@ pub struct SpawnLine { /// Contenu à écrire sur stdin du process (`None` ⇒ stdin fermé immédiatement). /// Sert au mode `--input-format stream-json` / au passage du prompt. pub stdin: Option, + /// Plan de sandbox OS à appliquer sur l'enfant (lot LP4-4). `None` ⇒ aucun + /// sandboxing : `run_turn` emprunte le drain async tokio **inchangé** (invariant + /// produit : rien posé ⇒ comportement natif). `Some` **et** un enforcer fourni à + /// [`run_turn`] ⇒ le plan est appliqué sur l'enfant via [`drain_sandboxed`]. + pub sandbox: Option, } /// Lance `spec`, pousse `spec.stdin` sur l'entrée standard, **draine toutes les @@ -61,14 +68,33 @@ pub struct SpawnLine { /// [`AgentSessionError::Timeout`] est retourné (la machinerie ne suppose jamais que /// l'appelant veut attendre indéfiniment). `None` ⇒ pas de borne. /// +/// Quand `spec.sandbox` porte un plan **et** qu'un `enforcer` est fourni (chemin +/// Linux uniquement, lot LP4-4), le tour passe par [`run_turn_sandboxed`] : l'enfant +/// est lancé sous le domaine Landlock. Sinon — et **partout** hors Linux — c'est le +/// drain async tokio historique, strictement inchangé (zéro régression). +/// /// # Errors -/// - [`AgentSessionError::Start`] si le process ne démarre pas (binaire introuvable) ; +/// - [`AgentSessionError::Start`] si le process ne démarre pas (binaire introuvable, +/// ou enforcement de sandbox impossible : fail-closed, **aucun** enfant ne tourne) ; /// - [`AgentSessionError::Io`] sur échec de lecture/écriture des pipes ; /// - [`AgentSessionError::Timeout`] si `timeout` expire. pub async fn run_turn( spec: &SpawnLine, timeout: Option, + enforcer: Option<&Arc>, ) -> Result, AgentSessionError> { + // Chemin SANDBOXÉ (Linux + plan posé + enforcer câblé) : transpose la technique + // du PTY (`spawn_command_sandboxed`) — enforce sur un thread jetable AVANT le fork, + // l'enfant hérite le domaine via fork+exec. + #[cfg(target_os = "linux")] + if let (Some(plan), Some(enforcer)) = (spec.sandbox.as_ref(), enforcer) { + return run_turn_sandboxed(spec, plan.clone(), Arc::clone(enforcer), timeout).await; + } + // Hors Linux : aucun sandboxing OS ⇒ on ignore l'enforcer (Noop de toute façon) et + // on garde le drain async historique. `let _` évite l'avertissement « unused ». + #[cfg(not(target_os = "linux"))] + let _ = enforcer; + match timeout { Some(dur) => match tokio::time::timeout(dur, drain(spec)).await { Ok(result) => result, @@ -78,6 +104,182 @@ pub async fn run_turn( } } +/// Variante **sandboxée** du tour (lot LP4-4, Linux seulement). Le drain bloquant +/// `std::process` est exécuté sur un **thread jetable** : on y restreint le thread +/// (`enforcer.enforce(plan)`) AVANT le `spawn`, puis on lance l'enfant. La technique +/// reproduit celle du PTY ([`crate::pty`]) : `pre_exec(enforce)` est INTERDIT (Landlock +/// alloue ⇒ risque de deadlock `malloc` post-`fork` en process multithreadé), on +/// s'appuie donc sur l'**héritage du domaine Landlock**. +/// +/// ## Pourquoi pas de `pre_exec` (divergence assumée du cadrage) +/// +/// Le cadrage proposait un `pre_exec` **vide** pour forcer `std` sur le chemin +/// déterministe `fork`+`exec` (jamais `posix_spawn`). Or `pre_exec` est `unsafe`, et +/// cette crate est `#![forbid(unsafe_code)]` (invariant non contournable localement). +/// On s'en passe sans perte de garantie : `landlock_restrict_self` restreint le +/// **thread courant et toute sa descendance**, héritage assuré par le noyau à travers +/// `fork`/`clone`/`vfork` **et** préservé par `execve` — donc aussi via `posix_spawn` +/// (qui est `clone`+`execve` sous le capot), car l'enforcement vit au niveau des +/// *credentials* de la tâche, hors d'atteinte de l'espace utilisateur. Le PTY n'obtenait +/// le `fork`+`exec` que comme **effet de bord** du `pre_exec` interne de `portable-pty` ; +/// la garantie de sécurité, elle, ne repose que sur cet héritage. Le thread meurt avec +/// sa restriction, les autres threads d'IdeA ne sont jamais touchés. +/// +/// `enforce` fail-closed : un `Err` ⇒ [`AgentSessionError::Start`] et **aucun** enfant. +/// +/// ## Timeout sous sandbox +/// +/// Le thread bloquant n'est pas annulable « de l'extérieur ». On le réconcilie avec +/// l'async par deux canaux oneshot : le thread renvoie un **killer** +/// (`Arc>`) juste après le spawn, puis son résultat à la fin. On pose +/// `tokio::time::timeout` sur la réception du résultat ; à expiration on **tue +/// l'enfant** via le killer ⇒ EOF côté stdout ⇒ le thread sort de sa boucle de drain, +/// `wait()` (reap, pas de zombie) et se termine. On renvoie alors [`AgentSessionError::Timeout`]. +#[cfg(target_os = "linux")] +async fn run_turn_sandboxed( + spec: &SpawnLine, + plan: SandboxPlan, + enforcer: Arc, + timeout: Option, +) -> Result, AgentSessionError> { + use std::sync::Mutex as StdMutex; + + // Données possédées : rien n'emprunte le thread jetable. + let command = spec.command.clone(); + let args = spec.args.clone(); + let cwd = spec.cwd.clone(); + let env = spec.env.clone(); + let stdin = spec.stdin.clone(); + + let (killer_tx, killer_rx) = + tokio::sync::oneshot::channel::>>(); + let (done_tx, done_rx) = + tokio::sync::oneshot::channel::, AgentSessionError>>(); + + // Thread JETABLE : sa restriction Landlock meurt avec lui. + std::thread::spawn(move || { + let result = + drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx); + // Le récepteur peut avoir abandonné (timeout) : on ignore l'erreur d'envoi. + let _ = done_tx.send(result); + }); + + match timeout { + Some(dur) => match tokio::time::timeout(dur, done_rx).await { + // Le thread a fini dans les temps (succès ou erreur métier). + Ok(Ok(result)) => result, + // Sender lâché sans valeur (panique du thread) ⇒ I/O. + Ok(Err(_canceled)) => Err(AgentSessionError::Io( + "thread sandbox terminé sans résultat".to_owned(), + )), + // Expiration : tue l'enfant (⇒ EOF ⇒ le thread se termine et reap). + Err(_elapsed) => { + if let Ok(child) = killer_rx.await { + if let Ok(mut c) = child.lock() { + let _ = c.kill(); + } + } + Err(AgentSessionError::Timeout) + } + }, + None => match done_rx.await { + Ok(result) => result, + Err(_canceled) => Err(AgentSessionError::Io( + "thread sandbox terminé sans résultat".to_owned(), + )), + }, + } +} + +/// Drain **bloquant et sandboxé** exécuté sur le thread jetable (lot LP4-4, Linux). +/// +/// 1. `enforce(plan)` restreint CE thread (fail-closed) — la descendance hérite le +/// domaine Landlock (cf. doc de [`run_turn_sandboxed`]) ; +/// 2. `std::process::Command::spawn` (aucun `pre_exec` : `unsafe` interdit ici) ; +/// 3. pousse le prompt sur stdin puis EOF ; +/// 4. sort `stdout` du child **avant** de le partager : le drain lit sans tenir le +/// `Mutex`, donc le killer (timeout) peut verrouiller et tuer à tout moment ; +/// 5. draine ligne-à-ligne jusqu'à EOF ; +/// 6. `wait()` (reap) — pas de zombie. +#[cfg(target_os = "linux")] +#[allow(clippy::too_many_arguments)] +fn drain_sandboxed( + command: String, + args: Vec, + cwd: String, + env: Vec<(String, String)>, + stdin_content: Option, + enforcer: &Arc, + plan: &SandboxPlan, + killer_tx: tokio::sync::oneshot::Sender>>, +) -> Result, AgentSessionError> { + use std::io::{BufRead, BufReader as StdBufReader, Write as _}; + use std::process::{Command as StdCommand, Stdio}; + use std::sync::Mutex as StdMutex; + + // 1. Restreint CE thread AVANT tout fork (fail-closed : Err ⇒ aucun child ne tourne). + enforcer + .enforce(plan) + .map_err(|e| AgentSessionError::Start(format!("sandbox enforcement failed: {e}")))?; + + // 2. Commande std (≡ `drain` async, mais synchrone). + let mut cmd = StdCommand::new(&command); + cmd.args(&args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if cwd != "/" && !cwd.is_empty() { + cmd.current_dir(&cwd); + } + for (key, value) in &env { + cmd.env(key, value); + } + // Pas de `pre_exec` : il serait `unsafe` (interdit dans cette crate). L'enfant + // hérite de toute façon le domaine Landlock posé sur ce thread (cf. doc de + // `run_turn_sandboxed`), que `std` emprunte `posix_spawn` ou `fork`+`exec`. + + let mut child = cmd + .spawn() + .map_err(|e| AgentSessionError::Start(format!("{command}: {e}")))?; + + // 3. Pousse le prompt sur stdin puis le ferme (EOF). Erreur d'I/O ⇒ `Io`. + if let Some(input) = &stdin_content { + let mut si = child + .stdin + .take() + .ok_or_else(|| AgentSessionError::Io("stdin pipe indisponible".to_owned()))?; + si.write_all(input.as_bytes()) + .map_err(|e| AgentSessionError::Io(e.to_string()))?; + drop(si); + } else { + drop(child.stdin.take()); + } + + // 4. Sort stdout AVANT de partager le child (drain sans lock ⇒ killer libre). + let stdout = child + .stdout + .take() + .ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?; + let child = Arc::new(StdMutex::new(child)); + // Donne au côté async de quoi tuer l'enfant en cas de timeout. + let _ = killer_tx.send(Arc::clone(&child)); + + // 5. Drain ligne-à-ligne jusqu'à EOF. Un kill côté async ferme stdout ⇒ EOF. + let mut collected = Vec::new(); + for line in StdBufReader::new(stdout).lines() { + match line { + Ok(l) => collected.push(l), + Err(e) => return Err(AgentSessionError::Io(e.to_string())), + } + } + + // 6. Reap (pas de zombie). Lock tenu brièvement. + if let Ok(mut c) = child.lock() { + let _ = c.wait(); + } + Ok(collected) +} + /// Cœur du drain : spawn → écriture stdin → lecture ligne-à-ligne → wait. async fn drain(spec: &SpawnLine) -> Result, AgentSessionError> { let mut cmd = Command::new(&spec.command); diff --git a/crates/infrastructure/src/session/sandbox_e2e.rs b/crates/infrastructure/src/session/sandbox_e2e.rs new file mode 100644 index 0000000..59cdc41 --- /dev/null +++ b/crates/infrastructure/src/session/sandbox_e2e.rs @@ -0,0 +1,479 @@ +//! **Tests bout-en-bout de l'enforcement Landlock sur le chemin STRUCTURÉ** (lot +//! LP4-4). Pair du module `sandbox_e2e_tests` ajouté dans [`crate::pty`] au lot +//! LP4-3 (chemin PTY brut) ; ici la cible est la machinerie des sessions structurées +//! (Claude/Codex en mode JSON) : `run_turn` → `run_turn_sandboxed` → `drain_sandboxed` +//! (thread jetable restreint par `enforce()` AVANT le spawn std, puis héritage du +//! domaine Landlock à travers `fork`/`exec`). +//! +//! **ZÉRO token** : aucun vrai `claude`/`codex` n'est lancé. On substitue soit `sh` +//! (qui émet une ligne JSONL puis tente des écritures FS), soit le [`FakeCli`] +//! scriptable existant. Tout est derrière `#[cfg(all(test, target_os = "linux"))]` +//! et — pour les assertions qui exigent un fencing réel — derrière le garde +//! [`landlock_is_enforced`] (skip propre sur un kernel sans Landlock, comme les +//! tests de l'adapter et du PTY). +//! +//! ## Couverture des 7 invariants (Architecte) +//! +//! 1. **Parité** — `pty_structured_run_turn_enforces_plan_end_to_end` +//! 2. **Companion négatif** — `structured_run_turn_without_plan_does_not_sandbox` +//! 3. **Fail-closed** — `structured_run_turn_fail_closed_no_child_on_enforce_err` +//! 4. **No-op par défaut** — `structured_run_turn_none_plan_is_native_path` +//! 5. **Confinement/irréversibilité** — `structured_two_turns_disjoint_grants_are_confined` +//! 6. **Timeout sous sandbox** — `structured_run_turn_timeout_under_sandbox` +//! 7. **Resume préservé** — `structured_sandboxed_turn_preserves_conversation_id` + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use domain::sandbox::{ + PathAccess, PathGrant, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus, +}; +use domain::Posture; + +use super::process::{run_turn, SpawnLine}; + +// --------------------------------------------------------------------------- +// Helpers (calqués sur sandbox/landlock.rs et pty::sandbox_e2e_tests) +// --------------------------------------------------------------------------- + +/// Un répertoire temporaire unique pour un test (zéro dépendance tempfile). +fn fresh_dir(tag: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let p = std::env::temp_dir().join(format!("idea-struct-sbx-{tag}-{nanos}")); + std::fs::create_dir_all(&p).unwrap(); + p +} + +/// Sonde si le kernel courant **applique réellement** Landlock, exactement comme le +/// chemin de lancement l'observerait : `enforce` d'un petit plan RW sur un **thread +/// jetable** (la restriction est irréversible ⇒ jamais sur le thread de test) et +/// renvoie `false` sur [`SandboxStatus::Unsupported`]. Permet le skip propre. +fn landlock_is_enforced() -> bool { + let probe = fresh_dir("probe"); + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: probe.to_string_lossy().into_owned(), + access: PathAccess::RW, + }], + default_posture: Posture::Ask, + }; + let status = std::thread::spawn(move || crate::sandbox::LandlockSandbox::new().enforce(&plan)) + .join() + .unwrap() + .expect("enforce ne doit pas échouer sous une posture Ask"); + let _ = std::fs::remove_dir_all(&probe); + !matches!(status, SandboxStatus::Unsupported) +} + +/// Plan RW sur un seul répertoire, posture `Ask` (jamais fail-closed). +fn rw_plan(root: &Path) -> SandboxPlan { + SandboxPlan { + allowed: vec![PathGrant { + abs_root: root.to_string_lossy().into_owned(), + access: PathAccess::RW, + }], + default_posture: Posture::Ask, + } +} + +/// Construit un [`SpawnLine`] sur `sh -c` qui **émet une ligne JSONL** sur stdout +/// (preuve que le drain/parsing du chemin structuré survit au sandbox) puis tente +/// d'écrire HORS grant **puis** DANS le grant. L'écriture hors-grant est tentée en +/// premier : si le marqueur in-grant apparaît, la tentative hors-grant a déjà eu lieu. +/// `json_line` ne doit contenir que des guillemets doubles (pas de quote simple). +fn writing_spawn_line( + json_line: &str, + denied_marker: &Path, + allowed_marker: &Path, + plan: Option, +) -> SpawnLine { + let script = format!( + "printf '%s\\n' '{json}'; echo outside > '{denied}'; echo inside > '{allowed}'", + json = json_line, + denied = denied_marker.display(), + allowed = allowed_marker.display(), + ); + SpawnLine { + command: "sh".to_owned(), + args: vec!["-c".to_owned(), script], + cwd: "/".to_owned(), + env: Vec::new(), + stdin: None, + sandbox: plan, + } +} + +/// Une ligne JSONL `result` réaliste (format Claude vérifié) — sans quote simple. +const RESULT_LINE: &str = + r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s-1"}"#; + +// --------------------------------------------------------------------------- +// INVARIANT 1 — PARITÉ (test pivot) +// --------------------------------------------------------------------------- + +/// **Pivot.** Le chemin structuré sandboxé (`run_turn` + plan + enforcer Landlock) +/// laisse l'écriture DANS le grant réussir, bloque (noyau) l'écriture HORS grant, et +/// **draine quand même** la ligne JSONL émise sur stdout (le parsing n'est pas cassé +/// par la restriction FS). Strictement équivalent au pivot PTY du lot LP4-3, mais via +/// la machinerie `process::run_turn` (chemin Claude/Codex JSON). +#[tokio::test] +async fn pty_structured_run_turn_enforces_plan_end_to_end() { + if !landlock_is_enforced() { + eprintln!("skip pty_structured_run_turn_enforces_plan_end_to_end: Landlock indisponible"); + return; + } + let allowed = fresh_dir("p1-allowed"); + let denied = fresh_dir("p1-denied"); + let allowed_marker = allowed.join("in.txt"); + let denied_marker = denied.join("out.txt"); + + let spec = writing_spawn_line( + RESULT_LINE, + &denied_marker, + &allowed_marker, + Some(rw_plan(&allowed)), + ); + let enforcer = crate::sandbox::default_enforcer(); + + let lines = run_turn(&spec, None, Some(&enforcer)) + .await + .expect("run_turn sandboxé réussit sous posture Ask"); + + // Le drain a bien remonté la ligne JSONL (parsing intact sous sandbox). + assert_eq!( + lines, + vec![RESULT_LINE.to_owned()], + "la ligne JSONL doit être drainée malgré la restriction FS" + ); + // L'écriture in-grant a réussi… + assert!( + allowed_marker.exists(), + "écriture in-grant doit réussir: {allowed_marker:?} absent — le grant a été mal fencé" + ); + // …et l'écriture hors-grant a été bloquée par le noyau. + assert!( + !denied_marker.exists(), + "BRÈCHE SANDBOX: écriture hors-grant réussie ({denied_marker:?}) — plan NON enforce sur le chemin structuré" + ); + + let _ = std::fs::remove_dir_all(&allowed); + let _ = std::fs::remove_dir_all(&denied); +} + +// --------------------------------------------------------------------------- +// INVARIANT 2 — COMPANION NÉGATIF +// --------------------------------------------------------------------------- + +/// Même machinerie + enforcer câblé, mais `sandbox == None` ⇒ l'écriture hors-grant +/// RÉUSSIT. Prouve que le blocage de l'invariant 1 vient du **plan enforce**, pas +/// d'une restriction ambiante du chemin structuré. (Contrôle anti faux-positif.) +#[tokio::test] +async fn structured_run_turn_without_plan_does_not_sandbox() { + let allowed = fresh_dir("p2-allowed"); + let denied = fresh_dir("p2-denied"); + let allowed_marker = allowed.join("in.txt"); + let denied_marker = denied.join("out.txt"); + + // plan = None ⇒ chemin natif, l'enforcer est ignoré même s'il est fourni. + let spec = writing_spawn_line(RESULT_LINE, &denied_marker, &allowed_marker, None); + let enforcer = crate::sandbox::default_enforcer(); + + let lines = run_turn(&spec, None, Some(&enforcer)) + .await + .expect("run_turn natif réussit"); + + assert_eq!(lines, vec![RESULT_LINE.to_owned()]); + assert!(allowed_marker.exists(), "écriture in-grant réussit (sans plan)"); + assert!( + denied_marker.exists(), + "sans plan, l'écriture hors-grant DOIT réussir: {denied_marker:?} absent — restriction ambiante anormale" + ); + + let _ = std::fs::remove_dir_all(&allowed); + let _ = std::fs::remove_dir_all(&denied); +} + +// --------------------------------------------------------------------------- +// INVARIANT 3 — FAIL-CLOSED +// --------------------------------------------------------------------------- + +/// Enforcer double qui **échoue toujours** — simule fidèlement le cas réel +/// « posture Deny sur kernel sans Landlock » (où `enforce` renvoie +/// [`SandboxError::KernelTooOld`]), de façon **déterministe** et indépendante du +/// kernel de CI (impossible de forcer `NotEnforced` sur une machine Landlock-capable). +#[derive(Debug)] +struct AlwaysFailEnforcer; + +impl SandboxEnforcer for AlwaysFailEnforcer { + fn kind(&self) -> SandboxKind { + SandboxKind::Landlock + } + fn enforce(&self, _plan: &SandboxPlan) -> Result { + Err(SandboxError::KernelTooOld( + "simulé: Landlock indisponible + posture Deny ⇒ fail-closed".to_owned(), + )) + } +} + +/// **Fail-closed.** Quand `enforce` renvoie `Err`, `run_turn` (chemin sandboxé) doit +/// remonter [`AgentSessionError::Start`] et **aucun enfant ne tourne** : le marqueur +/// que le child aurait écrit reste absent. C'est le câblage critique de sécurité du +/// chemin structuré (équivalent du fail-closed PTY). +#[tokio::test] +async fn structured_run_turn_fail_closed_no_child_on_enforce_err() { + use domain::ports::AgentSessionError; + + let dir = fresh_dir("p3"); + let marker = dir.join("should-not-exist.txt"); + + // Plan posture Deny (cohérent avec le scénario simulé) + enforcer qui échoue. + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: dir.to_string_lossy().into_owned(), + access: PathAccess::RW, + }], + default_posture: Posture::Deny, + }; + // Le child écrirait CE marqueur s'il était lancé : il ne doit jamais l'être. + let script = format!("echo ran > '{}'", marker.display()); + let spec = SpawnLine { + command: "sh".to_owned(), + args: vec!["-c".to_owned(), script], + cwd: "/".to_owned(), + env: Vec::new(), + stdin: None, + sandbox: Some(plan), + }; + let enforcer: Arc = Arc::new(AlwaysFailEnforcer); + + let err = run_turn(&spec, None, Some(&enforcer)) + .await + .expect_err("enforce Err ⇒ run_turn doit échouer (fail-closed)"); + assert!( + matches!(err, AgentSessionError::Start(_)), + "fail-closed doit remonter Start, vu: {err:?}" + ); + assert!( + !marker.exists(), + "FAIL-CLOSED VIOLÉ: un enfant a tourné ({marker:?} existe) malgré l'échec d'enforce" + ); + + let _ = std::fs::remove_dir_all(&dir); +} + +// --------------------------------------------------------------------------- +// INVARIANT 4 — NO-OP PAR DÉFAUT +// --------------------------------------------------------------------------- + +/// **No-op par défaut.** `plan == None` (effective_permissions None) ⇒ le chemin async +/// tokio historique est emprunté, comportement natif inchangé : le drain remonte les +/// lignes et **aucune** restriction n'est posée (une écriture arbitraire réussit), +/// y compris quand un enforcer est pourtant câblé sur la fabrique. La non-régression +/// du gros de la suite structurée (conformance/D0/D3) confirme l'absence d'effet de bord. +#[tokio::test] +async fn structured_run_turn_none_plan_is_native_path() { + let dir = fresh_dir("p4"); + let marker = dir.join("native.txt"); + let script = format!("printf '%s\\n' '{RESULT_LINE}'; echo ok > '{}'", marker.display()); + let spec = SpawnLine { + command: "sh".to_owned(), + args: vec!["-c".to_owned(), script], + cwd: "/".to_owned(), + env: Vec::new(), + stdin: None, + sandbox: None, // ⇐ rien posé ⇒ chemin natif + }; + let enforcer = crate::sandbox::default_enforcer(); + + // Enforcer fourni MAIS plan None ⇒ la branche sandboxée n'est pas prise. + let lines = run_turn(&spec, None, Some(&enforcer)) + .await + .expect("chemin natif réussit"); + assert_eq!(lines, vec![RESULT_LINE.to_owned()]); + assert!( + marker.exists(), + "sans plan, aucune restriction: l'écriture native doit réussir" + ); + + let _ = std::fs::remove_dir_all(&dir); +} + +// --------------------------------------------------------------------------- +// INVARIANT 5 — CONFINEMENT / IRRÉVERSIBILITÉ +// --------------------------------------------------------------------------- + +/// **Confinement.** Deux tours successifs (instances `run_turn` distinctes) avec des +/// grants **disjoints** : chacun ne voit que son propre périmètre. Le thread jetable +/// du 1er tour meurt avec sa restriction ⇒ le 2e tour n'en hérite pas (et inversement). +/// Prouve que l'enforcement ne « bave » pas d'un tour à l'autre ni sur IdeA. +#[tokio::test] +async fn structured_two_turns_disjoint_grants_are_confined() { + if !landlock_is_enforced() { + eprintln!("skip structured_two_turns_disjoint_grants_are_confined: Landlock indisponible"); + return; + } + let dir_a = fresh_dir("p5-a"); + let dir_b = fresh_dir("p5-b"); + let enforcer = crate::sandbox::default_enforcer(); + + // --- Tour A : grant = dir_a. Écrit dans A (ok) puis B (bloqué). --- + let a_in = dir_a.join("in.txt"); + let a_into_b = dir_b.join("from-a.txt"); + let spec_a = writing_spawn_line(RESULT_LINE, &a_into_b, &a_in, Some(rw_plan(&dir_a))); + run_turn(&spec_a, None, Some(&enforcer)) + .await + .expect("tour A ok"); + assert!(a_in.exists(), "tour A: écriture dans son grant (A) doit réussir"); + assert!( + !a_into_b.exists(), + "tour A: écriture dans B (hors grant A) doit être bloquée" + ); + + // --- Tour B : grant = dir_b. Écrit dans B (ok) puis A (bloqué). --- + // Si la restriction du tour A avait bavé, l'écriture dans B échouerait ici. + let b_in = dir_b.join("in.txt"); + let b_into_a = dir_a.join("from-b.txt"); + let spec_b = writing_spawn_line(RESULT_LINE, &b_into_a, &b_in, Some(rw_plan(&dir_b))); + run_turn(&spec_b, None, Some(&enforcer)) + .await + .expect("tour B ok"); + assert!( + b_in.exists(), + "tour B: écriture dans son grant (B) doit réussir — la restriction du tour A n'a pas bavé" + ); + assert!( + !b_into_a.exists(), + "tour B: écriture dans A (hors grant B) doit être bloquée" + ); + + let _ = std::fs::remove_dir_all(&dir_a); + let _ = std::fs::remove_dir_all(&dir_b); +} + +// --------------------------------------------------------------------------- +// INVARIANT 6 — TIMEOUT SOUS SANDBOX +// --------------------------------------------------------------------------- + +/// **Timeout sous sandbox.** Un enfant qui ne ferme jamais stdout (`sleep`) lancé via +/// le chemin sandboxé : `run_turn(timeout)` doit **tuer** l'enfant et renvoyer +/// [`AgentSessionError::Timeout`], rapidement (≪ durée du sleep) — preuve que le killer +/// oneshot + `tokio::time::timeout` fonctionnent et qu'aucun thread ne reste bloqué. +#[tokio::test] +async fn structured_run_turn_timeout_under_sandbox() { + use domain::ports::AgentSessionError; + + let dir = fresh_dir("p6"); + // `sleep 30` garde stdout ouvert ⇒ le drain bloquerait sans le killer du timeout. + let spec = SpawnLine { + command: "sleep".to_owned(), + args: vec!["30".to_owned()], + cwd: "/".to_owned(), + env: Vec::new(), + stdin: None, + sandbox: Some(rw_plan(&dir)), // ⇒ branche sandboxée (posture Ask ⇒ enforce Ok) + }; + let enforcer = crate::sandbox::default_enforcer(); + + let started = Instant::now(); + let err = run_turn(&spec, Some(Duration::from_millis(250)), Some(&enforcer)) + .await + .expect_err("doit expirer"); + let elapsed = started.elapsed(); + + assert!( + matches!(err, AgentSessionError::Timeout), + "le tour sandboxé doit expirer en Timeout, vu: {err:?}" + ); + assert!( + elapsed < Duration::from_secs(10), + "le timeout doit tuer l'enfant promptement (vu {elapsed:?}) — pas d'attente des 30s" + ); + + let _ = std::fs::remove_dir_all(&dir); +} + +// --------------------------------------------------------------------------- +// INVARIANT 7 — RESUME PRÉSERVÉ (via la fabrique réelle + adapter Claude) +// --------------------------------------------------------------------------- + +/// **Resume préservé.** Après un tour réellement sandboxé, l'`session_id`/conversation_id +/// est toujours capté correctement : la restriction FS ne casse pas le parsing du flux. +/// Passe par la **fabrique réelle** `StructuredSessionFactory::with_sandbox_enforcer`, +/// un profil Claude branché sur un [`FakeCli`] (init + result), et un plan transmis à +/// `start(.., Some(&plan))`. Le fake n'écrit aucun fichier (il imprime sur un pipe, hors +/// périmètre Landlock-FS), donc un plan write-only suffit : on prouve que la capture +/// d'id survit au domaine actif. +#[tokio::test] +async fn structured_sandboxed_turn_preserves_conversation_id() { + use domain::ids::ProfileId; + use domain::ports::{AgentSessionFactory, PreparedContext, ReplyEvent, SessionPlan}; + use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; + use domain::project::ProjectPath; + use domain::MarkdownDoc; + + use super::conformance::FakeCli; + use super::factory::StructuredSessionFactory; + + if !landlock_is_enforced() { + eprintln!("skip structured_sandboxed_turn_preserves_conversation_id: Landlock indisponible"); + return; + } + + let run_dir = fresh_dir("p7"); + let fake = FakeCli::printing(&[ + r#"{"type":"system","subtype":"init","session_id":"conv-sbx-1","cwd":"/tmp","tools":[]}"#, + r#"{"type":"result","subtype":"success","is_error":false,"result":"réponse","session_id":"conv-sbx-1","num_turns":1}"#, + ]); + + let profile = AgentProfile::new( + ProfileId::new_random(), + "Claude sandboxé", + &fake.command(), + Vec::new(), + ContextInjection::convention_file("CLAUDE.md").expect("convention file valide"), + None, + "{agentRunDir}", + None, + ) + .expect("profil valide") + .with_structured_adapter(StructuredAdapter::Claude); + + let factory = + StructuredSessionFactory::new().with_sandbox_enforcer(crate::sandbox::default_enforcer()); + + let ctx = PreparedContext { + content: MarkdownDoc::new("# ctx"), + relative_path: "CLAUDE.md".to_owned(), + }; + let cwd = ProjectPath::new(run_dir.to_string_lossy().into_owned()).expect("cwd absolu"); + let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés + + let session = factory + .start(&profile, &ctx, &cwd, &SessionPlan::None, Some(&plan)) + .await + .expect("start sandboxé ok"); + + // Avant tout tour : aucun id moteur. + assert_eq!(session.conversation_id(), None); + + // Un tour sandboxé : le flux doit porter un Final ET capter l'id. + let events: Vec = session.send("salut").await.expect("send ok").collect(); + let finals = events + .iter() + .filter(|e| matches!(e, ReplyEvent::Final { .. })) + .count(); + assert_eq!(finals, 1, "un Final attendu malgré le sandbox, vu: {events:?}"); + + // LE POINT : l'id de conversation a bien été capté sous enforcement actif. + assert_eq!( + session.conversation_id().as_deref(), + Some("conv-sbx-1"), + "la restriction FS ne doit pas casser la capture du conversation_id (resume)" + ); + + let _ = std::fs::remove_dir_all(&run_dir); +} diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index a4ac1f7..40030c2 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -7,6 +7,7 @@ mod context; mod embedder; mod memory; +mod permission; mod profile; mod project; mod skill; @@ -24,6 +25,7 @@ pub use embedder::{ RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall}; +pub use permission::FsPermissionStore; pub use profile::{FsEmbedderProfileStore, FsProfileStore}; pub use project::FsProjectStore; pub use skill::FsSkillStore; diff --git a/crates/infrastructure/src/store/permission.rs b/crates/infrastructure/src/store/permission.rs new file mode 100644 index 0000000..0402a36 --- /dev/null +++ b/crates/infrastructure/src/store/permission.rs @@ -0,0 +1,65 @@ +//! Filesystem-backed [`PermissionStore`] for project agent permissions. + +use std::sync::Arc; + +use async_trait::async_trait; + +use domain::ports::{FileSystem, FsError, PermissionStore, RemotePath, StoreError}; +use domain::{Project, ProjectPermissions}; + +const PERMISSIONS_FILE: &str = "permissions.json"; + +/// JSON-file implementation for `/.ideai/permissions.json`. +#[derive(Clone)] +pub struct FsPermissionStore { + fs: Arc, +} + +impl FsPermissionStore { + /// Builds the store from an injected filesystem port. + #[must_use] + pub fn new(fs: Arc) -> Self { + Self { fs } + } + + fn path(project: &Project) -> RemotePath { + let root = project.root.as_str().trim_end_matches(['/', '\\']); + RemotePath::new(format!("{root}/.ideai/{PERMISSIONS_FILE}")) + } + + async fn ensure_ideai(&self, project: &Project) -> Result<(), StoreError> { + let root = project.root.as_str().trim_end_matches(['/', '\\']); + self.fs + .create_dir_all(&RemotePath::new(format!("{root}/.ideai"))) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } +} + +#[async_trait] +impl PermissionStore for FsPermissionStore { + async fn load_permissions(&self, project: &Project) -> Result { + match self.fs.read(&Self::path(project)).await { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) + } + Err(FsError::NotFound(_)) => Ok(ProjectPermissions::default()), + Err(e) => Err(StoreError::Io(e.to_string())), + } + } + + async fn save_permissions( + &self, + project: &Project, + permissions: &ProjectPermissions, + ) -> Result<(), StoreError> { + self.ensure_ideai(project).await?; + let mut bytes = serde_json::to_vec_pretty(permissions) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + bytes.push(b'\n'); + self.fs + .write(&Self::path(project), &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } +} diff --git a/crates/infrastructure/tests/conversation_log.rs b/crates/infrastructure/tests/conversation_log.rs index 37ef810..a90f1ca 100644 --- a/crates/infrastructure/tests/conversation_log.rs +++ b/crates/infrastructure/tests/conversation_log.rs @@ -71,7 +71,8 @@ impl TempDir { } /// `/.ideai/conversations//providers.json.tmp` — the atomic-write tmp (P5). fn providers_tmp_path(&self, conversation: ConversationId) -> PathBuf { - self.conversation_dir(conversation).join("providers.json.tmp") + self.conversation_dir(conversation) + .join("providers.json.tmp") } } impl Drop for TempDir { @@ -119,10 +120,13 @@ async fn append_writes_one_valid_json_line_per_turn() { let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect(); assert_eq!(lines.len(), 3, "one line per append, got: {raw:?}"); for line in &lines { - let parsed: ConversationTurn = - serde_json::from_str(line).unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}")); + let parsed: ConversationTurn = serde_json::from_str(line) + .unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}")); // camelCase shape leaks through to the file (sanity on the persisted format). - assert!(line.contains("\"atMs\""), "expected camelCase atMs in {line:?}"); + assert!( + line.contains("\"atMs\""), + "expected camelCase atMs in {line:?}" + ); let _ = parsed; } } @@ -144,7 +148,9 @@ async fn persists_across_a_fresh_instance_restart() { (2, TurnRole::Response, "b"), (3, TurnRole::Prompt, "c"), ] { - log.append(c, turn(c, turn_id(id), role, txt)).await.unwrap(); + log.append(c, turn(c, turn_id(id), role, txt)) + .await + .unwrap(); } } @@ -184,7 +190,10 @@ async fn conversations_land_in_disjoint_files() { assert_ne!(p1, p2, "the two conversations must use distinct files"); // No cross-leak through the API, and the c2 file holds only c2's single line. - assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]); + assert_eq!( + texts(&log.read(c1, None).await.unwrap()), + vec!["c1-a", "c1-b"] + ); assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]); let c2_raw = std::fs::read_to_string(&p2).unwrap(); assert_eq!( @@ -192,7 +201,10 @@ async fn conversations_land_in_disjoint_files() { 1, "c2 file must not contain c1's turns" ); - assert!(!c2_raw.contains("c1-"), "no c1 content leaked into c2's file"); + assert!( + !c2_raw.contains("c1-"), + "no c1 content leaked into c2's file" + ); } // --------------------------------------------------------------------------- @@ -228,11 +240,21 @@ async fn corrupted_line_is_skipped_without_panic() { // A fresh instance must skip the junk line and return only the two valid turns. let log = FsConversationLog::new(&tmp.project_path()); let all = log.read(c, None).await.unwrap(); - assert_eq!(texts(&all), vec!["good-1", "good-2"], "garbage line skipped"); + assert_eq!( + texts(&all), + vec!["good-1", "good-2"], + "garbage line skipped" + ); // `last` must be just as tolerant. - assert_eq!(texts(&log.last(c, 5).await.unwrap()), vec!["good-1", "good-2"]); + assert_eq!( + texts(&log.last(c, 5).await.unwrap()), + vec!["good-1", "good-2"] + ); // And the cursor still works across the corrupted tail. - assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["good-2"]); + assert_eq!( + texts(&log.read(c, Some(turn_id(1))).await.unwrap()), + vec!["good-2"] + ); } #[tokio::test] @@ -260,7 +282,11 @@ async fn good_line_appended_after_corruption_is_still_read() { .unwrap(); let all = log.read(c, None).await.unwrap(); - assert_eq!(texts(&all), vec!["before", "after"], "junk in the middle is skipped, both reals survive"); + assert_eq!( + texts(&all), + vec!["before", "after"], + "junk in the middle is skipped, both reals survive" + ); } // --------------------------------------------------------------------------- @@ -274,7 +300,11 @@ async fn missing_conversation_reads_empty() { // Nothing was ever appended: no .ideai dir at all. assert!(log.read(conv_id(99), None).await.unwrap().is_empty()); - assert!(log.read(conv_id(99), Some(turn_id(1))).await.unwrap().is_empty()); + assert!(log + .read(conv_id(99), Some(turn_id(1))) + .await + .unwrap() + .is_empty()); assert!(log.last(conv_id(99), 5).await.unwrap().is_empty()); } @@ -293,8 +323,14 @@ async fn read_cursor_is_strictly_exclusive() { .unwrap(); } - assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["b", "c"]); - assert_eq!(texts(&log.read(c, Some(turn_id(2))).await.unwrap()), vec!["c"]); + assert_eq!( + texts(&log.read(c, Some(turn_id(1))).await.unwrap()), + vec!["b", "c"] + ); + assert_eq!( + texts(&log.read(c, Some(turn_id(2))).await.unwrap()), + vec!["c"] + ); // Cursor on the last id => nothing after. assert!(log.read(c, Some(turn_id(3))).await.unwrap().is_empty()); // Unknown cursor => empty (cohérent avec le double in-memory de P1). @@ -317,9 +353,15 @@ async fn last_contract_zero_and_bounds() { // last(n < len) => the n last, in insertion order. assert_eq!(texts(&log.last(c, 2).await.unwrap()), vec!["c", "d"]); // last(n > len) => everything. - assert_eq!(texts(&log.last(c, 10).await.unwrap()), vec!["a", "b", "c", "d"]); + assert_eq!( + texts(&log.last(c, 10).await.unwrap()), + vec!["a", "b", "c", "d"] + ); // last(n == len) => everything. - assert_eq!(texts(&log.last(c, 4).await.unwrap()), vec!["a", "b", "c", "d"]); + assert_eq!( + texts(&log.last(c, 4).await.unwrap()), + vec!["a", "b", "c", "d"] + ); } // --------------------------------------------------------------------------- @@ -352,7 +394,11 @@ async fn concurrent_appends_on_same_conversation_keep_both_lines_intact() { // Exactly two non-empty lines, each a fully-parseable turn (no interleaving). let raw = std::fs::read_to_string(tmp.log_path(c)).unwrap(); let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect(); - assert_eq!(lines.len(), 2, "two concurrent appends => two lines, got: {raw:?}"); + assert_eq!( + lines.len(), + 2, + "two concurrent appends => two lines, got: {raw:?}" + ); for line in &lines { serde_json::from_str::(line) .unwrap_or_else(|e| panic!("interleaved/corrupted line {line:?}: {e}")); @@ -391,15 +437,23 @@ async fn handoff_round_trip_multiline_summary_with_objective() { // summary_md délibérément multi-ligne, avec un `---` interne et des espaces de fin // pour éprouver la fidélité octet-pour-octet du corps. - let summary = "# Résumé de reprise\n\n- point un\n- point deux\n\n---\n\nbloc final avec trailing \n"; + let summary = + "# Résumé de reprise\n\n- point un\n- point deux\n\n---\n\nbloc final avec trailing \n"; let handoff = Handoff::new(summary, turn_id(42), Some("livrer le lot P3".to_string())); store.save(c, handoff.clone()).await.unwrap(); let loaded = store.load(c).await.unwrap(); - assert_eq!(loaded, Some(handoff.clone()), "round-trip doit redonner le Handoff exact"); + assert_eq!( + loaded, + Some(handoff.clone()), + "round-trip doit redonner le Handoff exact" + ); let loaded = loaded.unwrap(); - assert_eq!(loaded.summary_md, summary, "summary_md conservé octet pour octet"); + assert_eq!( + loaded.summary_md, summary, + "summary_md conservé octet pour octet" + ); assert_eq!(loaded.up_to, turn_id(42), "up_to conservé à l'identique"); assert_eq!(loaded.objective.as_deref(), Some("livrer le lot P3")); } @@ -428,8 +482,14 @@ async fn handoff_round_trip_without_objective() { // Sanity sur le format brut : pas de ligne `objective:` quand None. let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap(); - assert!(!raw.contains("objective:"), "aucune clé objective ne doit être écrite, got: {raw:?}"); - assert!(raw.contains("upTo: "), "upTo toujours présent, got: {raw:?}"); + assert!( + !raw.contains("objective:"), + "aucune clé objective ne doit être écrite, got: {raw:?}" + ); + assert!( + raw.contains("upTo: "), + "upTo toujours présent, got: {raw:?}" + ); } // --------------------------------------------------------------------------- @@ -464,8 +524,15 @@ async fn handoff_save_is_atomic_no_tmp_left_behind() { "le fichier temporaire handoff.md.tmp ne doit pas subsister après save" ); // Le fichier final existe et est complet/relisible. - assert!(tmp.handoff_path(c).exists(), "handoff.md final doit exister"); - assert_eq!(store.load(c).await.unwrap(), Some(handoff), "contenu final lisible et complet"); + assert!( + tmp.handoff_path(c).exists(), + "handoff.md final doit exister" + ); + assert_eq!( + store.load(c).await.unwrap(), + Some(handoff), + "contenu final lisible et complet" + ); } // --------------------------------------------------------------------------- @@ -485,10 +552,20 @@ async fn handoff_overwrite_keeps_only_the_last() { store.save(c, second.clone()).await.unwrap(); // load renvoie le dernier, aucune trace du premier. - assert_eq!(store.load(c).await.unwrap(), Some(second), "load doit renvoyer le dernier save"); + assert_eq!( + store.load(c).await.unwrap(), + Some(second), + "load doit renvoyer le dernier save" + ); let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap(); - assert!(!raw.contains("première version"), "le contenu du premier save ne doit pas subsister"); - assert!(!raw.contains("obj-1"), "l'objective du premier save ne doit pas subsister"); + assert!( + !raw.contains("première version"), + "le contenu du premier save ne doit pas subsister" + ); + assert!( + !raw.contains("obj-1"), + "l'objective du premier save ne doit pas subsister" + ); } // --------------------------------------------------------------------------- @@ -515,11 +592,20 @@ async fn handoff_is_isolated_per_conversation() { // Deux fichiers distincts sur disque. let p1 = tmp.handoff_path(c1); let p2 = tmp.handoff_path(c2); - assert_ne!(p1, p2, "deux conversations => deux fichiers handoff distincts"); + assert_ne!( + p1, p2, + "deux conversations => deux fichiers handoff distincts" + ); assert!(p1.exists() && p2.exists()); let raw2 = std::fs::read_to_string(&p2).unwrap(); - assert!(!raw2.contains("résumé c1"), "aucune fuite de c1 dans le handoff de c2"); - assert!(!raw2.contains("obj-c1"), "aucune fuite de l'objective de c1 dans c2"); + assert!( + !raw2.contains("résumé c1"), + "aucune fuite de c1 dans le handoff de c2" + ); + assert!( + !raw2.contains("obj-c1"), + "aucune fuite de l'objective de c1 dans c2" + ); } // --------------------------------------------------------------------------- @@ -537,11 +623,23 @@ async fn handoff_corruption_yields_serialization_error_no_panic() { // (4) `upTo` non-UUID. // (5) ligne de front-matter sans `:`. let cases: [(u128, &str, &str); 5] = [ - (101, "pas de fence ouvrant\nupTo: 00000000-0000-0000-0000-000000000001\n---\ncorps\n", "fence ouvrant absent"), - (102, "---\nupTo: 00000000-0000-0000-0000-000000000001\ncorps sans fence fermant\n", "fence fermant absent"), + ( + 101, + "pas de fence ouvrant\nupTo: 00000000-0000-0000-0000-000000000001\n---\ncorps\n", + "fence ouvrant absent", + ), + ( + 102, + "---\nupTo: 00000000-0000-0000-0000-000000000001\ncorps sans fence fermant\n", + "fence fermant absent", + ), (103, "---\nobjective: but\n---\ncorps\n", "upTo absent"), (104, "---\nupTo: pas-un-uuid\n---\ncorps\n", "upTo non-UUID"), - (105, "---\nligne sans deux-points\n---\ncorps\n", "ligne front-matter sans `:`"), + ( + 105, + "---\nligne sans deux-points\n---\ncorps\n", + "ligne front-matter sans `:`", + ), ]; for (n, content, label) in cases { @@ -575,7 +673,10 @@ async fn handoff_corruption_yields_serialization_error_no_panic() { /// Compte les lignes-tours (`- **…`) dans un `summary_md` rendu. fn turn_lines(summary_md: &str) -> Vec<&str> { - summary_md.lines().filter(|l| l.starts_with("- **")).collect() + summary_md + .lines() + .filter(|l| l.starts_with("- **")) + .collect() } /// `fold(None, turns)` = calcul de base : tours rendus, curseur = dernier id, @@ -595,7 +696,8 @@ async fn fold_none_renders_base_window_objective_and_cursor() { // Objectif extrait du 1er Prompt. assert_eq!(h.objective.as_deref(), Some("Implémente la feature X")); assert!( - h.summary_md.contains("**Objectif :** Implémente la feature X"), + h.summary_md + .contains("**Objectif :** Implémente la feature X"), "ligne d'objectif attendue, got:\n{}", h.summary_md ); @@ -603,7 +705,12 @@ async fn fold_none_renders_base_window_objective_and_cursor() { assert_eq!(h.up_to, turn_id(3)); // Les 3 tours rendus, un par ligne, avec le bon label. let lines = turn_lines(&h.summary_md); - assert_eq!(lines.len(), 3, "3 lignes-tours attendues, got:\n{}", h.summary_md); + assert_eq!( + lines.len(), + 3, + "3 lignes-tours attendues, got:\n{}", + h.summary_md + ); assert_eq!(lines[0], "- **Prompt:** Implémente la feature X"); assert_eq!(lines[1], "- **Tool:** ran grep"); assert_eq!(lines[2], "- **Response:** fait"); @@ -626,11 +733,18 @@ async fn fold_is_incremental_does_not_re_pass_old_turns() { // Le 2e appel ne re-fournit QUE t6. let h2 = s - .fold(Some(h1.clone()), &[turn(c, turn_id(6), TurnRole::Response, "r6")]) + .fold( + Some(h1.clone()), + &[turn(c, turn_id(6), TurnRole::Response, "r6")], + ) .await; let lines = turn_lines(&h2.summary_md); - assert_eq!(lines.len(), 6, "t1..t6 reconstitués depuis prev + incrément"); + assert_eq!( + lines.len(), + 6, + "t1..t6 reconstitués depuis prev + incrément" + ); assert_eq!(lines[0], "- **Prompt:** obj un"); assert_eq!(lines[5], "- **Response:** r6"); assert_eq!(h2.up_to, turn_id(6), "curseur = dernier de l'incrément"); @@ -686,7 +800,10 @@ async fn fold_keeps_existing_objective_over_new_prompt() { ); let h = s - .fold(Some(prev), &[turn(c, turn_id(2), TurnRole::Prompt, "un autre but")]) + .fold( + Some(prev), + &[turn(c, turn_id(2), TurnRole::Prompt, "un autre but")], + ) .await; assert_eq!(h.objective.as_deref(), Some("but initial"), "objectif figé"); @@ -752,10 +869,13 @@ async fn fold_flattens_multiline_and_marker_like_text_into_one_line() { // Le tour piégeux est aplati en une seule ligne (whitespace collapsé). let lines = turn_lines(&h1.summary_md); - assert_eq!(lines.len(), 2, "2 lignes-tours seulement, pas de ligne fantôme"); assert_eq!( - lines[1], - "- **Response:** ligne une - **Prompt:** faux marqueur ligne trois", + lines.len(), + 2, + "2 lignes-tours seulement, pas de ligne fantôme" + ); + assert_eq!( + lines[1], "- **Response:** ligne une - **Prompt:** faux marqueur ligne trois", "texte multi-lignes + marqueur aplati en une ligne" ); @@ -765,10 +885,17 @@ async fn fold_flattens_multiline_and_marker_like_text_into_one_line() { // (préfixée par `- **Response:** ligne une `), le reparse par `starts_with("- **")` // la compte comme UNE seule ligne — la fenêtre reste cohérente. let h2 = s - .fold(Some(h1.clone()), &[turn(c, turn_id(3), TurnRole::Response, "suite")]) + .fold( + Some(h1.clone()), + &[turn(c, turn_id(3), TurnRole::Response, "suite")], + ) .await; let lines2 = turn_lines(&h2.summary_md); - assert_eq!(lines2.len(), 3, "fenêtre cohérente après repli (pas de split parasite)"); + assert_eq!( + lines2.len(), + 3, + "fenêtre cohérente après repli (pas de split parasite)" + ); assert_eq!(lines2[2], "- **Response:** suite"); assert_eq!(h2.up_to, turn_id(3)); } @@ -902,8 +1029,16 @@ async fn provider_corrupted_file_yields_serialization_error_no_panic() { // Cas de contenus non-désérialisables en map. let cases: [(u128, &str, &str); 3] = [ - (201, "{ ceci n'est pas du json", "JSON syntaxiquement invalide"), - (202, "[\"pas\", \"un\", \"objet\"]", "JSON valide mais pas un objet map"), + ( + 201, + "{ ceci n'est pas du json", + "JSON syntaxiquement invalide", + ), + ( + 202, + "[\"pas\", \"un\", \"objet\"]", + "JSON valide mais pas un objet map", + ), (203, "{\"claude\": 123}", "valeur non-String"), ]; @@ -986,8 +1121,14 @@ async fn provider_is_isolated_per_conversation() { store.set(c2, "claude", "c2-id").await.unwrap(); // Chacune relit la sienne. - assert_eq!(store.get(c1, "claude").await.unwrap(), Some("c1-id".to_string())); - assert_eq!(store.get(c2, "claude").await.unwrap(), Some("c2-id".to_string())); + assert_eq!( + store.get(c1, "claude").await.unwrap(), + Some("c1-id".to_string()) + ); + assert_eq!( + store.get(c2, "claude").await.unwrap(), + Some("c2-id".to_string()) + ); // Deux fichiers distincts sur disque, sans fuite de contenu. let p1 = tmp.providers_path(c1); @@ -995,5 +1136,8 @@ async fn provider_is_isolated_per_conversation() { assert_ne!(p1, p2, "deux conversations ⇒ deux providers.json distincts"); assert!(p1.exists() && p2.exists()); let raw2 = std::fs::read_to_string(&p2).unwrap(); - assert!(!raw2.contains("c1-id"), "aucune fuite de c1 dans le providers.json de c2"); + assert!( + !raw2.contains("c1-id"), + "aucune fuite de c1 dans le providers.json de c2" + ); } diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 7430439..bbff51f 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -27,9 +27,9 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::agent::{AgentManifest, ManifestEntry}; use domain::events::{DomainEvent, OrchestrationSource}; +use domain::ids::NodeId; use domain::ids::SkillId; use domain::ids::{AgentId, ProfileId, ProjectId}; -use domain::ids::NodeId; use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, @@ -52,11 +52,11 @@ use application::{ CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, OrchestratorService, TerminalSessions, UpdateAgentContext, }; +use infrastructure::orchestrator::mcp::jsonrpc::{error_codes, Transport, TransportError}; use infrastructure::{ InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport, SystemMillisClock, }; -use infrastructure::orchestrator::mcp::jsonrpc::error_codes; use serde_json::{json, Value}; @@ -242,6 +242,7 @@ impl AgentRuntime for FakeRuntime { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } @@ -296,7 +297,6 @@ impl PtyPort for FakePty { } } - #[derive(Default, Clone)] struct NoopBus; impl EventBus for NoopBus { @@ -468,7 +468,9 @@ fn tools_call(id: i64, tool: &str, arguments: Value) -> Vec { /// Extracts the single text content block of a successful `tools/call` result. fn result_text(result: &Value) -> &str { - result["content"][0]["text"].as_str().expect("text content block") + result["content"][0]["text"] + .as_str() + .expect("text content block") } // --------------------------------------------------------------------------- @@ -485,15 +487,15 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() { })) .unwrap(); - let response = server.handle_raw(&raw).await.expect("tools/list owes a reply"); + let response = server + .handle_raw(&raw) + .await + .expect("tools/list owes a reply"); assert!(response.error.is_none(), "got error: {:?}", response.error); let result = response.result.expect("result"); let tools = result["tools"].as_array().expect("tools array"); - let names: Vec<&str> = tools - .iter() - .map(|t| t["name"].as_str().unwrap()) - .collect(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); for expected in [ "idea_list_agents", @@ -509,7 +511,10 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() { "idea_memory_read", "idea_memory_write", ] { - assert!(names.contains(&expected), "missing tool {expected}; got {names:?}"); + assert!( + names.contains(&expected), + "missing tool {expected}; got {names:?}" + ); } assert_eq!( tools.len(), @@ -520,7 +525,8 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() { // Every tool advertises an object input schema. for t in tools { assert_eq!( - t["inputSchema"]["type"], json!("object"), + t["inputSchema"]["type"], + json!("object"), "tool {} must declare an object input schema", t["name"] ); @@ -544,7 +550,11 @@ async fn launch_agent_call_creates_and_launches_the_agent() { json!({ "target": "dev-backend", "profile": "claude-code" }), ); let response = server.handle_raw(&raw).await.expect("reply owed"); - assert!(response.error.is_none(), "transport error: {:?}", response.error); + assert!( + response.error.is_none(), + "transport error: {:?}", + response.error + ); let result = response.result.expect("result"); assert_eq!(result["isError"], json!(false), "got {result}"); @@ -630,7 +640,11 @@ async fn ask_agent_returns_target_reply_inline() { let contexts = FakeContexts::new(); let agent_id = contexts.seed_agent("architect"); let (service, mailbox, sessions) = build_service_with_mailbox(contexts); - seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242))); + seed_live_pty( + &sessions, + agent_id, + SessionId::from_uuid(Uuid::from_u128(4242)), + ); let ask_server = server(Arc::clone(&service)); let ask = tokio::spawn(async move { @@ -655,7 +669,10 @@ async fn ask_agent_returns_target_reply_inline() { // requester, which `for_requester` injects as the `from` of the Reply command. let reply_server = server(Arc::clone(&service)).for_requester(agent_id.to_string()); let reply_raw = tools_call(8, "idea_reply", json!({ "result": "the answer is 42" })); - let reply_resp = reply_server.handle_raw(&reply_raw).await.expect("reply owed"); + let reply_resp = reply_server + .handle_raw(&reply_raw) + .await + .expect("reply owed"); assert_eq!(reply_resp.result.expect("result")["isError"], json!(false)); let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask) @@ -663,7 +680,11 @@ async fn ask_agent_returns_target_reply_inline() { .expect("ask completes after reply") .expect("join ok"); assert_eq!(response.id, json!(7), "id must be echoed"); - assert!(response.error.is_none(), "transport error: {:?}", response.error); + assert!( + response.error.is_none(), + "transport error: {:?}", + response.error + ); let result = response.result.expect("result"); assert_eq!(result["isError"], json!(false), "got {result}"); assert_eq!( @@ -751,7 +772,10 @@ async fn failed_command_is_tool_error_not_transport_error_and_server_survives() })) .unwrap(); let again = server.handle_raw(&raw).await.expect("server still serves"); - assert!(again.result.is_some(), "server must survive a failed command"); + assert!( + again.result.is_some(), + "server must survive a failed command" + ); } // --------------------------------------------------------------------------- @@ -836,6 +860,45 @@ async fn initialize_answers_minimal_handshake() { assert_eq!(result["serverInfo"]["name"], json!("idea-orchestrator")); } +/// Readiness de démarrage (fix race cold-launch, signal MCP) : un `initialize` reçu sur +/// un serveur per-connexion (`for_requester(id)`) déclenche le `ready_sink` avec l'id du +/// peer. Et un serveur SANS requester (anonyme) ne déclenche PAS le sink (peer legacy). +#[tokio::test] +async fn initialize_fires_ready_sink_with_requester() { + let (service, _s) = build_service(FakeContexts::new()); + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink_buf = Arc::clone(&captured); + let ready_sink: Arc = + Arc::new(move |req: &str| sink_buf.lock().unwrap().push(req.to_owned())); + + let base = McpServer::new(service, project()).with_ready_sink(ready_sink); + + let init = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", "id": 0, "method": "initialize", + "params": { "protocolVersion": "2024-11-05" } + })) + .unwrap(); + + // Cas 1 : peer identifié (requester non vide) ⇒ le sink reçoit son id. + let peer = base.for_requester("agent-42"); + let response = peer.handle_raw(&init).await.expect("reply owed"); + assert!(response.error.is_none(), "initialize must still answer"); + assert_eq!( + *captured.lock().unwrap(), + vec!["agent-42".to_owned()], + "initialize d'un peer identifié ⇒ ready_sink appelé avec son requester" + ); + + // Cas 2 : peer anonyme (requester vide, le serveur de base) ⇒ sink NON appelé. + captured.lock().unwrap().clear(); + let response = base.handle_raw(&init).await.expect("reply owed"); + assert!(response.error.is_none()); + assert!( + captured.lock().unwrap().is_empty(), + "requester vide ⇒ ready_sink jamais appelé (peer legacy/anonyme)" + ); +} + // --------------------------------------------------------------------------- // 9. Shared validation: a tools/call missing a required argument is rejected by // the SAME OrchestratorRequest::validate, with no dispatch. @@ -898,7 +961,10 @@ async fn scripted_session_over_memory_transport_round_trips() { // Exactly two responses (init + list); the notification produced none. let first = rx.recv().await.expect("init response"); let second = rx.recv().await.expect("list response"); - assert!(rx.recv().await.is_none(), "notification must not be answered"); + assert!( + rx.recv().await.is_none(), + "notification must not be answered" + ); let first: Value = serde_json::from_slice(&first).unwrap(); let second: Value = serde_json::from_slice(&second).unwrap(); @@ -927,7 +993,11 @@ async fn processed_tools_call_publishes_event_tagged_mcp_source() { json!({ "target": "dev-backend", "profile": "claude-code" }), ); let response = server.handle_raw(&raw).await.expect("reply owed"); - assert!(response.error.is_none(), "transport error: {:?}", response.error); + assert!( + response.error.is_none(), + "transport error: {:?}", + response.error + ); assert_eq!(response.result.expect("result")["isError"], json!(false)); // Exactly one OrchestratorRequestProcessed event, tagged as the MCP door. @@ -951,7 +1021,10 @@ async fn processed_tools_call_publishes_event_tagged_mcp_source() { assert_eq!(*source, OrchestrationSource::Mcp, "MCP door must tag Mcp"); assert_eq!(action, "idea_launch_agent", "action mirrors the tool name"); assert!(*ok, "the launch succeeded"); - assert_eq!(requester_id, "mcp", "stable mcp label until per-session bind"); + assert_eq!( + requester_id, "mcp", + "stable mcp label until per-session bind" + ); } other => panic!("expected OrchestratorRequestProcessed, got {other:?}"), } @@ -976,9 +1049,15 @@ async fn failed_tools_call_still_publishes_event_with_ok_false_and_mcp_source() .iter() .filter(|e| matches!(e, DomainEvent::OrchestratorRequestProcessed { .. })) .collect(); - assert_eq!(processed.len(), 1, "a failed call still beacons; got {events:?}"); + assert_eq!( + processed.len(), + 1, + "a failed call still beacons; got {events:?}" + ); match processed[0] { - DomainEvent::OrchestratorRequestProcessed { ok, source, action, .. } => { + DomainEvent::OrchestratorRequestProcessed { + ok, source, action, .. + } => { assert!(!*ok, "the dispatch failed → ok = false"); assert_eq!(*source, OrchestrationSource::Mcp); assert_eq!(action, "idea_stop_agent"); @@ -1001,8 +1080,174 @@ async fn server_without_event_sink_emits_nothing_and_does_not_panic() { json!({ "target": "dev-backend", "profile": "claude-code" }), ); let response = server.handle_raw(&raw).await.expect("reply owed"); - assert!(response.error.is_none(), "transport error: {:?}", response.error); + assert!( + response.error.is_none(), + "transport error: {:?}", + response.error + ); assert_eq!(response.result.expect("result")["isError"], json!(false)); // The command really ran (agent created) — proof the no-op sink path is live. assert_eq!(contexts.entries().len(), 1); } + +// --------------------------------------------------------------------------- +// 10. Anti-wedge (régression du bug serveur lockstep) + timeout du rendezvous. +// +// Cœur de la régression : un `idea_ask_agent` en attente de son `idea_reply` +// ne doit PLUS parquer toute la connexion. La preuve : pendant qu'un ask est +// bloqué (rendezvous jamais résolu), un second appel (`tools/list`) sur la +// MÊME connexion reçoit sa réponse. L'ancien `serve` lockstep ne lisait plus +// rien tant que `handle_raw` de l'ask n'avait pas rendu → ce test bouclait. +// --------------------------------------------------------------------------- + +/// Transport scriptable qui **reste ouvert** : il livre `inbound` dans l'ordre, +/// puis, une fois la file vide, `recv` reste en attente (au lieu de fermer) tant +/// que le test n'a pas appelé `close()`. Cela laisse la boucle `serve` vivante — +/// donc capable de drainer les réponses des tâches encore en vol — pendant qu'un +/// `idea_ask_agent` est parqué. Les `send` sont capturés sur un canal `mpsc`. +struct GatedTransport { + inbound: std::collections::VecDeque>, + outbound: tokio::sync::mpsc::UnboundedSender>, + close: Arc, +} + +impl GatedTransport { + fn new( + messages: Vec>, + ) -> ( + Self, + tokio::sync::mpsc::UnboundedReceiver>, + Arc, + ) { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let close = Arc::new(tokio::sync::Notify::new()); + ( + Self { + inbound: messages.into(), + outbound: tx, + close: Arc::clone(&close), + }, + rx, + close, + ) + } +} + +#[async_trait] +impl Transport for GatedTransport { + async fn recv(&mut self) -> Result, TransportError> { + if let Some(next) = self.inbound.pop_front() { + return Ok(next); + } + // File vide : on n'émet PAS Closed tout de suite — on attend le signal de + // fermeture pour ne pas tuer la boucle pendant qu'un ask est encore parqué. + self.close.notified().await; + Err(TransportError::Closed) + } + + async fn send(&mut self, message: &[u8]) -> Result<(), TransportError> { + self.outbound + .send(message.to_vec()) + .map_err(|_| TransportError::Closed) + } +} + +#[tokio::test] +async fn pending_ask_does_not_wedge_the_connection_concurrent_call_still_answered() { + // Un `idea_ask_agent` vers une cible vivante bloque sur la mailbox (aucun + // `idea_reply` ne viendra). Sur la MÊME connexion, un `tools/list` qui suit + // doit recevoir sa réponse SANS attendre la résolution de l'ask. + let contexts = FakeContexts::new(); + let agent_id = contexts.seed_agent("architect"); + let (service, mailbox, sessions) = build_service_with_mailbox(contexts); + seed_live_pty( + &sessions, + agent_id, + SessionId::from_uuid(Uuid::from_u128(909)), + ); + let server = server(service); + + let ask = tools_call( + 1, + "idea_ask_agent", + json!({ "target": "architect", "task": "blocking..." }), + ); + let list = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/list" + })) + .unwrap(); + + let (transport, mut rx, close) = GatedTransport::new(vec![ask, list]); + let serve = tokio::spawn(async move { + let mut transport = transport; + server.serve(&mut transport).await; + }); + + // L'ask a bien été enqueué (donc il est parqué en attente de reply). + tokio::time::timeout(std::time::Duration::from_secs(10), async { + while mailbox.pending(&agent_id) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("ask must enqueue a ticket"); + + // La réponse au `tools/list` arrive AVANT que l'ask soit résolu : c'est la + // preuve anti-wedge. (Sur l'ancien code lockstep, ce recv timeout-ait.) + let response = tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv()) + .await + .expect("tools/list must be answered while the ask is still pending") + .expect("a response payload"); + let response: Value = serde_json::from_slice(&response).unwrap(); + assert_eq!(response["id"], json!(2), "the answered call is tools/list"); + assert!( + response["result"]["tools"].is_array(), + "tools/list result, got {response}" + ); + + // L'ask est toujours en vol (non résolu, aucun `idea_reply` ne viendra) : la + // boucle restera en attente de sa réponse, c'est attendu. On la ferme et on + // abandonne la tâche serve (le rendezvous parqué ne se résoudra jamais ici). + assert_eq!(mailbox.pending(&agent_id), 1, "ask still pending"); + close.notify_one(); + serve.abort(); + let _ = serve.await; +} + +#[tokio::test] +async fn ask_agent_rendezvous_times_out_with_a_jsonrpc_error() { + // La cible ne répondra jamais. Avec une borne courte injectée, l'ask doit + // finir par renvoyer une ERREUR JSON-RPC propre (filet de sécurité serveur), + // au lieu de pendre indéfiniment. + let contexts = FakeContexts::new(); + let agent_id = contexts.seed_agent("architect"); + let (service, _mailbox, sessions) = build_service_with_mailbox(contexts); + seed_live_pty( + &sessions, + agent_id, + SessionId::from_uuid(Uuid::from_u128(910)), + ); + let server = server(service) + .with_ask_rendezvous_timeout(std::time::Duration::from_millis(50)); + + let raw = tools_call( + 1, + "idea_ask_agent", + json!({ "target": "architect", "task": "never answered" }), + ); + let response = tokio::time::timeout( + std::time::Duration::from_secs(10), + server.handle_raw(&raw), + ) + .await + .expect("must not hang past the injected timeout") + .expect("reply owed"); + + let error = response.error.expect("a JSON-RPC error on timeout"); + assert_eq!(error.code, error_codes::INTERNAL_ERROR, "got {error:?}"); + assert!( + error.message.contains("timeout"), + "explicit timeout message, got {error:?}" + ); + assert!(response.result.is_none(), "error responses carry no result"); +} diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 75b4e8a..5806837 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -16,10 +16,10 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::agent::{AgentManifest, ManifestEntry}; use domain::events::{DomainEvent, OrchestrationSource}; +use domain::ids::NodeId; use domain::ids::SkillId; use domain::ids::{AgentId, ProfileId, ProjectId}; use domain::markdown::MarkdownDoc; -use domain::ids::NodeId; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, @@ -241,6 +241,7 @@ impl AgentRuntime for FakeRuntime { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } @@ -295,7 +296,6 @@ impl PtyPort for FakePty { } } - #[derive(Default, Clone)] struct NoopBus; impl EventBus for NoopBus { @@ -471,11 +471,7 @@ fn build_service_with_mailbox( } /// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it. -fn seed_live_pty( - sessions: &TerminalSessions, - agent_id: AgentId, - session_id: SessionId, -) { +fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) { sessions.insert( PtyHandle { session_id }, TerminalSession::starting( @@ -650,7 +646,11 @@ async fn ask_request_surfaces_reply_alongside_detail() { let agent_id = contexts.seed_agent("architect"); let (service, mailbox, sessions) = build_service_with_mailbox(contexts.clone()); // Target already live in the PTY registry, so the ask reuses its terminal. - seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242))); + seed_live_pty( + &sessions, + agent_id, + SessionId::from_uuid(Uuid::from_u128(4242)), + ); let req = tmp.0.join("ask-req.json"); std::fs::write( @@ -770,7 +770,8 @@ async fn non_ask_request_omits_reply_key() { #[test] fn legacy_response_without_reply_round_trips_without_reintroducing_key() { // A response payload exactly as a pre-D6b IdeA would have written it. - let legacy = r#"{"ok":true,"action":"spawn_agent","detail":"launched agent dev-backend in background"}"#; + let legacy = + r#"{"ok":true,"action":"spawn_agent","detail":"launched agent dev-backend in background"}"#; let parsed: OrchestratorResponse = serde_json::from_str(legacy).expect("legacy response must still parse"); @@ -873,7 +874,12 @@ async fn watcher_publishes_processed_event_tagged_file_source() { let event = found.expect("watcher must publish a processed beacon within the poll budget"); match event { - DomainEvent::OrchestratorRequestProcessed { source, ok, requester_id, .. } => { + DomainEvent::OrchestratorRequestProcessed { + source, + ok, + requester_id, + .. + } => { assert_eq!(source, OrchestrationSource::File, "fs door must tag File"); assert!(ok, "the spawn request succeeded"); assert_eq!(requester_id, "Main", "requester id = request subdirectory"); diff --git a/crates/infrastructure/tests/permission_store.rs b/crates/infrastructure/tests/permission_store.rs new file mode 100644 index 0000000..e275869 --- /dev/null +++ b/crates/infrastructure/tests/permission_store.rs @@ -0,0 +1,86 @@ +//! L2 integration tests for [`FsPermissionStore`] against a real temp project. + +use std::path::PathBuf; +use std::sync::Arc; + +use domain::ids::{AgentId, ProjectId}; +use domain::ports::{FileSystem, PermissionStore}; +use domain::project::{Project, ProjectPath}; +use domain::remote::RemoteRef; +use domain::{ + AgentPermissionOverride, PermissionSet, Posture, ProjectPermissions, PERMISSIONS_VERSION, +}; +use infrastructure::{FsPermissionStore, LocalFileSystem}; +use uuid::Uuid; + +/// A unique scratch project directory under the OS temp dir, cleaned up on drop. +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("idea-l2-permissions-{}", Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + + fn project_root(&self) -> String { + self.0.to_string_lossy().into_owned() + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn store() -> FsPermissionStore { + let fs: Arc = Arc::new(LocalFileSystem::new()); + FsPermissionStore::new(fs) +} + +fn project(tmp: &TempDir) -> Project { + Project::new( + ProjectId::new_random(), + "permissions", + ProjectPath::new(tmp.project_root()).unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + +#[tokio::test] +async fn missing_permissions_file_returns_default_document() { + let tmp = TempDir::new(); + let project = project(&tmp); + + let loaded = store().load_permissions(&project).await.unwrap(); + + assert_eq!(loaded, ProjectPermissions::default()); + assert_eq!(loaded.version, PERMISSIONS_VERSION); +} + +#[tokio::test] +async fn save_then_load_roundtrips_project_defaults_and_agent_override() { + let tmp = TempDir::new(); + let project = project(&tmp); + let agent = AgentId::new_random(); + let doc = ProjectPermissions::new( + Some(PermissionSet::new(vec![], Posture::Ask)), + vec![AgentPermissionOverride::new( + agent, + PermissionSet::new(vec![], Posture::Deny), + )], + ); + + let store = store(); + store.save_permissions(&project, &doc).await.unwrap(); + + let loaded = store.load_permissions(&project).await.unwrap(); + assert_eq!(loaded, doc); + assert_eq!(loaded.resolve_for(agent).unwrap().fallback(), Posture::Deny); + + let path = tmp.0.join(".ideai").join("permissions.json"); + assert!(path.exists(), "store writes under .ideai/permissions.json"); +} diff --git a/crates/infrastructure/tests/pty_adapter.rs b/crates/infrastructure/tests/pty_adapter.rs index a07f60a..f2ecb92 100644 --- a/crates/infrastructure/tests/pty_adapter.rs +++ b/crates/infrastructure/tests/pty_adapter.rs @@ -26,6 +26,7 @@ fn sh_spec(script: &str) -> SpawnSpec { cwd: ProjectPath::new("/").unwrap(), env: Vec::new(), context_plan: None, + sandbox: None, } } diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 64f45c7..bf00690 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -24,6 +24,7 @@ import { TauriSkillGateway } from "./skill"; import { TauriMemoryGateway } from "./memory"; import { TauriEmbedderGateway } from "./embedder"; import { TauriGitGateway } from "./git"; +import { TauriPermissionGateway } from "./permission"; function notImplemented(what: string): never { const err: GatewayError = { @@ -55,6 +56,7 @@ export function createTauriGateways(): Gateways { skill: new TauriSkillGateway(), memory: new TauriMemoryGateway(), embedder: new TauriEmbedderGateway(), + permission: new TauriPermissionGateway(), }; } @@ -71,4 +73,5 @@ export { TauriMemoryGateway, TauriEmbedderGateway, TauriGitGateway, + TauriPermissionGateway, }; diff --git a/frontend/src/adapters/input.ts b/frontend/src/adapters/input.ts index be1d132..b25406b 100644 --- a/frontend/src/adapters/input.ts +++ b/frontend/src/adapters/input.ts @@ -1,9 +1,11 @@ /** - * Tauri adapter for {@link InputGateway} (lot F1, cadrage §4.2). + * Tauri adapter for {@link InputGateway} (ARCHITECTURE §20.3). * - * `submit` → `submit_agent_input` (enqueue), `interrupt` → `interrupt_agent` - * (preempt). These app-tauri commands land with lot C4; this adapter is complete - * on the frontend side and the mock covers tests/offline dev meanwhile. + * `interrupt` → `interrupt_agent` (preempt). `delegationDelivered` → + * `delegation_delivered` (the native write-portal's ack that a delegation's + * text was effectively written into the PTY). The former `submit` → + * `submit_agent_input` path is gone: the agent cell is a native terminal, so + * human keystrokes reach the PTY directly (no mediated-input strip). * * Commands use snake_case (Tauri convention); payload keys are camelCase * (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent @@ -15,19 +17,25 @@ import { invoke } from "@tauri-apps/api/core"; import type { InputGateway } from "@/ports"; export class TauriInputGateway implements InputGateway { - async submit( - projectId: string, - agentId: string, - text: string, - ): Promise { - await invoke("submit_agent_input", { - request: { projectId, agentId, text }, - }); - } - async interrupt(projectId: string, agentId: string): Promise { await invoke("interrupt_agent", { request: { projectId, agentId }, }); } + + async delegationDelivered( + projectId: string, + agentId: string, + ticket: string, + ): Promise { + await invoke("delegation_delivered", { + request: { projectId, agentId, ticket }, + }); + } + + async setFrontAttached(agentId: string, attached: boolean): Promise { + await invoke("set_front_attached", { + request: { agentId, attached }, + }); + } } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 3ba28c9..a4c0f70 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -27,7 +27,10 @@ import type { MemoryIndexEntry, MemoryLink, MemoryType, + EffectivePermissions, + PermissionSet, Project, + ProjectPermissions, ProfileAvailability, ResumableAgent, Skill, @@ -53,6 +56,7 @@ import type { OpenTerminalOptions, ProfileGateway, ProjectGateway, + PermissionGateway, ReattachResult, RemoteGateway, SkillGateway, @@ -1533,39 +1537,106 @@ export class MockEmbedderGateway implements EmbedderGateway { } } -/** One recorded mediated-input call (for test assertions). */ +/** One recorded agent-input control call (for test assertions). */ export interface MockInputCall { projectId: string; agentId: string; - /** Present for `submit`, absent for `interrupt`. */ - text?: string; + /** Present for `delegationDelivered`, absent for `interrupt`. */ + ticket?: string; } /** - * In-memory {@link InputGateway} (lot F1). Records every `submit`/`interrupt` - * so tests can assert the component routed through the port with the right - * args — no backend. `submit` always resolves (the FIFO never refuses an - * enqueue; the forward/fallback rule lives backend-side, §4.2/§6). + * In-memory {@link InputGateway} (ARCHITECTURE §20.3). Records every + * `interrupt`/`delegationDelivered` so tests can assert the component routed + * through the port with the right args — no backend. * * Exported so tests can instantiate it directly (same pattern as the other mocks). */ export class MockInputGateway implements InputGateway { - /** All recorded submit calls, in order. */ - readonly submits: MockInputCall[] = []; /** All recorded interrupt calls, in order. */ readonly interrupts: MockInputCall[] = []; - - async submit( - projectId: string, - agentId: string, - text: string, - ): Promise { - this.submits.push({ projectId, agentId, text }); - } + /** All recorded delegation-delivered acks, in order. */ + readonly delivered: MockInputCall[] = []; + /** All recorded front-attached reports, in order. */ + readonly frontAttached: { agentId: string; attached: boolean }[] = []; async interrupt(projectId: string, agentId: string): Promise { this.interrupts.push({ projectId, agentId }); } + + async delegationDelivered( + projectId: string, + agentId: string, + ticket: string, + ): Promise { + this.delivered.push({ projectId, agentId, ticket }); + } + + async setFrontAttached(agentId: string, attached: boolean): Promise { + this.frontAttached.push({ agentId, attached }); + } +} + +/** In-memory permissions gateway. */ +export class MockPermissionGateway implements PermissionGateway { + private docs = new Map(); + + private doc(projectId: string): ProjectPermissions { + if (!this.docs.has(projectId)) { + this.docs.set(projectId, { version: 1, agents: [] }); + } + return this.docs.get(projectId)!; + } + + async getProjectPermissions(projectId: string): Promise { + return structuredClone(this.doc(projectId)); + } + + async updateProjectPermissions( + projectId: string, + permissions: PermissionSet | null, + ): Promise { + const doc = this.doc(projectId); + if (permissions) doc.projectDefaults = structuredClone(permissions); + else delete doc.projectDefaults; + return structuredClone(doc); + } + + async updateAgentPermissions( + projectId: string, + agentId: string, + permissions: PermissionSet | null, + ): Promise { + const doc = this.doc(projectId); + const agents = (doc.agents ?? []).filter((entry) => entry.agentId !== agentId); + if (permissions) agents.push({ agentId, permissions: structuredClone(permissions) }); + doc.agents = agents; + return structuredClone(doc); + } + + async resolveAgentPermissions( + projectId: string, + agentId: string, + ): 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; + return { + rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])], + fallback: mostRestrictive(project?.fallback, agent?.fallback), + }; + } +} + +function mostRestrictive( + project?: PermissionSet["fallback"], + agent?: PermissionSet["fallback"], +): PermissionSet["fallback"] { + const rank = { allow: 0, ask: 1, deny: 2 } as const; + const fallback = project ?? agent ?? "ask"; + if (!agent) return fallback; + return rank[agent] >= rank[fallback] ? agent : fallback; } /** Builds the full set of mock gateways. */ @@ -1585,6 +1656,7 @@ export function createMockGateways(): Gateways { skill: new MockSkillGateway(agentGateway), memory: new MockMemoryGateway(), embedder: new MockEmbedderGateway(), + permission: new MockPermissionGateway(), }; } diff --git a/frontend/src/adapters/permission.ts b/frontend/src/adapters/permission.ts new file mode 100644 index 0000000..369afab --- /dev/null +++ b/frontend/src/adapters/permission.ts @@ -0,0 +1,43 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { + EffectivePermissions, + PermissionSet, + ProjectPermissions, +} from "@/domain"; +import type { PermissionGateway } from "@/ports"; + +/** Tauri-backed permissions gateway. */ +export class TauriPermissionGateway implements PermissionGateway { + getProjectPermissions(projectId: string): Promise { + return invoke("get_project_permissions", { projectId }); + } + + updateProjectPermissions( + projectId: string, + permissions: PermissionSet | null, + ): Promise { + return invoke("update_project_permissions", { + request: { projectId, permissions }, + }); + } + + updateAgentPermissions( + projectId: string, + agentId: string, + permissions: PermissionSet | null, + ): Promise { + return invoke("update_agent_permissions", { + request: { projectId, agentId, permissions }, + }); + } + + resolveAgentPermissions( + projectId: string, + agentId: string, + ): Promise { + return invoke("resolve_agent_permissions", { + request: { projectId, agentId }, + }); + } +} diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index d50f464..3b9078a 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -20,6 +20,22 @@ export type DomainEvent = | { type: "agentExited"; agentId: string; code: number } | { type: "agentProfileChanged"; agentId: string; profileId: string } | { type: "agentBusyChanged"; agentId: string; busy: boolean } + | { + /** + * A delegation is ready to be injected into the agent's native terminal + * (ARCHITECTURE §20). The backend is the queue/busy authority but no longer + * PTY-writes the turn: the frontend write-portal runs the handshake (b→e) + * and writes `text` + `submitSequence`. `submitSequence`/`submitDelayMs` + * come from the target's profile; absent ⇒ the portal applies its defaults + * (`"\r"`, ~60 ms). + */ + type: "delegationReady"; + agentId: string; + ticket: string; + text: string; + submitSequence?: string; + submitDelayMs?: number; + } | { type: "templateUpdated"; templateId: string; version: number } | { type: "agentDriftDetected"; agentId: string; from: number; to: number } | { type: "agentSynced"; agentId: string; to: number } @@ -68,6 +84,67 @@ export interface GatewayError { message: string; } +// --------------------------------------------------------------------------- +// Permissions (LP1) +// --------------------------------------------------------------------------- + +/** Agent capability governed by IdeA permissions. */ +export type Capability = "read" | "write" | "delete" | "executeBash"; + +/** Permission rule verdict. */ +export type PermissionEffect = "allow" | "deny"; + +/** Fallback stance when no permission rule matches. */ +export type PermissionPosture = "ask" | "allow" | "deny"; + +/** Glob path scope, relative to the project root. */ +export type PathScope = string[]; + +/** Shell command matcher. */ +export type CommandMatcher = + | { kind: "exact"; value: string } + | { kind: "prefix"; value: string } + | { kind: "glob"; value: string }; + +/** Per-command bash verdict. */ +export interface CommandRule { + matcher: CommandMatcher; + effect: PermissionEffect; +} + +/** One permission rule. */ +export interface PermissionRule { + capability: Capability; + effect: PermissionEffect; + paths?: PathScope; + commands?: CommandRule[]; +} + +/** Permission policy bundle. */ +export interface PermissionSet { + rules: PermissionRule[]; + fallback: PermissionPosture; +} + +/** One sparse agent override in `.ideai/permissions.json`. */ +export interface AgentPermissionOverride { + agentId: string; + permissions: PermissionSet; +} + +/** Full project permission document. */ +export interface ProjectPermissions { + version: number; + projectDefaults?: PermissionSet; + agents?: AgentPermissionOverride[]; +} + +/** Effective project+agent policy. */ +export interface EffectivePermissions { + rules: PermissionRule[]; + fallback: PermissionPosture; +} + // --------------------------------------------------------------------------- // Layout (L4) — mirror of the domain `LayoutTree` (ARCHITECTURE §3, §7). // --------------------------------------------------------------------------- diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 47dea63..585e9e4 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -206,9 +206,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { {/* ── Create form ── */}
    void handleCreate(e)} - className="flex flex-wrap items-end gap-2 border-b border-border p-4" + className="flex flex-col gap-3 border-b border-border p-4" > -
    +
    {/* Template selector */} -
    +