From fdcf16c38762b278581b397f5c833bae7576f214 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 13 Jun 2026 21:42:53 +0200 Subject: [PATCH 01/15] chore(wip): checkpoint P8/C avant chantier Codex inter-agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sauvegarde de l'arbre de travail en cours (persistance P8, conversations C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le support de la délégation inter-agents pour les profils Codex. Le round-trip inter-agent question/réponse est couvert sans tokens par les tests loopback existants (state::mcp_e2e_loopback_tests). Co-Authored-By: Claude Opus 4.8 --- .ideai/agents.json | 14 + .ideai/agents/newtest.md | 0 .ideai/agents/testconversation.md | 0 .../handoff.md | 7 + .../log.jsonl | 1 + .../handoff.md | 9 + .../log.jsonl | 3 + .ideai/layouts.json | 44 +- .ideai/memory/MEMORY.md | 1 + ...mcp-bridge-and-delegation-runtime-notes.md | 24 + ARCHITECTURE.md | 133 ++++ crates/app-tauri/src/commands.rs | 116 +-- crates/app-tauri/src/dto.rs | 43 +- crates/app-tauri/src/events.rs | 33 + crates/app-tauri/src/lib.rs | 11 +- crates/app-tauri/src/mcp_bridge.rs | 235 ++++-- crates/app-tauri/src/mcp_endpoint.rs | 14 +- crates/app-tauri/src/state.rs | 732 ++++++++++++++++-- crates/app-tauri/tests/chat_bridge.rs | 17 +- crates/app-tauri/tests/dto.rs | 3 +- crates/app-tauri/tests/dto_chat.rs | 14 +- .../app-tauri/tests/list_live_agents_r0b.rs | 10 +- crates/app-tauri/tests/orchestrator_wiring.rs | 10 +- crates/application/src/agent/catalogue.rs | 7 +- crates/application/src/agent/lifecycle.rs | 92 ++- crates/application/src/agent/mod.rs | 14 +- crates/application/src/layout/mod.rs | 2 +- crates/application/src/layout/usecases.rs | 7 +- crates/application/src/lib.rs | 26 +- .../src/orchestrator/context_guard.rs | 64 +- .../application/src/orchestrator/service.rs | 129 +-- crates/application/src/terminal/registry.rs | 7 +- crates/application/tests/agent_lifecycle.rs | 288 ++++--- .../application/tests/change_agent_profile.rs | 37 +- .../application/tests/conversation_record.rs | 41 +- .../tests/list_resumable_agents.rs | 33 +- .../application/tests/orchestrator_service.rs | 294 +++++-- crates/application/tests/profile_usecases.rs | 16 +- crates/application/tests/send_blocking_d1.rs | 13 +- .../application/tests/structured_launch_d3.rs | 156 +++- .../tests/structured_registry_d1.rs | 13 +- crates/domain/src/conversation.rs | 11 +- crates/domain/src/conversation_log.rs | 32 +- crates/domain/src/events.rs | 23 + crates/domain/src/input.rs | 58 +- crates/domain/src/mailbox.rs | 25 +- crates/domain/src/profile.rs | 125 ++- crates/domain/tests/agent_profile_a0.rs | 10 +- crates/domain/tests/layout.rs | 7 +- crates/domain/tests/structured_session_d0.rs | 9 +- .../src/conversation_log/handoff.rs | 6 +- crates/infrastructure/src/input/mod.rs | 293 +++++-- crates/infrastructure/src/lib.rs | 2 +- crates/infrastructure/src/mailbox/mod.rs | 20 +- .../src/orchestrator/mcp/server.rs | 9 +- .../src/orchestrator/mcp/tools.rs | 13 +- .../src/orchestrator/mcp/transport.rs | 4 +- crates/infrastructure/src/session/mod.rs | 44 +- .../infrastructure/tests/conversation_log.rs | 242 ++++-- crates/infrastructure/tests/mcp_server.rs | 87 ++- .../tests/orchestrator_watcher.rs | 25 +- frontend/src/adapters/input.ts | 30 +- frontend/src/adapters/mock/index.ts | 33 +- frontend/src/domain/index.ts | 16 + .../features/layout/LayoutGrid.f2.test.tsx | 101 +-- frontend/src/features/layout/LayoutGrid.tsx | 95 ++- .../features/terminals/MediatedInput.test.tsx | 108 --- .../src/features/terminals/MediatedInput.tsx | 93 --- .../terminals/TerminalView.portal.test.tsx | 174 +++++ .../src/features/terminals/TerminalView.tsx | 60 +- frontend/src/features/terminals/index.ts | 8 +- .../features/terminals/useAgentBusy.test.tsx | 80 -- .../src/features/terminals/useAgentBusy.ts | 58 -- .../terminals/useWritePortal.test.tsx | 270 +++++++ .../src/features/terminals/useWritePortal.ts | 244 ++++++ frontend/src/ports/index.ts | 59 +- 76 files changed, 3783 insertions(+), 1404 deletions(-) create mode 100644 .ideai/agents/newtest.md create mode 100644 .ideai/agents/testconversation.md create mode 100644 .ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md create mode 100644 .ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl create mode 100644 .ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/handoff.md create mode 100644 .ideai/conversations/6ffc1f69-77a5-0da1-1965-ef26d1df72f5/log.jsonl create mode 100644 .ideai/memory/mcp-bridge-and-delegation-runtime-notes.md delete mode 100644 frontend/src/features/terminals/MediatedInput.test.tsx delete mode 100644 frontend/src/features/terminals/MediatedInput.tsx create mode 100644 frontend/src/features/terminals/TerminalView.portal.test.tsx delete mode 100644 frontend/src/features/terminals/useAgentBusy.test.tsx delete mode 100644 frontend/src/features/terminals/useAgentBusy.ts create mode 100644 frontend/src/features/terminals/useWritePortal.test.tsx create mode 100644 frontend/src/features/terminals/useWritePortal.ts diff --git a/.ideai/agents.json b/.ideai/agents.json index 418a75a..c1ad72a 100644 --- a/.ideai/agents.json +++ b/.ideai/agents.json @@ -35,6 +35,20 @@ "mdPath": "agents/qa.md", "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", "synchronized": false + }, + { + "agentId": "c932c770-cf36-4fb2-a966-71bb1644e4b4", + "name": "TestConversation", + "mdPath": "agents/testconversation.md", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "synchronized": false + }, + { + "agentId": "484eff91-60a1-459f-9ebe-c9552cc70447", + "name": "NewTest", + "mdPath": "agents/newtest.md", + "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "synchronized": false } ] } 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..b4a045a --- /dev/null +++ b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md @@ -0,0 +1,7 @@ +--- +upTo: 44bc8d1d-93e9-46b3-94a8-8d11ab72ae82 +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 + +- **Prompt:** 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. ## Où Fichier : 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`. ## Le trou à combler Le 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). ## Problème à régler d'abord Le `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 ». => 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. ## Le test à écrire (un seul, simple) `async fn ask_reply_round_trips_over_serve_peer_via_handshake_requester()` : 1. `let proj = project();` ; `let contexts = FakeContexts::new();` ; `let target_id = contexts.seed_agent("architect");` 2. `let (service, mailbox, sessions) = build_service_with_mailbox(contexts);` 3. `seed_live_pty(&sessions, target_id, );` (cible vivante → pas de spawn) 4. `let server = Arc::new(McpServer::new(service, proj.clone()));` 5. 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). 6. 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. 7. 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`. 8. 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). GARDE-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. ## Contraintes - Respecte l'archi hexagonale : les fakes implémentent les ports du domaine, aucune dépendance nouvelle. - Code lisible, commenté dans le style du module (doc-comments expliquant POURQUOI le handshake requester est le point testé). - 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. Rends 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. \ 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..a6935f7 --- /dev/null +++ b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl @@ -0,0 +1 @@ +{"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."} 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/layouts.json b/.ideai/layouts.json index 549d22d..58b25ce 100644 --- a/.ideai/layouts.json +++ b/.ideai/layouts.json @@ -1,24 +1,24 @@ { "version": 1, - "activeId": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5", + "activeId": "1af250f0-65ef-4b78-8905-b1746673aee0", "layouts": [ { - "id": "2fc8a7df-0bf6-4116-acd6-895ae04aa3e5", + "id": "1af250f0-65ef-4b78-8905-b1746673aee0", "name": "Default", "kind": "terminal", "tree": { "root": { "type": "split", "node": { - "id": "1eb96c70-e954-42d0-907b-f8528d0442bf", + "id": "56ffe1e4-636c-458d-9ab2-7e278fd45897", "direction": "row", "children": [ { "node": { "type": "leaf", "node": { - "id": "db40e3de-4980-4bed-b6fa-1c136f49d30e", - "session": "d7d4c099-eda3-4b33-82e6-e6f8d57b8b97", + "id": "c3319a9a-1345-4fa2-b64e-5f3fe00d13d8", + "session": "4965c71a-f69f-4c06-90de-ecb81acff710", "agent": "a6ced819-b893-4213-b003-9e9dc79b9641", "agentWasRunning": true } @@ -27,12 +27,36 @@ }, { "node": { - "type": "leaf", + "type": "split", "node": { - "id": "92826533-1a7c-4d9e-b6b4-f1e904ddc81a", - "session": "5d20f53a-ac76-4677-9a6f-064d3319cc73", - "agent": "edce8090-4c57-47c5-a319-c08fd172438b", - "agentWasRunning": true + "id": "8cf1c06e-2654-45a3-bf17-a9c2507da935", + "direction": "column", + "children": [ + { + "node": { + "type": "leaf", + "node": { + "id": "69dc2e23-86f5-4770-84c4-b1b4b2c25299", + "session": "11fbf6b4-eb62-4420-95e9-feb2ff667c43", + "agent": "c932c770-cf36-4fb2-a966-71bb1644e4b4", + "agentWasRunning": true + } + }, + "weight": 1.0 + }, + { + "node": { + "type": "leaf", + "node": { + "id": "b9251e74-3bd5-43ee-90e5-e6bb87faab38", + "session": "69a3bf52-05ef-45c0-badf-26b0d8224f0e", + "agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", + "agentWasRunning": true + } + }, + "weight": 1.0 + } + ] } }, "weight": 1.0 diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 73331b8..5492db9 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -3,3 +3,4 @@ - [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). 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..d8811e4 --- /dev/null +++ b/.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md @@ -0,0 +1,24 @@ +--- +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 `frontend/node_modules/.bin/tauri build --bundles appimage`), remplacer l'AppImage, relancer IdeA, puis retester. + +## 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. + +See also [[remaining-work-idea-agent-control-ide]], [[agent-context-memory-and-profile-handoff]]. 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/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index d3c9fcf..df31957 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -9,19 +9,16 @@ 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, + 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, + ResolveMemoryLinksInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateSkillInput, }; @@ -30,27 +27,27 @@ 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, - 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, - TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, - UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateSkillRequestDto, - UpdateTemplateRequestDto, WriteTerminalRequestDto, + DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, + EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, + GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, + GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, + InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, + LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, 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, TerminalClosedDto, TerminalSessionDto, + UnassignSkillRequestDto, UpdateAgentContextRequestDto, UpdateMemoryRequestDto, + UpdateProjectContextRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, + WriteTerminalRequestDto, }; use crate::pty::{PtyBridge, PtyChunk}; use crate::state::AppState; @@ -122,6 +119,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)) } @@ -1049,6 +1047,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 { @@ -1206,31 +1209,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 +1233,32 @@ 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(()) +} + /// `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..7ac7c70 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -1197,20 +1197,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 +1208,22 @@ 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 `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 +1437,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..b937b6f 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -56,6 +56,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 { @@ -225,6 +245,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, diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 43c35cb..7baedb0 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); } @@ -166,8 +161,8 @@ pub fn run() { commands::launch_agent, commands::change_agent_profile, commands::agent_send, - commands::submit_agent_input, commands::interrupt_agent, + commands::delegation_delivered, 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..1e0f64e 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -5,31 +5,29 @@ //! `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, - OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext, - RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, - RenameLayout, ResizeTerminal, - ResolveMemoryLinks, - SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions, - SuggestedThisSession, - SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, - UpdateMemory, UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal, - AGENT_MEMORY_RECALL_BUDGET, + 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, + ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects, + ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LoadLayout, McpRuntime, + MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, + OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext, RecallMemory, + ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, + ResizeTerminal, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile, SetActiveLayout, + SnapshotRunningAgents, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, + TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, UpdateMemory, + UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector, @@ -37,19 +35,25 @@ use domain::ports::{ MemoryRecall, MemoryStore, 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, 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, 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; @@ -646,14 +650,15 @@ impl AppState { // 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), ); // Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/ @@ -821,8 +826,7 @@ 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 mailbox = Arc::clone(&inmemory_mailbox) as Arc; let input_mediator = Arc::new( MediatedInbox::with_pty( Arc::clone(&inmemory_mailbox), @@ -835,8 +839,8 @@ impl AppState { ) 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), @@ -1056,6 +1060,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 +1092,556 @@ 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; + }; + if !is_claude_mcp_profile(profile) { + continue; + } + let _ = migrate_claude_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, + } +} + +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 +1677,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. @@ -1808,7 +2369,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 +2397,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 +2455,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 +2547,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 +2628,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 +2669,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 +2885,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::{ @@ -2683,8 +3264,8 @@ 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, @@ -2743,12 +3324,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 +3367,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 +3436,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 +3494,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) @@ -2958,9 +3543,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 +3614,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 +3786,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 +3802,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 +3830,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..9d4fb5f 100644 --- a/crates/app-tauri/tests/chat_bridge.rs +++ b/crates/app-tauri/tests/chat_bridge.rs @@ -126,7 +126,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 +230,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 +243,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..fa28e41 100644 --- a/crates/application/src/agent/catalogue.rs +++ b/crates/application/src/agent/catalogue.rs @@ -19,7 +19,8 @@ use domain::ids::ProfileId; 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. @@ -161,7 +162,9 @@ mod mcp_tests { let mcp = profile(slug).mcp.expect("mcp present"); assert_eq!( mcp.config, - McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() }, + McpConfigStrategy::ConfigFile { + target: ".mcp.json".to_owned() + }, "profile `{slug}` should declare `.mcp.json`" ); assert_eq!(mcp.transport, McpTransport::Stdio); diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index c81a64b..6b0a84b 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -461,8 +461,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 @@ -524,8 +524,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); } @@ -1300,14 +1302,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(), @@ -1679,10 +1680,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`]. /// @@ -1708,17 +1717,33 @@ 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::Flag { flag } => { @@ -1976,6 +2001,7 @@ fn claude_settings_seed(project_root: &str) -> String { ] }}, "skipDangerousModePermissionPrompt": true, + "enabledMcpjsonServers": ["idea"], "sandbox": {{ "enabled": false }} @@ -2347,11 +2373,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 +2555,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"), @@ -2636,6 +2650,8 @@ mod tests { // Valid JSON. let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed is valid JSON"); assert_eq!(parsed["permissions"]["defaultMode"], "bypassPermissions"); + // IdeA MCP server pre-approved so idea_* tools load without a prompt. + assert_eq!(parsed["enabledMcpjsonServers"][0], "idea"); assert_eq!( parsed["permissions"]["additionalDirectories"][0], "/home/me/proj" diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index cda3258..1ddb711 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -20,19 +20,17 @@ pub use structured::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, - ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor, - UpdateAgentContext, + LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, + ListAgentsOutput, McpRuntime, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, + ReadAgentContextOutput, StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, }; +pub use resume::{ + ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, +}; pub use usecases::{ ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, 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..c4d405d 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -28,19 +28,19 @@ 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, + 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, 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::{ 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..3e23ce5 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -19,15 +19,15 @@ 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; @@ -416,7 +416,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 +456,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 +481,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 @@ -755,8 +757,8 @@ impl OrchestratorService { .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) = self.prompt_and_submit_for_agent(project, agent_id).await; + 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 @@ -866,8 +868,8 @@ impl OrchestratorService { let handle = 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) = self.prompt_and_submit_for_agent(project, agent_id).await; + 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 +924,34 @@ 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})" + ); + } + /// 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 +974,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). /// @@ -1039,19 +1068,18 @@ 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")) + AppError::Process(format!( + "handle PTY de l'agent {target} introuvable après lancement" + )) }) } @@ -1318,30 +1346,41 @@ impl OrchestratorService { ))) } - /// 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) { + 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()); + }; + 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()); + }; + let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms); + (profile.prompt_ready_pattern, submit) } /// Resolves a human-friendly profile reference (slug like `claude-code`, 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/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index f7463ee..9d1b89e 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -1312,7 +1312,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 +1591,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 +1617,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 +1646,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 +1669,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 +1696,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 +1752,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 +1810,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 +1856,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 +1916,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 +1947,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 +2225,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 +2437,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 +2482,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; diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs index 83e25f0..1877ec9 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -46,9 +46,7 @@ use domain::{ }; use uuid::Uuid; -use application::{ - ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, TerminalSessions, -}; +use application::{ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, TerminalSessions}; // --------------------------------------------------------------------------- // FakeContexts (AgentContextStore) — manifest + md_path → content @@ -675,10 +673,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())) } } @@ -860,7 +855,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 +1016,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 +1113,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 +1143,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 +1179,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/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..c823d9e 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -343,10 +343,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 +380,6 @@ impl EventBus for SpyBus { } } - struct SeqIds(Mutex); impl SeqIds { fn new() -> Self { @@ -655,13 +654,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 +789,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 +830,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 +961,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 +1015,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 +1144,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 +1183,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 @@ -1242,7 +1267,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 +1318,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 +1390,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 +1453,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 +1538,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 +1680,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 +1716,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 +1823,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 +1906,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 +1951,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 +1977,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 +2002,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 +2028,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 +2072,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 +2088,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 +2100,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 +2147,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 +2216,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 +2323,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 +2350,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 +2370,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 +2395,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 +2417,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 +2461,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 +2495,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 +2511,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 +2659,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 +2674,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 +2716,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 +2741,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 +2774,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 +2791,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 +2820,8 @@ 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" + ); } 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..a6b2e33 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -570,10 +570,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 +606,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 +862,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 +918,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 +984,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"); @@ -1125,7 +1140,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 +1156,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 +1201,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 +1360,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 +1375,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 +1449,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 +1464,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 +1493,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 +1553,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 +1567,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 +1613,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 +1636,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 +1667,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 +1694,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 +1722,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..1f9b053 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; @@ -173,6 +174,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..21b8897 100644 --- a/crates/domain/src/input.rs +++ b/crates/domain/src/input.rs @@ -97,6 +97,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 +131,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,21 +159,30 @@ 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 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/profile.rs b/crates/domain/src/profile.rs index 45505fe..e886930 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -325,6 +325,28 @@ 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, + /// 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, } /// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel). @@ -461,6 +483,8 @@ impl AgentProfile { structured_adapter: None, mcp: None, prompt_ready_pattern: None, + submit_sequence: None, + submit_delay_ms: None, }) } @@ -491,6 +515,23 @@ impl AgentProfile { 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 + } + /// 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 @@ -611,10 +652,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 +691,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 +708,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 +725,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 +776,60 @@ 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)); + } } 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..571af6c 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()) + ); } // --------------------------------------------------------------------------- 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/input/mod.rs b/crates/infrastructure/src/input/mod.rs index 7e26fdf..205eb0c 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, InputMediator, SubmitConfig}; +use domain::mailbox::{AgentMailbox, PendingReply, Ticket, TicketId}; use domain::ports::{EventBus, PtyHandle, PtyPort}; use crate::mailbox::InMemoryMailbox; @@ -122,10 +122,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). @@ -136,6 +139,10 @@ pub struct MediatedInbox { pty: Option>, /// Per-agent live input handle (one stream per agent), fed by `bind_handle`. handles: Mutex>, + /// 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>, /// 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. @@ -153,6 +160,7 @@ impl MediatedInbox { clock, pty: None, handles: Mutex::new(HashMap::new()), + submit: Mutex::new(HashMap::new()), watched: Arc::new(Mutex::new(HashSet::new())), } } @@ -181,6 +189,7 @@ impl MediatedInbox { clock, pty: Some(pty), handles: Mutex::new(HashMap::new()), + submit: Mutex::new(HashMap::new()), watched: Arc::new(Mutex::new(HashSet::new())), } } @@ -188,7 +197,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 +209,12 @@ 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) + } + /// The underlying mailbox (e.g. for `cancel_head` / `resolve` from the orchestrator). #[must_use] pub fn mailbox(&self) -> Arc { @@ -250,10 +268,7 @@ 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()) - { + 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); break; @@ -273,6 +288,17 @@ 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; @@ -286,29 +312,36 @@ impl InputMediator for MediatedInbox { since_ms: self.clock.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). + // 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(); + events.publish(DomainEvent::DelegationReady { + agent_id: agent, + ticket: ticket_id, + // 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 …]`). + text: delegation_preamble(&ticket.requester, ticket_id, &ticket.task), + submit_sequence: submit.sequence, + submit_delay_ms: submit.delay_ms, + }); } } self.mailbox.enqueue(agent, ticket) @@ -323,12 +356,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); } @@ -392,7 +428,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 +460,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 +501,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 +528,123 @@ 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)), + ); + 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 enqueue_does_not_write_into_the_pty() { + // The whole point of §20: the backend stops writing the turn into the PTY. + let pty = Arc::new(FakePty::new()); + let inbox = MediatedInbox::with_pty( + Arc::new(InMemoryMailbox::new()), + Arc::new(FixedClock(1)), + Arc::clone(&pty) as Arc, + ); + let a = agent(1); + let h = handle(1); + inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default()); + + inbox.enqueue(a, ticket(10, "do X")); + inbox.enqueue(a, ticket(11, "do Y")); // even a second enqueue must not write + + assert!( + pty.writes.lock().unwrap().is_empty(), + "enqueue must perform NO pty.write (the `\\n` band-aid is gone)" + ); + } + #[tokio::test] async fn enqueue_returns_pending_reply_resolved_via_mailbox() { let inbox = inbox_at(5); @@ -490,7 +676,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 +748,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 +845,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 +862,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 +878,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 +887,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 +903,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 +927,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 +947,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,8 +975,13 @@ 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"); } diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index b458629..c7671e8 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -38,10 +38,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}; 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..a4d9fe5 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -219,9 +219,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!({})); @@ -238,7 +236,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/session/mod.rs b/crates/infrastructure/src/session/mod.rs index de4084d..d064b40 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -165,7 +165,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,8 +218,8 @@ 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")); assert!(sess.events.is_empty()); @@ -371,7 +373,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!( @@ -673,10 +679,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 +712,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. @@ -730,10 +740,12 @@ mod tests { .unwrap() .events .is_empty()); - assert!(codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#) - .unwrap() - .events - .is_empty()); + assert!( + codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#) + .unwrap() + .events + .is_empty() + ); } // ---- Machinerie process via FakeCli --------------------------------- @@ -1160,7 +1172,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); } 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..4badeda 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; use infrastructure::{ InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport, SystemMillisClock, }; -use infrastructure::orchestrator::mcp::jsonrpc::error_codes; use serde_json::{json, Value}; @@ -296,7 +296,6 @@ impl PtyPort for FakePty { } } - #[derive(Default, Clone)] struct NoopBus; impl EventBus for NoopBus { @@ -468,7 +467,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 +486,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 +510,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 +524,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 +549,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 +639,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 +668,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 +679,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 +771,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" + ); } // --------------------------------------------------------------------------- @@ -898,7 +921,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 +953,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 +981,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 +1009,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,7 +1040,11 @@ 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); diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 75b4e8a..81cce99 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, @@ -295,7 +295,6 @@ impl PtyPort for FakePty { } } - #[derive(Default, Clone)] struct NoopBus; impl EventBus for NoopBus { @@ -471,11 +470,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 +645,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 +769,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 +873,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/frontend/src/adapters/input.ts b/frontend/src/adapters/input.ts index be1d132..180c7b8 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,19 @@ 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 }, + }); + } } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 3ba28c9..6cb6c0c 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1533,39 +1533,38 @@ 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[] = []; 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 }); + } } /** Builds the full set of mock gateways. */ diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index d50f464..5a4ab73 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 } diff --git a/frontend/src/features/layout/LayoutGrid.f2.test.tsx b/frontend/src/features/layout/LayoutGrid.f2.test.tsx index 5579a4c..30ca15f 100644 --- a/frontend/src/features/layout/LayoutGrid.f2.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.f2.test.tsx @@ -1,22 +1,20 @@ /** - * F2 — mediated input for agent cells (cadrage §4.2/§7). + * Agent cell = NATIVE terminal (ARCHITECTURE §20) — supersedes the F2 mediated + * input contract. * - * Two contracts are exercised here, with the real {@link DIProvider} and the - * in-memory mocks: + * The agent cell no longer mediates keystrokes through a separate strip. It is a + * real terminal: * - * 1. **Keystrokes are mediated in agent mode.** A cell that pins an agent puts - * `TerminalView` in `agentMode`: human keystrokes (`term.onData`) MUST NOT be - * written to the PTY (input flows through {@link MediatedInput}). A plain - * (agent-less) cell keeps the raw-shell behaviour (keystrokes → PTY). - * 2. **The PTY *output* is always painted** (both modes) — xterm stays the raw - * output view, unchanged by F2. - * 3. **`MediatedInput` is mounted under the terminal** for an agent cell, and - * **never** an `AgentChatView` (the structured chat surface stays removed). + * 1. **Keystrokes reach the PTY in BOTH modes.** An agent cell and a plain cell + * alike forward `term.onData` keystrokes straight to `handle.write`. + * 2. **The PTY *output* is always painted** — xterm stays the raw output view. + * 3. **No `MediatedInput` strip and no `AgentChatView`** are ever mounted; the + * agent cell renders only the raw {@link TerminalView}. * * xterm is stubbed (as in the sibling chat test) so `term.open` succeeds under * jsdom and the opener/keystroke wiring genuinely runs. The stub captures the - * `onData` keystroke handler so the test can fire a keystroke and assert whether - * it reached the PTY handle (`handle.write`). + * `onData` keystroke handler so the test can fire a keystroke and assert it + * reached the PTY handle (`handle.write`). */ import { beforeEach, describe, it, expect, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; @@ -79,7 +77,7 @@ import { DIProvider } from "@/app/di"; import { leaves } from "./layout"; import { LayoutGrid } from "./LayoutGrid"; -/** Fresh mock set (input gateway included so MediatedInput is fully wired). */ +/** Fresh mock set (input gateway included so the write-portal is fully wired). */ function makeGateways(agentGateway: MockAgentGateway): Gateways { return { layout: new MockLayoutGateway(), @@ -123,42 +121,43 @@ function spyTerminalWrite(gw: MockTerminalGateway): ReturnType { return writeSpy; } +async function pinAgent(gateways: Gateways, agentId: string): Promise { + const layout = gateways.layout as MockLayoutGateway; + const leafId = leaves(await layout.loadLayout("p1"))[0].id; + await layout.mutateLayout("p1", { + type: "setCellAgent", + target: leafId, + agent: agentId, + }); +} + beforeEach(() => { xtermState.keyHandler = null; xtermState.writes = []; }); -describe("LayoutGrid F2 — mediated input for agent cells", () => { - it("an AGENT cell does NOT write keystrokes to the PTY (input is mediated)", async () => { +describe("LayoutGrid — agent cell is a native terminal (§20)", () => { + it("an AGENT cell DOES write keystrokes to the PTY (native terminal)", async () => { const agentGateway = new MockAgentGateway(); const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude", }); const gateways = makeGateways(agentGateway); - // Pin the agent onto the leaf in this run's layout gateway. - const layout = gateways.layout as MockLayoutGateway; - const leafId = leaves(await layout.loadLayout("p1"))[0].id; - await layout.mutateLayout("p1", { - type: "setCellAgent", - target: leafId, - agent: agent.id, - }); + await pinAgent(gateways, agent.id); const writeSpy = spyAgentWrite(agentGateway); renderGrid(gateways); - // Wait for the launch to settle (the opener ran and adopted a handle). await waitFor(() => expect(agentGateway.launchAgent).toHaveBeenCalled()); await waitFor(() => expect(xtermState.keyHandler).not.toBeNull()); - // Simulate a human keystroke into xterm. + // Simulate human keystrokes into xterm; in native mode they reach the PTY. xtermState.keyHandler!("h"); xtermState.keyHandler!("i"); - // In agent mode the keystroke must be swallowed — never forwarded to the PTY. - expect(writeSpy).not.toHaveBeenCalled(); + await waitFor(() => expect(writeSpy).toHaveBeenCalled()); }); it("a PLAIN cell still writes keystrokes to the PTY (raw shell, unchanged)", async () => { @@ -176,64 +175,48 @@ describe("LayoutGrid F2 — mediated input for agent cells", () => { xtermState.keyHandler!("l"); xtermState.keyHandler!("s"); - // Plain shell: keystrokes flow straight to the PTY (current behaviour). await waitFor(() => expect(writeSpy).toHaveBeenCalled()); }); it("paints PTY output in BOTH modes (xterm output view unchanged)", async () => { - // Agent cell: the mock greets on open → output must reach term.write. const agentGateway = new MockAgentGateway(); const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude", }); const gateways = makeGateways(agentGateway); - const layout = gateways.layout as MockLayoutGateway; - const leafId = leaves(await layout.loadLayout("p1"))[0].id; - await layout.mutateLayout("p1", { - type: "setCellAgent", - target: leafId, - agent: agent.id, - }); + await pinAgent(gateways, agent.id); renderGrid(gateways); - // The mock agent greets on open; that output must be painted by xterm even - // though keystroke input is mediated. await waitFor(() => expect(xtermState.writes.length).toBeGreaterThan(0)); }); - it("mounts MediatedInput under an agent cell, never an AgentChatView", async () => { + it("renders only the raw TerminalView — no MediatedInput, no AgentChatView", async () => { const agentGateway = new MockAgentGateway(); const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude", }); const gateways = makeGateways(agentGateway); - const layout = gateways.layout as MockLayoutGateway; - const leafId = leaves(await layout.loadLayout("p1"))[0].id; - await layout.mutateLayout("p1", { - type: "setCellAgent", - target: leafId, - agent: agent.id, - }); - - renderGrid(gateways); - - await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy()); - expect(screen.getByTestId("terminal-view")).toBeTruthy(); - expect(screen.getByTestId("mediated-input")).toBeTruthy(); - expect(screen.queryByTestId("agent-chat-view")).toBeNull(); - }); - - it("a PLAIN cell has NO mediated input strip", async () => { - const agentGateway = new MockAgentGateway(); - const gateways = makeGateways(agentGateway); + await pinAgent(gateways, agent.id); renderGrid(gateways); await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy()); expect(screen.getByTestId("terminal-view")).toBeTruthy(); expect(screen.queryByTestId("mediated-input")).toBeNull(); + expect(screen.queryByTestId("agent-chat-view")).toBeNull(); + }); + + it("a PLAIN cell has no portal overlay", async () => { + const agentGateway = new MockAgentGateway(); + const gateways = makeGateways(agentGateway); + + renderGrid(gateways); + + await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy()); + expect(screen.getByTestId("terminal-view")).toBeTruthy(); + expect(screen.queryByTestId("write-portal-overlay")).toBeNull(); }); }); diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index bd0df85..8c66fb5 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -27,10 +27,9 @@ import type { TerminalHandle, } from "@/ports"; import { - MediatedInput, ResumeConversationPopup, TerminalView, - useAgentBusy, + useWritePortal, } from "@/features/terminals"; import { useGateways } from "@/app/di"; import { leaves, normalizeWeights, resizeAdjacent } from "./layout"; @@ -203,10 +202,12 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0; const { agent: agentGateway, system } = useGateways(); - // Per-agent busy map (lot F1) feeds the mediated input strip: while the agent - // is processing a turn, "Envoyer" is dimmed (but the enqueue stays open) and - // "Interrompre" is active. Absent key ⇒ idle. - const busyMap = useAgentBusy(); + // The single write-portal of this cell (ARCHITECTURE §20). It owns the human + // line counter, the local delegation FIFO, the handshake (b→e) and the overlay + // shown while a delegation is being injected. For a plain (agent-less) cell + // `agent` is null: the portal stays inert (no subscription, no overlay) and is + // simply not passed to the terminal. + const { portal, overlay } = useWritePortal(projectId, agent ?? null); // Load the project's agents for the dropdown. const [agents, setAgents] = useState([]); @@ -552,44 +553,66 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm )} {/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the - raw xterm {@link TerminalView}. Agent cells are interactive PTYs; their - cross-model delegation happens through MCP tools, not a chat view. - Re-key on the agent so the right opener is captured at mount; the - persisted session drives re-attach (never a re-spawn) when navigating. - - Lot F2 (cadrage §4.2): in an **agent** cell the terminal is output-only - for the human (`agentMode`) and the {@link MediatedInput} strip is - mounted **under** it — keystrokes route through IdeA, not the PTY. A - **plain** cell keeps the raw-shell behaviour (keystrokes → PTY) and has - no input strip. We stack terminal + strip in a column so the strip sits - beneath the live output. */} + raw xterm {@link TerminalView}. Agent cells are **native terminals** + (ARCHITECTURE §20): every human keystroke (Enter included) reaches the + PTY. There is no mediated-input strip; cross-model delegation flows + through the write-portal (which injects at a clean line boundary) and + MCP tools. Re-key on the agent so the right opener is captured at mount; + the persisted session drives re-attach (never a re-spawn) when + navigating. The agent cell passes its {@link WritePortal} so keystroke + counting + injection suspension are wired; a plain cell passes none. */}
-
- void vm.setSession(id, sid)} - agentMode={agentId != null} - /> -
- {agentId != null && ( - + void vm.setSession(id, sid)} + agentMode={agentId != null} + portal={agentId != null ? portal : undefined} + /> + {/* Write-portal overlay (ARCHITECTURE §20.3 step b/e): while a delegation + is being injected into the agent's PTY, a grey veil with a centred + message sits above the terminal. Only ever shown for an agent cell. */} + {agentId != null && overlay && ( +
+ + Un agent est en train de parler… + +
)}
{busyNotice && ( diff --git a/frontend/src/features/terminals/MediatedInput.test.tsx b/frontend/src/features/terminals/MediatedInput.test.tsx deleted file mode 100644 index 670735d..0000000 --- a/frontend/src/features/terminals/MediatedInput.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * F1 — {@link MediatedInput} wired to {@link MockInputGateway} through the real - * {@link DIProvider}. Asserts the mediated-input contract (cadrage §4.2/§6): - * - * - "Envoyer" routes to `InputGateway.submit` with the right args; - * - "Interrompre" routes to `InputGateway.interrupt`; - * - while busy, "Envoyer" is dimmed/aria-disabled but the enqueue STILL goes - * through (forward/fallback — never block the user), and "Interrompre" stays - * active. - * - * Fully offline: gateways are mocks, no backend. - */ -import { describe, it, expect } from "vitest"; -import { render, screen, waitFor, fireEvent } from "@testing-library/react"; - -import type { Gateways } from "@/ports"; -import { MockInputGateway, MockSystemGateway } from "@/adapters/mock"; -import { DIProvider } from "@/app/di"; -import { MediatedInput } from "./MediatedInput"; - -function renderInput( - input: MockInputGateway, - props: Partial> = {}, -) { - const gateways = { - input, - system: new MockSystemGateway(), - } as unknown as Gateways; - return render( - - - , - ); -} - -describe("MediatedInput (with MockInputGateway)", () => { - it("mounts and renders the input strip", () => { - renderInput(new MockInputGateway()); - expect(screen.getByTestId("mediated-input")).toBeTruthy(); - }); - - it("Envoyer routes to gateway.submit with project/agent/text", async () => { - const input = new MockInputGateway(); - renderInput(input); - - fireEvent.change(screen.getByLabelText("Message à l'agent"), { - target: { value: "hello agent" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); - - await waitFor(() => { - expect(input.submits).toEqual([ - { projectId: "p1", agentId: "a1", text: "hello agent" }, - ]); - }); - // No interrupt was triggered. - expect(input.interrupts).toEqual([]); - }); - - it("does not submit blank/whitespace-only text", async () => { - const input = new MockInputGateway(); - renderInput(input); - - fireEvent.change(screen.getByLabelText("Message à l'agent"), { - target: { value: " " }, - }); - fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); - - expect(input.submits).toEqual([]); - }); - - it("Interrompre routes to gateway.interrupt (not an enqueue)", async () => { - const input = new MockInputGateway(); - renderInput(input); - - fireEvent.click(screen.getByRole("button", { name: "Interrompre" })); - - await waitFor(() => { - expect(input.interrupts).toEqual([{ projectId: "p1", agentId: "a1" }]); - }); - expect(input.submits).toEqual([]); - }); - - it("while busy: Envoyer is aria-disabled but the enqueue still goes through, Interrompre stays active", async () => { - const input = new MockInputGateway(); - renderInput(input, { busy: true }); - - const send = screen.getByRole("button", { name: "Envoyer" }); - const interrupt = screen.getByRole("button", { name: "Interrompre" }); - - // Visually disabled (forward/fallback: dimmed, not hard-disabled). - expect(send.getAttribute("aria-disabled")).toBe("true"); - // Interrompre is never disabled. - expect(interrupt.hasAttribute("disabled")).toBe(false); - - // The enqueue path is NOT blocked while busy. - fireEvent.change(screen.getByLabelText("Message à l'agent"), { - target: { value: "queued while busy" }, - }); - fireEvent.click(send); - - await waitFor(() => { - expect(input.submits).toEqual([ - { projectId: "p1", agentId: "a1", text: "queued while busy" }, - ]); - }); - }); -}); diff --git a/frontend/src/features/terminals/MediatedInput.tsx b/frontend/src/features/terminals/MediatedInput.tsx deleted file mode 100644 index 569641d..0000000 --- a/frontend/src/features/terminals/MediatedInput.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/** - * MediatedInput (lot F1, cadrage §4.2/§4.3). - * - * The mediated input strip rendered **under** an agent cell's terminal. Human - * keystrokes for an agent no longer go straight to the PTY — they are typed here - * and routed through the {@link InputGateway} port: - * - * - **Envoyer** → `submit` (enqueue in the agent's single FIFO). - * - **Interrompre** → `interrupt` (preempt the current turn — NOT an enqueue). - * - * Busy semantics (forward/fallback, §4.2/§6): while the agent is `busy`, - * "Envoyer" is **disabled visually** but the enqueue path is never blocked — the - * backend FIFO is the single point that serialises. So `busy` only dims the - * button; "Interrompre" stays active so the user can always preempt. - * - * Hexagonal: this component talks to {@link InputGateway} via the DI provider, - * never to `invoke()` directly. The busy flag is supplied by the caller (fed by - * {@link useAgentBusy}); this keeps the component pure and easy to test. - */ - -import { useState, type FormEvent } from "react"; - -import { Button, Input } from "@/shared"; -import { useGateways } from "@/app/di"; - -export interface MediatedInputProps { - /** Owning project. */ - projectId: string; - /** The agent this input strip targets. */ - agentId: string; - /** - * Whether the agent is currently processing a turn. Dims "Envoyer" (without - * blocking the enqueue) and is irrelevant to "Interrompre" (always active). - * Defaults to `false`. - */ - busy?: boolean; -} - -/** The mediated input strip for an agent cell. */ -export function MediatedInput({ - projectId, - agentId, - busy = false, -}: MediatedInputProps) { - const { input } = useGateways(); - const [text, setText] = useState(""); - - const send = async () => { - const value = text; - if (value.trim() === "") return; - // Clear optimistically: the enqueue is fire-and-forward; never block the - // user (forward/fallback). Even while busy the submit goes through. - setText(""); - await input.submit(projectId, agentId, value); - }; - - const onSubmit = (e: FormEvent) => { - e.preventDefault(); - void send(); - }; - - const onInterrupt = () => { - void input.interrupt(projectId, agentId); - }; - - return ( -
- setText(e.target.value)} - /> - - -
- ); -} diff --git a/frontend/src/features/terminals/TerminalView.portal.test.tsx b/frontend/src/features/terminals/TerminalView.portal.test.tsx new file mode 100644 index 0000000..6b6f6fa --- /dev/null +++ b/frontend/src/features/terminals/TerminalView.portal.test.tsx @@ -0,0 +1,174 @@ +/** + * L3 — TerminalView agent cell is a **native terminal** wired to a + * {@link WritePortal} (ARCHITECTURE §20). + * + * Contracts exercised (xterm stubbed so `term.open` succeeds under jsdom and the + * keystroke wiring genuinely runs; the stub captures the `onData` handler): + * - In agent mode a keystroke is written to the PTY (native), AND reported to + * the portal for line counting. + * - The portal's `isSuspended()` gates the relay: while suspended, keystrokes + * are NOT forwarded to the PTY (but are still reported for counting). + * - The live handle is bound to the portal on adopt, unbound on unmount. + */ +import { beforeEach, describe, it, expect, vi } from "vitest"; +import { render, waitFor } from "@testing-library/react"; + +const xtermState: { keyHandler: ((data: string) => void) | null } = { + keyHandler: null, +}; + +vi.mock("@xterm/xterm", () => ({ + Terminal: class { + loadAddon() {} + open() {} + onData(cb: (data: string) => void) { + xtermState.keyHandler = cb; + return { dispose() {} }; + } + onResize() { + return { dispose() {} }; + } + write() {} + dispose() {} + get cols() { + return 80; + } + get rows() { + return 24; + } + }, +})); +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class { + fit() {} + }, +})); +vi.mock("@xterm/xterm/css/xterm.css", () => ({})); + +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +import type { Gateways, TerminalHandle, WritePortal } from "@/ports"; +import { DIProvider } from "@/app/di"; +import { TerminalView } from "./TerminalView"; + +function makeHandle(write = vi.fn().mockResolvedValue(undefined)): TerminalHandle { + return { + sessionId: "s1", + write, + resize: vi.fn().mockResolvedValue(undefined), + detach: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makePortal(overrides: Partial = {}): WritePortal { + return { + onHumanData: vi.fn(), + isSuspended: vi.fn(() => false), + bindHandle: vi.fn(), + unbindHandle: vi.fn(), + ...overrides, + }; +} + +function decode(b: Uint8Array): string { + return new TextDecoder().decode(b); +} + +beforeEach(() => { + xtermState.keyHandler = null; +}); + +describe("TerminalView native agent terminal + portal (§20)", () => { + it("writes keystrokes to the PTY in agent mode and reports them to the portal", async () => { + const write = vi.fn().mockResolvedValue(undefined); + const handle = makeHandle(write); + const open = vi.fn(async () => handle); + const portal = makePortal(); + const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways; + + render( + + + , + ); + + await waitFor(() => expect(xtermState.keyHandler).not.toBeNull()); + await waitFor(() => expect(portal.bindHandle).toHaveBeenCalledWith(handle)); + + xtermState.keyHandler!("a"); + + expect(portal.onHumanData).toHaveBeenCalledWith("a"); + await waitFor(() => expect(write).toHaveBeenCalled()); + expect(decode(write.mock.calls[0][0])).toBe("a"); + }); + + it("does NOT forward keystrokes while the portal is suspended (but still counts them)", async () => { + const write = vi.fn().mockResolvedValue(undefined); + const handle = makeHandle(write); + const open = vi.fn(async () => handle); + const onHumanData = vi.fn(); + const portal = makePortal({ isSuspended: () => true, onHumanData }); + const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways; + + render( + + + , + ); + + await waitFor(() => expect(xtermState.keyHandler).not.toBeNull()); + await waitFor(() => expect(portal.bindHandle).toHaveBeenCalled()); + + xtermState.keyHandler!("x"); + + // Reported for counting, but the relay is suspended ⇒ not written. + expect(onHumanData).toHaveBeenCalledWith("x"); + expect(write).not.toHaveBeenCalled(); + }); + + it("unbinds the handle from the portal on unmount", async () => { + const handle = makeHandle(); + const open = vi.fn(async () => handle); + const portal = makePortal(); + const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways; + + const { unmount } = render( + + + , + ); + + await waitFor(() => expect(portal.bindHandle).toHaveBeenCalled()); + unmount(); + expect(portal.unbindHandle).toHaveBeenCalled(); + }); + + it("a PLAIN cell ignores the portal and writes keystrokes natively", async () => { + const write = vi.fn().mockResolvedValue(undefined); + const handle = makeHandle(write); + const open = vi.fn(async () => handle); + const portal = makePortal(); + const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways; + + render( + + {/* agentMode false: portal must never be touched */} + + , + ); + + await waitFor(() => expect(xtermState.keyHandler).not.toBeNull()); + xtermState.keyHandler!("l"); + + await waitFor(() => expect(write).toHaveBeenCalled()); + expect(portal.onHumanData).not.toHaveBeenCalled(); + expect(portal.bindHandle).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index dd6188b..a9fb4d2 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -3,11 +3,14 @@ * {@link TerminalGateway} port (or a custom opener), and fits it to its container: * * - PTY output (gateway `onData`) → `term.write(bytes)`. - * - xterm `onData` (keystrokes) → `handle.write(bytes)` **only for a plain - * (non-agent) cell** (a raw shell). In **agent mode** (`agentMode`, lot F2, - * cadrage §4.2) keystrokes are NOT written to the PTY: input is mediated by - * IdeA through {@link MediatedInput}. The PTY **output** path stays live and - * INCHANGED in both modes (xterm is always the raw output view). + * - xterm `onData` (keystrokes) → `handle.write(bytes)` in **both** modes: the + * agent cell is a **native terminal** (ARCHITECTURE §20), so human keystrokes + * (Enter included) reach the PTY unconditionally, exactly like a raw shell. + * In agent mode the keystrokes are additionally reported to the write-portal + * (a {@link WritePortal} supplied via `portal`) which (a) keeps a *human line* + * counter (+1 per printable, reset on Enter/Ctrl-C) and (b) can momentarily + * **suspend** the keystroke relay while it injects a delegation. The PTY + * **output** path stays live and unchanged in both modes. * - container resize (fit addon) → `handle.resize(rows, cols)`. * * Pure presentation: it only knows the port, never `invoke()`/`Channel` @@ -40,6 +43,7 @@ import type { OpenTerminalOptions, ReattachResult, TerminalHandle, + WritePortal, } from "@/ports"; interface TerminalViewProps { @@ -76,14 +80,21 @@ interface TerminalViewProps { */ onSessionId?: (sessionId: string) => void; /** - * Agent mode (lot F2, cadrage §4.2). When `true` the cell hosts an agent, so - * keystrokes (`term.onData`) are **not** forwarded to the PTY — input is - * mediated by IdeA through {@link MediatedInput} rendered under the terminal. - * The PTY **output** stays live and unchanged (xterm remains the raw output - * view). When `false`/absent the cell is a plain shell: keystrokes go straight - * to the PTY (current behaviour). Defaults to `false`. + * Agent mode (ARCHITECTURE §20). When `true` the cell hosts an agent and the + * terminal is **native**: keystrokes (`term.onData`) reach the PTY exactly + * like a plain shell, AND are reported to the {@link WritePortal} (`portal`) + * for line counting / suspension. When `false`/absent the cell is a plain + * shell with no portal. Defaults to `false`. */ agentMode?: boolean; + /** + * The write-portal for this agent cell (ARCHITECTURE §20). Only meaningful in + * agent mode: it receives keystroke reports, gates the relay via + * {@link WritePortal.isSuspended}, and is given the live handle so it can + * inject delegations through the same single PTY writer. Absent for plain + * cells. + */ + portal?: WritePortal; } export function TerminalView({ @@ -93,6 +104,7 @@ export function TerminalView({ sessionId, onSessionId, agentMode = false, + portal, }: TerminalViewProps) { const { terminal } = useGateways(); const containerRef = useRef(null); @@ -116,6 +128,8 @@ export function TerminalView({ terminalRef.current = terminal; const agentModeRef = useRef(agentMode); agentModeRef.current = agentMode; + const portalRef = useRef(portal); + portalRef.current = portal; useEffect(() => { const container = containerRef.current; @@ -151,14 +165,22 @@ export function TerminalView({ let handle: TerminalHandle | null = null; const encoder = new TextEncoder(); - // Keystroke → PTY path. In **agent mode** (F2) keystrokes are mediated by - // IdeA (MediatedInput) and must NOT reach the PTY here; we drop them so the - // raw terminal is output-only for the human. In **plain mode** keystrokes go - // straight to the PTY (raw shell), buffering any that arrive before the PTY - // finished opening. The output path below is unchanged in both modes. + // Keystroke → PTY path. The agent cell is a **native terminal** + // (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell. + // In agent mode we additionally (1) report the keystroke to the write-portal + // for line counting, and (2) honour the portal's `isSuspended()` flag, which + // is raised while the portal injects a delegation so the human keystroke does + // not race the injected text. Keystrokes arriving before the PTY opened are + // buffered. The output path below is unchanged in both modes. let pending = ""; const onKey = term.onData((data) => { - if (agentModeRef.current) return; + const portal = portalRef.current; + if (agentModeRef.current && portal) { + // Always report for counting (even while suspended — the portal decides + // what counts), then drop the relay while the portal is injecting. + portal.onHumanData(data); + if (portal.isSuspended()) return; + } if (handle) void handle.write(encoder.encode(data)); else pending += data; }); @@ -176,6 +198,9 @@ export function TerminalView({ return; } handle = h; + // Hand the live handle to the write-portal so it can inject delegations + // through the SAME single PTY writer (no second physical writer). + if (agentModeRef.current) portalRef.current?.bindHandle(h); if (pending) { void h.write(encoder.encode(pending)); pending = ""; @@ -268,6 +293,7 @@ export function TerminalView({ if (rafId) cancelAnimationFrame(rafId); ro.disconnect(); onKey.dispose(); + portalRef.current?.unbindHandle(); // DETACH, never close: tearing the view down (navigation / layout change) // must leave the backend PTY running so the AI isn't cut off. Killing the // PTY is an explicit user action handled elsewhere. diff --git a/frontend/src/features/terminals/index.ts b/frontend/src/features/terminals/index.ts index a385ef3..ddb8486 100644 --- a/frontend/src/features/terminals/index.ts +++ b/frontend/src/features/terminals/index.ts @@ -1,8 +1,6 @@ -/** Terminals feature (L3): xterm.js wrapper bound to the `TerminalGateway`. */ +/** Terminals (L3): xterm.js wrapper bound to the `TerminalGateway`. */ export { TerminalView } from "./TerminalView"; export { ResumeConversationPopup } from "./ResumeConversationPopup"; -export { MediatedInput } from "./MediatedInput"; -export type { MediatedInputProps } from "./MediatedInput"; -export { useAgentBusy } from "./useAgentBusy"; -export type { AgentBusyMap } from "./useAgentBusy"; +export { useWritePortal } from "./useWritePortal"; +export type { UseWritePortalResult } from "./useWritePortal"; diff --git a/frontend/src/features/terminals/useAgentBusy.test.tsx b/frontend/src/features/terminals/useAgentBusy.test.tsx deleted file mode 100644 index 9d8965b..0000000 --- a/frontend/src/features/terminals/useAgentBusy.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/** - * F1 — {@link useAgentBusy} fed by `agentBusyChanged` domain events through the - * mock {@link MockSystemGateway}. Asserts the busy store tracks per-agent state - * and that a busy agent drives the {@link MediatedInput} button states. - */ -import { describe, it, expect } from "vitest"; -import { render, screen, waitFor, act } from "@testing-library/react"; - -import type { Gateways } from "@/ports"; -import { MockInputGateway, MockSystemGateway } from "@/adapters/mock"; -import { DIProvider } from "@/app/di"; -import { MediatedInput } from "./MediatedInput"; -import { useAgentBusy } from "./useAgentBusy"; - -/** Tiny harness: renders the input with `busy` derived from the store. */ -function BusyHarness({ agentId }: { agentId: string }) { - const busy = useAgentBusy(); - return ( - - ); -} - -function renderHarness(system: MockSystemGateway, agentId = "a1") { - const gateways = { - input: new MockInputGateway(), - system, - } as unknown as Gateways; - return render( - - - , - ); -} - -describe("useAgentBusy (with MockSystemGateway events)", () => { - it("starts idle: Envoyer is not aria-disabled", () => { - renderHarness(new MockSystemGateway()); - const send = screen.getByRole("button", { name: "Envoyer" }); - expect(send.getAttribute("aria-disabled")).toBeNull(); - }); - - it("an agentBusyChanged(true) event dims Envoyer; (false) re-enables it", async () => { - const system = new MockSystemGateway(); - renderHarness(system, "a1"); - - act(() => { - system.emit({ type: "agentBusyChanged", agentId: "a1", busy: true }); - }); - await waitFor(() => { - expect( - screen.getByRole("button", { name: "Envoyer" }).getAttribute("aria-disabled"), - ).toBe("true"); - }); - - act(() => { - system.emit({ type: "agentBusyChanged", agentId: "a1", busy: false }); - }); - await waitFor(() => { - expect( - screen.getByRole("button", { name: "Envoyer" }).getAttribute("aria-disabled"), - ).toBeNull(); - }); - }); - - it("a busy event for another agent does not affect this cell", async () => { - const system = new MockSystemGateway(); - renderHarness(system, "a1"); - - act(() => { - system.emit({ type: "agentBusyChanged", agentId: "other", busy: true }); - }); - - // Give the effect a tick; this cell (a1) must stay idle. - await waitFor(() => { - expect( - screen.getByRole("button", { name: "Envoyer" }).getAttribute("aria-disabled"), - ).toBeNull(); - }); - }); -}); diff --git a/frontend/src/features/terminals/useAgentBusy.ts b/frontend/src/features/terminals/useAgentBusy.ts deleted file mode 100644 index 14da283..0000000 --- a/frontend/src/features/terminals/useAgentBusy.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Light per-agent busy store (lot F1, cadrage §4.2/§4.3). - * - * Keeps `agentBusy: Record` fed by the discrete - * `agentBusyChanged` domain event relayed from the backend (a real event, not a - * high-frequency channel). The component consumes this to *disable* the "Envoyer" - * button visually while still allowing the enqueue (forward/fallback, §6) and to - * keep "Interrompre" active. - * - * For F1 the backend event (lot C4) may not yet be emitted; until then the store - * simply stays all-idle, and tests drive it through the mock `SystemGateway`. - */ - -import { useEffect, useState } from "react"; - -import type { DomainEvent } from "@/domain"; -import { useGateways } from "@/app/di"; - -/** A read-only map of agentId → busy. Absent key ⇒ idle. */ -export type AgentBusyMap = Readonly>; - -/** - * Subscribes to `agentBusyChanged` domain events and returns the current - * busy map. Unsubscribes on unmount. - */ -export function useAgentBusy(): AgentBusyMap { - const { system } = useGateways(); - const [busy, setBusy] = useState>({}); - - useEffect(() => { - // The system gateway may be absent in some compositions/tests; stay all-idle - // rather than throw (the busy map is a best-effort overlay). - if (!system) return; - let unsubscribe: (() => void) | undefined; - let cancelled = false; - - void system - .onDomainEvent((event: DomainEvent) => { - if (event.type !== "agentBusyChanged") return; - setBusy((prev) => - prev[event.agentId] === event.busy - ? prev - : { ...prev, [event.agentId]: event.busy }, - ); - }) - .then((un) => { - if (cancelled) un(); - else unsubscribe = un; - }); - - return () => { - cancelled = true; - unsubscribe?.(); - }; - }, [system]); - - return busy; -} diff --git a/frontend/src/features/terminals/useWritePortal.test.tsx b/frontend/src/features/terminals/useWritePortal.test.tsx new file mode 100644 index 0000000..7d4eecf --- /dev/null +++ b/frontend/src/features/terminals/useWritePortal.test.tsx @@ -0,0 +1,270 @@ +/** + * L4 — the write-portal hook {@link useWritePortal} (ARCHITECTURE §20). + * + * Driven with fake timers + a fake handle recording writes, and the real + * {@link DIProvider} wiring the in-memory {@link MockSystemGateway} (to emit + * `delegationReady`) and {@link MockInputGateway} (to record the ack). + * + * Cases (cadrage §20.6): + * - injects NOTHING while the human line is non-empty (counter > 0); + * - at an empty line → writes `text` WITHOUT `\n`, then `\r` after the delay; + * - K=2 race → exactly two `\x7f` before the text; + * - 2 s floor → overlay/suspension held until 2000 ms after step (b); + * - the relay is suspended during the overlay; + * - `delegationDelivered` is acked exactly once after (d); + * - default submitSequence/delay applied when the profile omits them. + */ +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +import type { Gateways, TerminalHandle } from "@/ports"; +import { + MockInputGateway, + MockSystemGateway, +} from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; +import { useWritePortal } from "./useWritePortal"; + +function makeHandle(): { handle: TerminalHandle; writes: string[] } { + const writes: string[] = []; + const handle: TerminalHandle = { + sessionId: "s1", + write: vi.fn(async (b: Uint8Array) => { + writes.push(new TextDecoder().decode(b)); + }), + resize: vi.fn().mockResolvedValue(undefined), + detach: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + }; + return { handle, writes }; +} + +function setup(agentId: string | null = "ag1") { + const system = new MockSystemGateway(); + const input = new MockInputGateway(); + const gateways = { system, input } as unknown as Gateways; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const view = renderHook(() => useWritePortal("p1", agentId), { wrapper }); + return { system, input, view }; +} + +const PROFILELESS = (ticket: string, text: string) => ({ + type: "delegationReady" as const, + agentId: "ag1", + ticket, + text, +}); + +beforeEach(() => { + vi.useFakeTimers(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe("useWritePortal (§20)", () => { + it("injects NOTHING while the human line is non-empty, releases at empty line", async () => { + const { system, view } = setup(); + const { handle, writes } = makeHandle(); + + // Bind a live handle and type a printable char (line non-empty). + act(() => view.result.current.portal.bindHandle(handle)); + act(() => view.result.current.portal.onHumanData("a")); // K = 1 + + // A delegation arrives while the line is non-empty. + await act(async () => { + system.emit(PROFILELESS("t1", "hello")); + }); + // Nothing written: the delegation waits. + expect(writes.length).toBe(0); + + // The human presses Enter → line empty → injection fires. + await act(async () => { + view.result.current.portal.onHumanData("\r"); + await vi.runAllTimersAsync(); + }); + + // text written without trailing newline, then the submit sequence. + expect(writes).toContain("hello"); + expect(writes[writes.length - 1]).toBe("\r"); + expect(writes.join("")).not.toContain("\n"); + }); + + it("at an empty line writes text then \\r after the delay, acking once", async () => { + const { system, input, view } = setup(); + const { handle, writes } = makeHandle(); + act(() => view.result.current.portal.bindHandle(handle)); + + await act(async () => { + system.emit(PROFILELESS("t1", "do it")); + // Flush the microtask that defers step (c)/(d) but NOT the submit delay. + await Promise.resolve(); + await Promise.resolve(); + }); + // Line is empty (counter 0) ⇒ handshake started; overlay raised. + expect(view.result.current.overlay).toBe(true); + + // Before the delay elapses, only the text has been written (no submit yet). + expect(writes).toEqual(["do it"]); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(writes).toEqual(["do it", "\r"]); + // Acked exactly once with the right shape. + expect(input.delivered).toEqual([ + { projectId: "p1", agentId: "ag1", ticket: "t1" }, + ]); + }); + + it("K=2 race → exactly two \\x7f backspaces before the text (never Ctrl-U)", async () => { + const { system, view } = setup(); + const { handle, writes } = makeHandle(); + act(() => view.result.current.portal.bindHandle(handle)); + + // The delegation arrives at an EMPTY line, so the handshake starts and + // raises suspension. The §20.6 race: in the micro-window between "empty + // observed" (b) and step (c) re-checking the counter, the human types K=2 + // printables. TerminalView still reports them while suspended, so the + // counter is 2 when step (c) reads it ⇒ exactly two backspaces. + await act(async () => { + system.emit(PROFILELESS("t1", "TASK")); + // Race two keystrokes in before the timers (delay) flush step (c)/(d). + view.result.current.portal.onHumanData("x"); + view.result.current.portal.onHumanData("y"); + await vi.runAllTimersAsync(); + }); + + const idxText = writes.indexOf("TASK"); + expect(idxText).toBeGreaterThan(0); + expect(writes[idxText - 1]).toBe("\x7f\x7f"); + expect(writes.join("")).not.toContain("\x15"); // never Ctrl-U + }); + + it("holds the overlay until the 2 s floor after step (b)", async () => { + const { system, view } = setup(); + const { handle } = makeHandle(); + act(() => view.result.current.portal.bindHandle(handle)); + + await act(async () => { + system.emit(PROFILELESS("t1", "hi")); + }); + expect(view.result.current.overlay).toBe(true); + + // Finish the write phase (default 60 ms delay) — still well under 2 s. + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + // The relay is still suspended and the overlay still up (2 s floor). + expect(view.result.current.portal.isSuspended()).toBe(true); + expect(view.result.current.overlay).toBe(true); + + // Cross the 2 s floor. + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(view.result.current.overlay).toBe(false); + expect(view.result.current.portal.isSuspended()).toBe(false); + }); + + it("suspends the keystroke relay during the overlay", async () => { + const { system, view } = setup(); + const { handle } = makeHandle(); + act(() => view.result.current.portal.bindHandle(handle)); + + expect(view.result.current.portal.isSuspended()).toBe(false); + await act(async () => { + system.emit(PROFILELESS("t1", "hi")); + }); + // During injection the relay is suspended. + expect(view.result.current.portal.isSuspended()).toBe(true); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(view.result.current.portal.isSuspended()).toBe(false); + }); + + it("applies the profile submitSequence/submitDelayMs when provided", async () => { + const { system, view } = setup(); + const { handle, writes } = makeHandle(); + act(() => view.result.current.portal.bindHandle(handle)); + + await act(async () => { + system.emit({ + type: "delegationReady", + agentId: "ag1", + ticket: "t1", + text: "msg", + submitSequence: "\n", + submitDelayMs: 200, + }); + }); + + // After 100 ms (< 200) the submit sequence has not been written yet. + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(writes).toEqual(["msg"]); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(writes).toEqual(["msg", "\n"]); + }); + + it("ignores delegationReady for a different agent", async () => { + const { system, view } = setup(); + const { handle, writes } = makeHandle(); + act(() => view.result.current.portal.bindHandle(handle)); + + await act(async () => { + system.emit({ + type: "delegationReady", + agentId: "OTHER", + ticket: "t1", + text: "nope", + }); + await vi.runAllTimersAsync(); + }); + expect(writes.length).toBe(0); + expect(view.result.current.overlay).toBe(false); + }); + + it("waits for a live handle before injecting", async () => { + const { system, input, view } = setup(); + const { handle, writes } = makeHandle(); + + // No handle yet: an arriving delegation must NOT write or ack. + await act(async () => { + system.emit(PROFILELESS("t1", "later")); + await vi.runAllTimersAsync(); + }); + expect(writes.length).toBe(0); + expect(input.delivered.length).toBe(0); + + // Handle becomes live (PTY opened) → injection proceeds. + await act(async () => { + view.result.current.portal.bindHandle(handle); + await vi.runAllTimersAsync(); + }); + expect(writes).toEqual(["later", "\r"]); + expect(input.delivered.length).toBe(1); + }); + + it("is inert for a plain (agent-less) cell", async () => { + const { system, view } = setup(null); + const { handle, writes } = makeHandle(); + act(() => view.result.current.portal.bindHandle(handle)); + + await act(async () => { + system.emit(PROFILELESS("t1", "x")); + await vi.runAllTimersAsync(); + }); + expect(writes.length).toBe(0); + expect(view.result.current.overlay).toBe(false); + }); +}); diff --git a/frontend/src/features/terminals/useWritePortal.ts b/frontend/src/features/terminals/useWritePortal.ts new file mode 100644 index 0000000..8873e3d --- /dev/null +++ b/frontend/src/features/terminals/useWritePortal.ts @@ -0,0 +1,244 @@ +/** + * useWritePortal — the single write-portal of an agent cell (ARCHITECTURE §20). + * + * The agent cell hosts the CLI as a **native terminal**: every human keystroke + * (Enter included) reaches the PTY through {@link TerminalView}. IdeA never owns + * a chat box; it only *observes* a "human line in progress" counter and, when a + * delegation is ready, injects its text at a **clean boundary** (human line + * empty) through the SAME handle the human keystrokes use — so there is exactly + * one physical PTY writer (the front), no race between two writers. + * + * Responsibilities (the four the cadrage assigns to the portal): + * 1. The **human-line counter**: `+1` per printable keystroke, `reset` on Enter + * (`\r`/`\n`) and Ctrl-C (`\x03`). Only keystrokes are counted; control + * sequences (arrows/escape, CSI…) are ignored so they never falsely mark the + * line "non-empty" and block injection forever. + * 2. The **local FIFO** of `delegationReady` events received for this agent + * (via `system.onDomainEvent`, filtered like {@link useAgentBusy}). + * 3. The **handshake (b→e)**: only when a delegation is at the head of the FIFO + * AND the human line is empty: + * (b) suspend the keystroke relay + raise the overlay; + * (c) re-check the counter K; if K>0 write exactly K backspaces `\x7f` + * (never Ctrl-U) to erase what the human typed in the micro-window; + * (d) `write(text)` WITHOUT `\n`, then after `submitDelayMs ?? 60` ms + * `write(submitSequence ?? "\r")`; then ack via `delegationDelivered` + * exactly once; + * (e) drop the suspension + lower the overlay, with a **2 s floor** from + * (b) (anti-flash): if (e) would happen < 2000 ms after (b), the + * overlay/suspension are held until 2000 ms. + * A delegation that arrives while the line is non-empty simply **waits** in + * the FIFO (no write, no refusal, no loss) and is released at the next empty + * line. + * 4. The boolean **overlay** state, rendered by the hosting cell. + * + * Hexagonal: the hook talks to ports via DI (`system`, `input`), never to + * `invoke()`. It returns a {@link WritePortal} object whose identity is stable + * (memoised) so {@link TerminalView} can hold it in a ref. + */ + +import { useEffect, useMemo, useRef, useState } from "react"; + +import type { DomainEvent } from "@/domain"; +import type { TerminalHandle, WritePortal } from "@/ports"; +import { useGateways } from "@/app/di"; + +/** Default submit sequence when the profile omits one (paste-detection esquive). */ +const DEFAULT_SUBMIT_SEQUENCE = "\r"; +/** Default delay (ms) between text and submit-sequence writes. */ +const DEFAULT_SUBMIT_DELAY_MS = 60; +/** Anti-flash overlay floor (ms) from step (b). */ +const OVERLAY_FLOOR_MS = 2000; + +/** A pending delegation kept in the local FIFO. */ +interface PendingDelegation { + ticket: string; + text: string; + submitSequence?: string; + submitDelayMs?: number; +} + +/** What the hook returns: the portal object (for TerminalView) + overlay state. */ +export interface UseWritePortalResult { + /** The portal object handed to {@link TerminalView} via its `portal` prop. */ + portal: WritePortal; + /** Whether the "an agent is speaking…" overlay must be shown. */ + overlay: boolean; +} + +const encoder = new TextEncoder(); + +/** + * True for a single printable keystroke chunk. We count printable input only; + * control bytes (ESC/CSI: arrows, function keys, Ctrl-*) must not bump the + * counter, otherwise an arrow keypress would mark the line "non-empty" forever. + * + * A keystroke chunk from xterm's `onData` is normally a single grapheme, but a + * paste can deliver many. We treat the chunk as printable iff it contains **no** + * control character (code point < 0x20 or 0x7f). Enter (`\r`/`\n`) and Ctrl-C + * (`\x03`) are handled separately as resets before this check. + */ +function isPrintable(data: string): boolean { + if (data.length === 0) return false; + for (const ch of data) { + const code = ch.codePointAt(0)!; + if (code < 0x20 || code === 0x7f) return false; + } + return true; +} + +export function useWritePortal( + projectId: string, + agentId: string | null, +): UseWritePortalResult { + const { system, input } = useGateways(); + const [overlay, setOverlay] = useState(false); + + // ── Mutable state held in refs (the handshake runs outside React render) ─── + const counterRef = useRef(0); // human line counter K + const queueRef = useRef([]); // local FIFO + const handleRef = useRef(null); + const suspendedRef = useRef(false); // relay suspended while injecting + const injectingRef = useRef(false); // a handshake is in flight + + // Stable refs to the gateways used inside the (non-reactive) handshake. + const inputRef = useRef(input); + inputRef.current = input; + const agentIdRef = useRef(agentId); + agentIdRef.current = agentId; + + // The input port needs the project id for the delivery ack. + const projectIdRef = useRef(projectId); + projectIdRef.current = projectId; + + // ── (3)+(4): the handshake, attempted whenever a boundary may have opened ── + const tryInject = useRef<() => void>(() => {}); + tryInject.current = () => { + if (injectingRef.current) return; // one handshake at a time + const head = queueRef.current[0]; + if (!head) return; + if (counterRef.current > 0) return; // line not empty ⇒ wait + const handle = handleRef.current; + if (!handle) return; // no live PTY yet ⇒ wait + const agent = agentIdRef.current; + const project = projectIdRef.current; + if (!agent) return; + + injectingRef.current = true; + const startedAt = Date.now(); + + // (b) suspend the relay + raise the overlay. + suspendedRef.current = true; + setOverlay(true); + + void (async () => { + try { + // Yield once so any keystroke that landed in the micro-window between + // "empty observed" (a) and the suspension taking hold (b) is counted + // before we re-check K at step (c). Without this the race window would + // be zero and a late keystroke would survive the injection un-erased. + await Promise.resolve(); + // (c) re-check K; erase exactly K human keystrokes with `\x7f` + // (backspace) — never Ctrl-U (which could clear more than the human typed). + const k = counterRef.current; + if (k > 0) { + await handle.write(encoder.encode("\x7f".repeat(k))); + counterRef.current = 0; + } + + // (d) write the text WITHOUT a trailing newline, then the submit + // sequence after the profile's delay (esquive de la paste-detection). + await handle.write(encoder.encode(head.text)); + const delay = head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS; + await sleep(delay); + const submit = head.submitSequence ?? DEFAULT_SUBMIT_SEQUENCE; + await handle.write(encoder.encode(submit)); + + // Dequeue + ack exactly once (best-effort; never throws the handshake). + queueRef.current.shift(); + try { + await inputRef.current.delegationDelivered(project, agent, head.ticket); + } catch { + /* ack is observability-only; never block the portal */ + } + } finally { + // (e) lower the overlay + resume the relay, with the 2 s anti-flash floor. + const elapsed = Date.now() - startedAt; + const remaining = OVERLAY_FLOOR_MS - elapsed; + if (remaining > 0) await sleep(remaining); + suspendedRef.current = false; + setOverlay(false); + injectingRef.current = false; + // A boundary may now be open for the next queued delegation. + if (queueRef.current.length > 0) tryInject.current(); + } + })(); + }; + + // ── (2): subscribe to delegationReady for THIS agent and enqueue ─────────── + useEffect(() => { + if (!system || !agentId) return; + let unsubscribe: (() => void) | undefined; + let cancelled = false; + + void system + .onDomainEvent((event: DomainEvent) => { + if (event.type !== "delegationReady") return; + if (event.agentId !== agentId) return; + queueRef.current.push({ + ticket: event.ticket, + text: event.text, + submitSequence: event.submitSequence, + submitDelayMs: event.submitDelayMs, + }); + // A delegation may already be at a clean boundary (line empty) — try now. + tryInject.current(); + }) + .then((un) => { + if (cancelled) un(); + else unsubscribe = un; + }); + + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [system, agentId]); + + // ── (1)+(3): the portal object handed to TerminalView (stable identity) ──── + const portal = useMemo( + () => ({ + onHumanData(data: string) { + // Reset on Enter / Ctrl-C, increment on a printable keystroke, ignore + // control sequences. After a reset the line is empty ⇒ a queued + // delegation may now be injectable. + if (data === "\r" || data === "\n" || data === "\x03") { + counterRef.current = 0; + tryInject.current(); + return; + } + if (isPrintable(data)) { + counterRef.current += 1; + } + }, + isSuspended() { + return suspendedRef.current; + }, + bindHandle(handle: TerminalHandle) { + handleRef.current = handle; + // The PTY just became available — a queued delegation may be injectable. + tryInject.current(); + }, + unbindHandle() { + handleRef.current = null; + }, + }), + [], + ); + + return { portal, overlay }; +} + +/** Promise-based delay used by the handshake (driven by fake timers in tests). */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index b03c2ae..fa8999b 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -242,6 +242,33 @@ export interface TerminalHandle { close(): Promise; } +/** + * The write-portal contract an agent-cell terminal talks to (ARCHITECTURE §20). + * The portal owns the *human line* counter, the suspension flag (raised while it + * injects a delegation) and — once the view has a live PTY — the handle it + * writes through. The terminal view is the only effective PTY writer + * (single-writer invariant): it relays human keystrokes and the portal injects + * via the same handle, never a second physical writer. + */ +export interface WritePortal { + /** + * Reports a raw human keystroke chunk (`term.onData`) so the portal can keep + * its line counter. Called for **every** keystroke in agent mode, including + * while suspended (the portal decides what to count). + */ + onHumanData(data: string): void; + /** + * Whether the keystroke relay to the PTY is currently suspended (the portal + * is injecting a delegation). When `true`, the view drops keystrokes so they + * do not race the injected text. + */ + isSuspended(): boolean; + /** Binds the live PTY handle so the portal can inject through it. */ + bindHandle(handle: TerminalHandle): void; + /** Drops the handle reference (view torn down). */ + unbindHandle(): void; +} + /** * Terminals (L3): open a PTY with a per-session output stream, then write/ * resize/close it through the returned {@link TerminalHandle}. @@ -487,22 +514,32 @@ export interface MemoryGateway { } /** - * Mediated agent input (lot F1, cadrage §4.2). All human input to an *agent* - * cell flows through this port instead of being written straight to the PTY: - * "Envoyer" enqueues a task in the agent's single FIFO (`submit`), "Interrompre" - * preempts the current turn (`interrupt`). The component talks to this gateway, - * never to `invoke()` directly. + * Agent input control (ARCHITECTURE §20). The mediated-input strip is gone: the + * agent cell is a **native terminal** and human keystrokes (Enter included) go + * straight to the PTY. This port only carries the two out-of-band controls: * - * Note (forward/fallback, §4.2/§6): `submit` must never be blocked client-side - * when the agent is busy — the UI may *disable the button visually* but the - * enqueue path stays available; the backend FIFO is the single point that - * serialises. So callers may still call `submit` while busy. + * - **Interrompre** → `interrupt` (preempt the current turn — a control byte, + * not an enqueue). + * - **Ack** → `delegationDelivered`: the cell's write-portal confirms it has + * effectively written a delegation's text into the native PTY (closes the + * "the turn was delivered" loop for observability; the `ask` wake-up still + * rides on `idea_reply`). + * + * The component talks to this gateway via DI, never to `invoke()` directly. */ export interface InputGateway { - /** Envoyer = enqueue: appends `text` to the agent's input FIFO. */ - submit(projectId: string, agentId: string, text: string): Promise; /** Interrompre = preempt: signals the current turn to stop (not an enqueue). */ interrupt(projectId: string, agentId: string): Promise; + /** + * Ack: the cell's write-portal has written the delegation `ticket` into the + * agent's native PTY (ARCHITECTURE §20.3). Best-effort; never changes the + * ticket correlation. + */ + delegationDelivered( + projectId: string, + agentId: string, + ticket: string, + ): Promise; } /** From 0f8ba38d515d0f764e8441c737e89b7906c86fd7 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 14 Jun 2026 09:28:44 +0200 Subject: [PATCH 02/15] feat(agents): pont Codex inter-agents + readiness/heartbeat lot 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux chantiers livrés au vert (workspace entier : domain+application+ infrastructure 42 + app-tauri --lib 128, 0 échec). ## Codex inter-agents - domaine: McpConfigStrategy::TomlConfigHome { target, home_env } + toml_config_home(...); AgentProfile::materializes_idea_bridge() (whitelist Claude/ConfigFile + Codex/TomlConfigHome); McpServerWiring + encodeur TOML. - application: lifecycle apply_mcp_config bras TomlConfigHome (écrit {runDir}/, pousse (home_env, parent) dans spec.env); guard_mcp_bridge_supported ré-exprimée via materializes_idea_bridge(); catalogue Codex porte toml_config_home(".codex/config.toml","CODEX_HOME"). - app-tauri: is_codex_mcp_profile, migrate_codex_run_dir, mcp_server_entry_toml. - tests: matrice domaine TomlConfigHome + round-trip dual Claude/Codex sur loopback réel (fakes, zéro token). ## Readiness/heartbeat lot 1 - domaine: readiness.rs — ReadinessPolicy::classify (Final => TurnEnded), variantes ReplyEvent::Heartbeat / ToolActivity. - application: drain_with_readiness consulte la policy et appelle mark_idle sur le signal déterministe; branché dans ask_agent. Corrige la cause racine: une cible qui ne renvoie qu'un Final (sans idea_reply) débloque désormais sa file Busy. - infrastructure: adapters de session émettent Heartbeat/ToolActivity. - tests: drain_with_readiness_lot1 (points QA 5 & 6) verts. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/chat.rs | 14 +- crates/app-tauri/src/commands.rs | 6 +- crates/app-tauri/src/events.rs | 75 +++ crates/app-tauri/src/state.rs | 327 ++++++++++++- crates/app-tauri/tests/chat_bridge.rs | 22 +- crates/application/src/agent/catalogue.rs | 49 +- crates/application/src/agent/lifecycle.rs | 124 +++-- crates/application/src/agent/mod.rs | 2 +- crates/application/src/agent/structured.rs | 184 ++++++- crates/application/src/lib.rs | 4 +- .../application/src/orchestrator/service.rs | 354 +++++++++++++- .../tests/drain_with_readiness_lot1.rs | 338 +++++++++++++ .../application/tests/orchestrator_service.rs | 254 ++++++++++ crates/domain/src/events.rs | 13 + crates/domain/src/input.rs | 46 ++ crates/domain/src/lib.rs | 8 +- crates/domain/src/ports.rs | 17 +- crates/domain/src/profile.rs | 460 ++++++++++++++++++ crates/domain/src/readiness.rs | 111 +++++ crates/infrastructure/src/input/mod.rs | 369 +++++++++++++- crates/infrastructure/src/session/claude.rs | 28 +- crates/infrastructure/src/session/codex.rs | 25 +- .../infrastructure/src/session/conformance.rs | 9 +- crates/infrastructure/src/session/mod.rs | 62 +-- 24 files changed, 2745 insertions(+), 156 deletions(-) create mode 100644 crates/application/tests/drain_with_readiness_lot1.rs create mode 100644 crates/domain/src/readiness.rs 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 df31957..609e9e3 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -1196,7 +1196,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 diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index b937b6f..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. @@ -84,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 { @@ -215,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 { @@ -265,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, @@ -376,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/state.rs b/crates/app-tauri/src/state.rs index 1e0f64e..6b38e5d 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -827,7 +827,7 @@ impl AppState { // 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 mediated_inbox = Arc::new( MediatedInbox::with_pty( Arc::clone(&inmemory_mailbox), Arc::new(SystemMillisClock), @@ -836,7 +836,28 @@ 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()) @@ -870,7 +891,12 @@ impl AppState { .with_record_turn( Arc::new(AppRecordTurnProvider) as Arc, Arc::clone(&clock) as Arc, - ), + ) + // Registre des sessions IA structurées (§17.5) : permet à `ask_agent` de + // router une cible structurée (sans PTY) sur sa session et de la drainer via + // `drain_with_readiness` — le `Final` réveille le round-trip ET marque l'agent + // `Idle` (readiness/heartbeat lot 1). + .with_structured(Arc::clone(&structured_sessions)), ); // --- Windows (L10) --- @@ -1119,10 +1145,13 @@ impl AppState { let Some(profile) = profile_by_id.get(&agent.profile_id) else { continue; }; - if !is_claude_mcp_profile(profile) { - 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; } - let _ = migrate_claude_run_dir(project, &agent.id, profile).await; } } } @@ -1229,6 +1258,115 @@ fn claude_mcp_config_target(profile: &AgentProfile) -> Option<&str> { } } +/// 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), @@ -3284,6 +3422,88 @@ mod mcp_e2e_loopback_tests { (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, + 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) + } + /// 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) { sessions.insert( @@ -3519,6 +3739,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. diff --git a/crates/app-tauri/tests/chat_bridge.rs b/crates/app-tauri/tests/chat_bridge.rs index 9d4fb5f..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); } diff --git a/crates/application/src/agent/catalogue.rs b/crates/application/src/agent/catalogue.rs index fa28e41..25df1c8 100644 --- a/crates/application/src/agent/catalogue.rs +++ b/crates/application/src/agent/catalogue.rs @@ -80,8 +80,11 @@ pub fn reference_profiles() -> Vec { .expect("codex reference profile is valid") .with_structured_adapter(StructuredAdapter::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( @@ -157,18 +160,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] diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 6b0a84b..2192ff6 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -1746,6 +1746,36 @@ impl LaunchAgent { }, } } + 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 _ = self.fs.write(&path, declaration.as_bytes()).await; + } + None => match self.fs.exists(&path).await { + Ok(true) => {} + Ok(false) => { + let _ = self.fs.write(&path, declaration.as_bytes()).await; + } + Err(_) => {} + }, + } + // `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 @@ -1858,58 +1888,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 @@ -1919,6 +1931,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 diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 1ddb711..deabf16 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -16,7 +16,7 @@ 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}; 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/lib.rs b/crates/application/src/lib.rs index c4d405d..86525fd 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -28,8 +28,8 @@ pub mod terminal; pub mod window; pub use agent::{ - reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking, - ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, + 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, diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 3e23ce5..6c32450 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -32,8 +32,9 @@ use domain::{ 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,20 @@ 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))) +} /// 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 +202,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 +273,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). @@ -750,6 +789,42 @@ 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 @@ -786,6 +861,10 @@ 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); // Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the // turn into the bound handle). The service no longer writes the PTY directly — @@ -793,7 +872,7 @@ impl OrchestratorService { // 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 { + 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 @@ -821,6 +900,120 @@ impl OrchestratorService { } } + /// 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); + + // 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, + Err(err) => { + mailbox.cancel_head(agent_id, ticket_id); + 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 + } + Err(_cancelled) => { + mailbox.cancel_head(agent_id, ticket_id); + return Err(AppError::Process(format!( + "agent {target} : canal de réponse fermé avant un résultat" + ))); + } + }, + }; + + // 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` @@ -1083,6 +1276,74 @@ impl OrchestratorService { }) } + /// 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 /// `session_for`) and the [`ConversationRegistry`] (domain thread state), when the /// latter is wired. Idempotent. @@ -1315,8 +1576,6 @@ impl OrchestratorService { profile_id: &ProfileId, target: &str, ) -> Result<(), AppError> { - use domain::profile::{McpConfigStrategy, StructuredAdapter}; - let Some(profile) = self .profiles .list() @@ -1327,21 +1586,20 @@ 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 ))) } @@ -1383,6 +1641,54 @@ impl OrchestratorService { (profile.prompt_ready_pattern, submit) } + /// 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`, /// command like `claude`, or display name like `Claude Code`) to a configured /// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by @@ -1459,6 +1765,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)), 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/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index c823d9e..f2b9ff6 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -2825,3 +2825,257 @@ async fn p6b_failing_record_does_not_degrade_ask() { "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, + ) -> 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/domain/src/events.rs b/crates/domain/src/events.rs index 1f9b053..215634e 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -71,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. diff --git a/crates/domain/src/input.rs b/crates/domain/src/input.rs index 21b8897..b0bc982 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] @@ -195,6 +218,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..1c53cdf 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -47,6 +47,7 @@ pub mod orchestrator; pub mod ports; pub mod profile; pub mod project; +pub mod readiness; pub mod remote; pub mod skill; pub mod template; @@ -74,7 +75,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 +86,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, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index af49b1c..0f31670 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -209,6 +209,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 +231,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>; // --------------------------------------------------------------------------- diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index e886930..0c53d09 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -118,6 +118,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 +248,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 +298,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 +342,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 +527,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 +543,15 @@ 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 @@ -483,6 +710,7 @@ impl AgentProfile { structured_adapter: None, mcp: None, prompt_ready_pattern: None, + liveness: None, submit_sequence: None, submit_delay_ms: None, }) @@ -515,6 +743,15 @@ 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. @@ -546,6 +783,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)] @@ -832,4 +1096,200 @@ mod mcp_tests { 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()); + } + + #[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/infrastructure/src/input/mod.rs b/crates/infrastructure/src/input/mod.rs index 205eb0c..e16bc2c 100644 --- a/crates/infrastructure/src/input/mod.rs +++ b/crates/infrastructure/src/input/mod.rs @@ -25,7 +25,7 @@ use std::sync::{Arc, Mutex}; use domain::events::DomainEvent; use domain::ids::AgentId; -use domain::input::{AgentBusyState, InputMediator, SubmitConfig}; +use domain::input::{AgentBusyState, AgentLiveness, InputMediator, SubmitConfig}; use domain::mailbox::{AgentMailbox, PendingReply, Ticket, TicketId}; use domain::ports::{EventBus, PtyHandle, PtyPort}; @@ -40,13 +40,34 @@ 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>, events: Option>, } +/// É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()), events, } } @@ -57,6 +78,100 @@ impl BusyTracker { .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) @@ -94,6 +209,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); } } @@ -143,6 +261,10 @@ pub struct MediatedInbox { /// 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. @@ -161,6 +283,7 @@ impl MediatedInbox { pty: None, handles: Mutex::new(HashMap::new()), submit: Mutex::new(HashMap::new()), + stall: Mutex::new(HashMap::new()), watched: Arc::new(Mutex::new(HashSet::new())), } } @@ -190,6 +313,7 @@ impl MediatedInbox { pty: Some(pty), handles: Mutex::new(HashMap::new()), submit: Mutex::new(HashMap::new()), + stall: Mutex::new(HashMap::new()), watched: Arc::new(Mutex::new(HashSet::new())), } } @@ -215,6 +339,22 @@ impl MediatedInbox { .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 { @@ -305,13 +445,22 @@ impl InputMediator for MediatedInbox { // 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, }, ); + // 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 @@ -391,10 +540,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) } @@ -985,4 +1147,205 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(60)); assert!(inbox.busy_state(a).is_busy(), "no match ⇒ stays Busy"); } + + // ==================================================================== + // Lot 2 — détection de stagnation (last_seen / sweep_stalled / liveness) + // ==================================================================== + + use std::sync::atomic::{AtomicU64, Ordering}; + use domain::input::AgentLiveness; + + /// 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/session/claude.rs b/crates/infrastructure/src/session/claude.rs index 6ffeb15..3be6da8 100644 --- a/crates/infrastructure/src/session/claude.rs +++ b/crates/infrastructure/src/session/claude.rs @@ -47,10 +47,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 +81,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 +97,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 }) @@ -216,13 +221,22 @@ impl AgentSession for ClaudeSdkSession { 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..94042a8 100644 --- a/crates/infrastructure/src/session/codex.rs +++ b/crates/infrastructure/src/session/codex.rs @@ -43,13 +43,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 +75,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 +98,7 @@ pub fn parse_event(line: &str) -> Result { } } } - // turn.started / turn.completed / type inconnu : ignoré (robustesse). + // type inconnu : ignoré (robustesse). _ => {} } @@ -192,12 +196,21 @@ impl AgentSession for CodexExecSession { 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..79bc53c 100644 --- a/crates/infrastructure/src/session/conformance.rs +++ b/crates/infrastructure/src/session/conformance.rs @@ -208,14 +208,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/mod.rs b/crates/infrastructure/src/session/mod.rs index d064b40..89e3951 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -106,23 +106,24 @@ mod tests { // -- 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")); } @@ -221,10 +222,15 @@ mod tests { 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"}}"#, @@ -236,11 +242,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] @@ -728,30 +729,26 @@ 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"}"#) + // Un `type` inconnu reste ignoré (robustesse), pas un heartbeat. + assert!(codex::parse_event(r#"{"type":"telemetry"}"#) .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":{}}"#) - .unwrap() - .events - .is_empty() - ); } // ---- 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(&[ @@ -762,12 +759,19 @@ mod tests { ]); let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", 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 @@ -1097,6 +1101,8 @@ mod tests { 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() }, @@ -1104,7 +1110,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!( From aa2f67ae89c7880aeb54339a6cd5790b9e085ad0 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 14 Jun 2026 09:45:39 +0200 Subject: [PATCH 03/15] =?UTF-8?q?fix(orchestrator):=20ne=20pas=20c=C3=A2bl?= =?UTF-8?q?er=20with=5Fstructured=20sur=20l'orchestrateur=20(r=C3=A9gressi?= =?UTF-8?q?on=200f8ba38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Régression introduite par 0f8ba38 : le re-câblage de `.with_structured(...)` sur `OrchestratorService` (pour activer `drain_with_readiness`, readiness lot 1) faisait emprunter à `ask_agent` la branche structurée `ensure_structured_session`, qui ne peut JAMAIS aboutir : depuis le lot B-2 (« Option 1 Terminal + MCP », eca2ba9) la fabrique structurée est décâblée de `LaunchAgent`, donc aucun agent n'a de session structurée — tous tournent en PTY brut et la délégation passe par les outils MCP. Symptôme : `idea_ask_agent` échouait systématiquement avec « aucune session structurée vivante après lancement » à chaque relance d'IdeA. Non détecté par les tests : les e2e loopback câblent leur propre service structuré complet, jamais le composition root réel (gap tests ≠ composition). Fix : retirer le `.with_structured` du composition root pour que `self.structured = None` et que `ask_agent` retombe sur le chemin PTY+MCP fonctionnel (état pré-0f8ba38). `drain_with_readiness` reste dormant tant que la voie structurée n'est pas réactivée au composition root. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/state.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 6b38e5d..61a6f3d 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -891,12 +891,19 @@ impl AppState { .with_record_turn( Arc::new(AppRecordTurnProvider) as Arc, Arc::clone(&clock) as Arc, - ) - // Registre des sessions IA structurées (§17.5) : permet à `ask_agent` de - // router une cible structurée (sans PTY) sur sa session et de la drainer via - // `drain_with_readiness` — le `Final` réveille le round-trip ET marque l'agent - // `Idle` (readiness/heartbeat lot 1). - .with_structured(Arc::clone(&structured_sessions)), + ), + // 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) --- From 46492506e149a529a739cb01dcc56bb7ffdc1cd7 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 15 Jun 2026 19:15:01 +0200 Subject: [PATCH 04/15] =?UTF-8?q?fix(orchestrator):=20garde=20RAII=20contr?= =?UTF-8?q?e=20une=20cible=20coinc=C3=A9e=20Busy=20apr=C3=A8s=20une=20d?= =?UTF-8?q?=C3=A9l=C3=A9gation=20interrompue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une délégation `idea_ask_agent` interrompue/annulée côté demandeur (futur `ask_agent` *dropped*) laissait la cible en `Busy` à vie : sur un drop, aucune branche du `select!` ne s'exécute, donc `mark_idle` n'était jamais appelé. Toute délégation suivante restait en file derrière ce tour fantôme et n'était jamais livrée au PTY (symptôme : « DevFrontend/QA ne répondent plus », pont MCP pourtant ESTAB). Ajoute un garde RAII `BusyTurnGuard` posé juste après l'enqueue sur les deux chemins (`ask` PTY et `ask_structured`) : son `Drop` fait `cancel_head` + `mark_idle` quoi qu'il arrive (erreur, timeout, drop), sauf `disarm()` sur la branche succès. Indispensable d'être un garde et non un `mark_idle` dans les branches : le cas réel est un futur droppé. Retire les `cancel_head` redondants des branches erreur/timeout (positionnel/idempotent). `sweep_stalled` reste advisory (n'appelle jamais `mark_idle`) — non concerné. Tests: dropped_ask_future_frees_busy_target, second_delegation_delivered_after_dropped_ask, cancelled_ask_marks_target_idle + 2 tests du garde. cargo test --workspace vert (80 suites). Co-Authored-By: Claude Opus 4.8 --- .../application/src/orchestrator/service.rs | 336 +++++++++++++++--- .../application/tests/orchestrator_service.rs | 118 ++++++ 2 files changed, 408 insertions(+), 46 deletions(-) diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 6c32450..c6f7fbc 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -94,6 +94,68 @@ 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 /// app-tauri (seul détenteur de current_exe/$APPIMAGE/mcp_endpoint). @@ -827,12 +889,23 @@ impl OrchestratorService { // 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, submit) = self.prompt_and_submit_for_agent(project, agent_id).await; + 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 @@ -866,12 +939,22 @@ impl OrchestratorService { // 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. + // 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 @@ -885,18 +968,19 @@ 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)), } } @@ -925,11 +1009,13 @@ impl OrchestratorService { ) -> 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 : \ + _ => { + 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(), - )), + .to_owned(), + )) + } }; // Checkpoint Prompt (best-effort), AVANT de déplacer `task` dans le ticket. @@ -963,27 +1049,30 @@ impl OrchestratorService { // 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 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, - Err(err) => { - mailbox.cancel_head(agent_id, ticket_id); - return Err(AppError::from(err)); - } + // 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 { @@ -993,8 +1082,8 @@ impl OrchestratorService { input.mark_idle(agent_id); content } + // Canal fermé : le garde fait `cancel_head` + `mark_idle` au Drop. Err(_cancelled) => { - mailbox.cancel_head(agent_id, ticket_id); return Err(AppError::Process(format!( "agent {target} : canal de réponse fermé avant un résultat" ))); @@ -1002,6 +1091,10 @@ impl OrchestratorService { }, }; + // 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, @@ -1058,10 +1151,21 @@ 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, submit) = self.prompt_and_submit_for_agent(project, agent_id).await; + 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- @@ -1145,6 +1249,26 @@ impl OrchestratorService { ); } + /// 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). @@ -1219,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). @@ -1235,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)); } } @@ -1269,11 +1400,13 @@ impl OrchestratorService { // 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(|| { + 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`. @@ -1337,11 +1470,14 @@ impl OrchestratorService { .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" - )) - }) + 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 @@ -1616,7 +1752,7 @@ impl OrchestratorService { &self, project: &Project, agent_id: AgentId, - ) -> (Option, SubmitConfig) { + ) -> (Option, SubmitConfig, bool) { let Some(agent) = self .list_agents .execute(ListAgentsInput { @@ -1626,7 +1762,7 @@ impl OrchestratorService { .ok() .and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id)) else { - return (None, SubmitConfig::default()); + return (None, SubmitConfig::default(), false); }; let Some(profile) = self .profiles @@ -1635,10 +1771,13 @@ impl OrchestratorService { .ok() .and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id)) else { - return (None, SubmitConfig::default()); + return (None, SubmitConfig::default(), false); }; let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms); - (profile.prompt_ready_pattern, submit) + // 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) : @@ -1809,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/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index f2b9ff6..fccb5cc 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -1255,6 +1255,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() { From 27597eb64e8e8c5e3dd182f0e1da8c28767b5db5 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 15 Jun 2026 20:39:18 +0200 Subject: [PATCH 05/15] =?UTF-8?q?feat(permissions):=20voie=20projection=20?= =?UTF-8?q?CLI=20(LP0=E2=86=92LP3)=20+=20checkpoint=20Codex/input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jalon vert regroupant deux chantiers entrelacés dans le working tree, indissociables au niveau fichier mais tous deux verts (cargo test --workspace + tests frontend permissions au vert). Permissions — voie « projection CLI » (advisory), complète : - LP0 domaine pur : modèle PermissionSet/EffectivePermissions, resolve deny-wins + postures Allow --- ...mcp-bridge-and-delegation-runtime-notes.md | 29 +- crates/app-tauri/src/commands.rs | 138 +- crates/app-tauri/src/dto.rs | 63 +- crates/app-tauri/src/lib.rs | 5 + crates/app-tauri/src/state.rs | 110 +- crates/application/src/agent/catalogue.rs | 29 + crates/application/src/agent/lifecycle.rs | 755 ++++++++-- crates/application/src/agent/mod.rs | 6 +- crates/application/src/lib.rs | 24 +- crates/application/src/permission.rs | 150 ++ crates/application/tests/agent_lifecycle.rs | 632 +++++++- .../application/tests/change_agent_profile.rs | 534 ++++++- .../application/tests/permission_usecases.rs | 128 ++ crates/domain/src/input.rs | 39 + crates/domain/src/lib.rs | 15 +- crates/domain/src/permission.rs | 1315 +++++++++++++++++ crates/domain/src/ports.rs | 37 + crates/domain/src/profile.rs | 74 + crates/infrastructure/src/fs/mod.rs | 9 + crates/infrastructure/src/input/mod.rs | 612 +++++++- crates/infrastructure/src/lib.rs | 10 +- .../src/orchestrator/mcp/server.rs | 166 ++- .../infrastructure/src/permission/claude.rs | 362 +++++ crates/infrastructure/src/permission/codex.rs | 190 +++ crates/infrastructure/src/permission/mod.rs | 46 + crates/infrastructure/src/store/mod.rs | 2 + crates/infrastructure/src/store/permission.rs | 65 + crates/infrastructure/tests/mcp_server.rs | 203 ++- .../infrastructure/tests/permission_store.rs | 86 ++ frontend/src/adapters/index.ts | 3 + frontend/src/adapters/input.ts | 6 + frontend/src/adapters/mock/index.ts | 73 + frontend/src/adapters/permission.ts | 43 + frontend/src/domain/index.ts | 61 + .../features/permissions/PermissionsPanel.tsx | 381 +++++ frontend/src/features/permissions/index.ts | 1 + .../features/permissions/permissions.test.tsx | 89 ++ .../features/permissions/usePermissions.ts | 236 +++ .../src/features/projects/ProjectsView.tsx | 11 + .../src/features/terminals/useWritePortal.ts | 9 + frontend/src/ports/index.ts | 35 + 41 files changed, 6513 insertions(+), 269 deletions(-) create mode 100644 crates/application/src/permission.rs create mode 100644 crates/application/tests/permission_usecases.rs create mode 100644 crates/domain/src/permission.rs create mode 100644 crates/infrastructure/src/permission/claude.rs create mode 100644 crates/infrastructure/src/permission/codex.rs create mode 100644 crates/infrastructure/src/permission/mod.rs create mode 100644 crates/infrastructure/src/store/permission.rs create mode 100644 crates/infrastructure/tests/permission_store.rs create mode 100644 frontend/src/adapters/permission.ts create mode 100644 frontend/src/features/permissions/PermissionsPanel.tsx create mode 100644 frontend/src/features/permissions/index.ts create mode 100644 frontend/src/features/permissions/permissions.test.tsx create mode 100644 frontend/src/features/permissions/usePermissions.ts diff --git a/.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md b/.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md index d8811e4..c0dc322 100644 --- a/.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md +++ b/.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md @@ -10,7 +10,7 @@ Deux bugs trouves le 2026-06-13 en testant la conversation inter-agents (`idea_a ## 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 `frontend/node_modules/.bin/tauri build --bundles appimage`), remplacer l'AppImage, relancer IdeA, puis retester. +**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. @@ -21,4 +21,31 @@ La session shell herite des variables de l'AppImage montee (`APPDIR`, `LD_LIBRAR ## 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/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 609e9e3..38c8b90 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -18,9 +18,10 @@ use application::{ ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, RenameLayoutInput, - ResolveMemoryLinksInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, - SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, - UpdateMemoryInput, UpdateProjectContextInput, UpdateSkillInput, + ResolveAgentPermissionsInput, ResolveMemoryLinksInput, SetActiveLayoutInput, + SnapshotRunningAgentsInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, + UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput, + UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput, }; use domain::ports::PtyHandle; @@ -34,19 +35,22 @@ use crate::dto::{ CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, - EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, - GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, - GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, - InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, - LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, - MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, - ProjectListDto, ReadAgentContextResponseDto, ReattachChatDto, ReattachResultDto, + EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, + ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, + GitCommitDto, + GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, + GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, + InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, + LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, + OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, + ProjectPermissionsDto, ReadAgentContextResponseDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, - ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveProfileRequestDto, - SetActiveLayoutRequestDto, SkillDto, SkillListDto, SyncAgentWithTemplateRequestDto, - SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, - UnassignSkillRequestDto, UpdateAgentContextRequestDto, UpdateMemoryRequestDto, - UpdateProjectContextRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, + ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, + SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, SkillListDto, + SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, + TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, + UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, }; use crate::pty::{PtyBridge, PtyChunk}; @@ -208,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) // --------------------------------------------------------------------------- @@ -1263,6 +1348,29 @@ pub async fn delegation_delivered( 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 7ac7c70..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")] @@ -1224,6 +1270,21 @@ pub struct DeliveredDelegationRequestDto { 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)] diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 7baedb0..59fd150 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -127,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, @@ -163,6 +167,7 @@ pub fn run() { commands::agent_send, 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/state.rs b/crates/app-tauri/src/state.rs index 61a6f3d..b9e0694 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -17,23 +17,25 @@ use application::{ 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, - ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects, - ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LoadLayout, McpRuntime, - MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext, RecallMemory, - ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, - ResizeTerminal, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile, SetActiveLayout, - SnapshotRunningAgents, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, - TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, UpdateMemory, - UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + 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, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, + SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, + 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::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -44,10 +46,11 @@ 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, FsOrchestratorWatcher, FsProfileStore, FsProjectStore, - FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository, + FsHandoffStore, FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, + FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository, HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory, SystemClock, @@ -230,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, @@ -498,6 +509,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 @@ -632,6 +647,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), @@ -646,6 +675,7 @@ 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 : @@ -658,7 +688,11 @@ impl AppState { // `/.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), + 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/ @@ -676,7 +710,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 @@ -707,6 +744,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( @@ -856,8 +905,7 @@ impl AppState { } }); } - let input_mediator = - Arc::clone(&mediated_inbox) as Arc; + 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()) @@ -957,6 +1005,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, @@ -1069,9 +1121,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, @@ -1355,7 +1418,10 @@ fn codex_mcp_config_target(profile: &AgentProfile) -> Option<(&str, &str)> { /// 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 transport = profile + .mcp + .as_ref() + .map_or(McpTransport::Stdio, |m| m.transport); let (command, args) = match runtime { Some(rt) => ( rt.exe.clone(), diff --git a/crates/application/src/agent/catalogue.rs b/crates/application/src/agent/catalogue.rs index 25df1c8..f01d6e3 100644 --- a/crates/application/src/agent/catalogue.rs +++ b/crates/application/src/agent/catalogue.rs @@ -18,6 +18,7 @@ //! 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, @@ -61,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"), @@ -79,6 +81,7 @@ pub fn reference_profiles() -> Vec { ) .expect("codex reference profile is valid") .with_structured_adapter(StructuredAdapter::Codex) + .with_projector(ProjectorKey::Codex) .with_mcp(McpCapability::new( // 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 @@ -201,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 2192ff6..bbb581f 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -11,17 +11,21 @@ //! [`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, }; @@ -367,6 +371,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 +404,7 @@ impl ChangeAgentProfile { launch, events, structured: None, + projectors: None, } } @@ -406,6 +418,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 +451,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 +468,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. @@ -478,6 +509,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]). @@ -555,6 +600,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. @@ -832,6 +937,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. /// @@ -859,6 +1039,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 / @@ -882,6 +1066,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 { @@ -913,13 +1101,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` @@ -1205,16 +1416,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. @@ -1277,8 +1486,29 @@ 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?; // 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ── // Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le @@ -1575,53 +1805,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 @@ -1709,6 +2000,7 @@ impl LaunchAgent { &self, profile: &AgentProfile, run_dir: &ProjectPath, + project_root: &ProjectPath, runtime: Option<&McpRuntime>, spec: &mut SpawnSpec, ) { @@ -1761,16 +2053,37 @@ impl LaunchAgent { // écraser une déclaration réelle par la minimale. match runtime { Some(_) => { - let _ = self.fs.write(&path, declaration.as_bytes()).await; + 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 _ = self.fs.write(&path, declaration.as_bytes()).await; + 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); @@ -1986,57 +2299,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, - "enabledMcpjsonServers": ["idea"], - "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() { @@ -2053,6 +2318,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 @@ -2658,39 +3097,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"); - // IdeA MCP server pre-approved so idea_* tools load without a prompt. - assert_eq!(parsed["enabledMcpjsonServers"][0], "idea"); - 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 deabf16..e93f6e9 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -24,9 +24,9 @@ pub use lifecycle::{ ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, - ListAgentsOutput, McpRuntime, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, - ReadAgentContextOutput, StructuredSessionDescriptor, UpdateAgentContext, - UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, + ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider, + ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor, + UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, }; pub use resume::{ ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 86525fd..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; @@ -29,14 +30,15 @@ pub mod window; pub use agent::{ 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, ProfileAvailability, + 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, @@ -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/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/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 9d1b89e..fe0033a 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, }; // --------------------------------------------------------------------------- @@ -352,6 +358,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 +373,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 +437,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 +867,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 +900,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(); @@ -2507,3 +2539,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 1877ec9..315b178 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -30,23 +30,30 @@ 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}; +use application::{ + ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, PermissionProjectorRegistry, + TerminalSessions, +}; // --------------------------------------------------------------------------- // FakeContexts (AgentContextStore) — manifest + md_path → content @@ -239,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)] @@ -260,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] @@ -287,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()) } @@ -366,6 +392,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] @@ -766,6 +802,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 // --------------------------------------------------------------------------- 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/domain/src/input.rs b/crates/domain/src/input.rs index b0bc982..be82041 100644 --- a/crates/domain/src/input.rs +++ b/crates/domain/src/input.rs @@ -211,6 +211,45 @@ pub trait InputMediator: Send + Sync { 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); diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 1c53cdf..9c38a96 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -44,6 +44,7 @@ pub mod mailbox; pub mod markdown; pub mod memory; pub mod orchestrator; +pub mod permission; pub mod ports; pub mod profile; pub mod project; @@ -117,6 +118,13 @@ pub use layout::{ pub use events::{DomainEvent, OrchestrationSource}; +pub use permission::{ + 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 orchestrator::{ OrchestratorCommand, OrchestratorError, OrchestratorRequest, OrchestratorVisibility, }; @@ -126,7 +134,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/permission.rs b/crates/domain/src/permission.rs new file mode 100644 index 0000000..b65fd77 --- /dev/null +++ b/crates/domain/src/permission.rs @@ -0,0 +1,1315 @@ +//! 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 }) +} + +// --------------------------------------------------------------------------- +// 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); + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 0f31670..627e236 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; @@ -617,6 +618,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 @@ -1063,6 +1079,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 0c53d09..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. /// @@ -574,6 +575,20 @@ pub struct AgentProfile { /// `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). @@ -713,6 +728,7 @@ impl AgentProfile { liveness: None, submit_sequence: None, submit_delay_ms: None, + projector: None, }) } @@ -769,6 +785,15 @@ impl AgentProfile { 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 @@ -1257,6 +1282,55 @@ mod mcp_tests { 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. 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 e16bc2c..5198ebc 100644 --- a/crates/infrastructure/src/input/mod.rs +++ b/crates/infrastructure/src/input/mod.rs @@ -44,9 +44,57 @@ struct BusyTracker { /// 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 @@ -68,10 +116,36 @@ impl BusyTracker { 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() @@ -192,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. @@ -256,7 +386,15 @@ 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). @@ -276,24 +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 } @@ -306,12 +523,16 @@ 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())), @@ -409,8 +630,10 @@ impl MediatedInbox { 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); + // 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 { @@ -476,21 +699,32 @@ impl InputMediator for MediatedInbox { busy: true, }); let submit = self.submit().get(&agent).cloned().unwrap_or_default(); - events.publish(DomainEvent::DelegationReady { - agent_id: agent, + // 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, - // 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 …]`). - text: delegation_preamble(&ticket.requester, ticket_id, &ticket.task), + 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) @@ -523,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 @@ -761,6 +1019,9 @@ mod tests { 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(); @@ -786,24 +1047,71 @@ mod tests { } #[test] - fn enqueue_does_not_write_into_the_pty() { - // The whole point of §20: the backend stops writing the turn into the PTY. + 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); - let h = handle(1); - inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default()); + inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default()); + inbox.set_front_attached(a, true); inbox.enqueue(a, ticket(10, "do X")); - inbox.enqueue(a, ticket(11, "do Y")); // even a second enqueue must not write + 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(), - "enqueue must perform NO pty.write (the `\\n` band-aid is gone)" + "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" ); } @@ -1148,12 +1456,252 @@ mod tests { 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 std::sync::atomic::{AtomicU64, Ordering}; 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. @@ -1211,7 +1759,11 @@ mod tests { // 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"); + 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); diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index c7671e8..1ed8376 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -24,6 +24,7 @@ pub mod input; pub mod inspector; pub mod mailbox; pub mod orchestrator; +pub mod permission; pub mod process; pub mod pty; pub mod remote; @@ -49,6 +50,7 @@ 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}; @@ -61,8 +63,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/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index a4d9fe5..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 { @@ -228,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. 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/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/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 4badeda..762d8d1 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -52,7 +52,7 @@ use application::{ CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents, OrchestratorService, TerminalSessions, UpdateAgentContext, }; -use infrastructure::orchestrator::mcp::jsonrpc::error_codes; +use infrastructure::orchestrator::mcp::jsonrpc::{error_codes, Transport, TransportError}; use infrastructure::{ InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport, SystemMillisClock, @@ -859,6 +859,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. @@ -1049,3 +1088,165 @@ async fn server_without_event_sink_emits_nothing_and_does_not_panic() { // 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/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/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 180c7b8..b25406b 100644 --- a/frontend/src/adapters/input.ts +++ b/frontend/src/adapters/input.ts @@ -32,4 +32,10 @@ export class TauriInputGateway implements InputGateway { 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 6cb6c0c..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, @@ -1553,6 +1557,8 @@ export class MockInputGateway implements InputGateway { readonly interrupts: MockInputCall[] = []; /** 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 }); @@ -1565,6 +1571,72 @@ export class MockInputGateway implements InputGateway { ): 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. */ @@ -1584,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 5a4ab73..3b9078a 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -84,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/permissions/PermissionsPanel.tsx b/frontend/src/features/permissions/PermissionsPanel.tsx new file mode 100644 index 0000000..ae1b6fa --- /dev/null +++ b/frontend/src/features/permissions/PermissionsPanel.tsx @@ -0,0 +1,381 @@ +import { useEffect, useMemo, useState } from "react"; + +import { Button, Panel, Spinner, cn } from "@/shared"; +import type { PermissionSet, PermissionPosture } from "@/domain"; +import { + type CapabilityChoice, + type PolicyDraft, + draftFromSet, + isCustomPolicy, + usePermissions, +} from "./usePermissions"; + +export interface PermissionsPanelProps { + projectId: string; +} + +type EditorTarget = + | { type: "project" } + | { type: "agent"; agentId: string; agentName: string }; + +const CAPABILITY_ROWS: { + key: keyof Omit; + label: string; +}[] = [ + { key: "read", label: "Read" }, + { key: "write", label: "Write" }, + { key: "delete", label: "Delete" }, + { key: "executeBash", label: "Bash" }, +]; + +const POSTURE_LABELS: Record = { + ask: "Ask", + allow: "Allow", + deny: "Deny", +}; + +export function PermissionsPanel({ projectId }: PermissionsPanelProps) { + const vm = usePermissions(projectId); + const [target, setTarget] = useState({ type: "project" }); + + const selectedAgent = target.type === "agent" + ? vm.rows.find((row) => row.agent.id === target.agentId) ?? null + : null; + const activePolicy = selectedAgent?.override ?? vm.document?.projectDefaults ?? null; + const activeDraft = target.type === "project" + ? vm.projectDraft + : draftFromSet(selectedAgent?.override ?? vm.document?.projectDefaults ?? null); + const activeCustom = target.type === "project" + ? isCustomPolicy(vm.document?.projectDefaults) + : isCustomPolicy(selectedAgent?.override); + const hasProjectDefaults = vm.document?.projectDefaults != null; + + async function handleSave(draft: PolicyDraft) { + if (target.type === "project") { + await vm.saveProjectDefaults(draft); + } else { + await vm.saveAgentOverride(target.agentId, draft); + } + } + + async function handleClear() { + if (target.type === "project") { + await vm.clearProjectDefaults(); + } else { + await vm.clearAgentOverride(target.agentId); + } + } + + return ( + void vm.refresh()} + > + Refresh + + } + className="flex flex-col" + flush + > + {vm.error && ( +

+ {vm.error} +

+ )} + +
+ setTarget({ type: "project" })} + policy={vm.document?.projectDefaults ?? null} + /> + +
+
+

+ Agents +

+ {vm.busy && } +
+ {vm.rows.length === 0 ? ( +

No agents yet.

+ ) : ( +
    + {vm.rows.map((row) => { + const active = + target.type === "agent" && target.agentId === row.agent.id; + return ( +
  • + + setTarget({ + type: "agent", + agentId: row.agent.id, + agentName: row.agent.name, + }) + } + policy={row.override} + /> +
  • + ); + })} +
+ )} +
+ + void handleSave(draft)} + onClear={() => void handleClear()} + /> +
+
+ ); +} + +interface PolicyCardProps { + title: string; + subtitle: string; + active: boolean; + custom: boolean; + policy: PermissionSet | null; + onSelect: () => void; +} + +function PolicyCard({ + title, + subtitle, + active, + custom, + policy, + onSelect, +}: PolicyCardProps) { + return ( + + ); +} + +interface PermissionEditorProps { + title: string; + draft: PolicyDraft; + sourcePolicy: PermissionSet | null; + custom: boolean; + canClear: boolean; + busy: boolean; + onSave: (draft: PolicyDraft) => void; + onClear: () => void; +} + +function PermissionEditor({ + title, + draft, + sourcePolicy, + custom, + canClear, + busy, + onSave, + onClear, +}: PermissionEditorProps) { + const [local, setLocal] = useState(draft); + + useEffect(() => { + setLocal(draft); + }, [draft]); + + const changed = useMemo( + () => JSON.stringify(local) !== JSON.stringify(draft), + [draft, local], + ); + + function setCapability( + capability: keyof Omit, + choice: CapabilityChoice, + ) { + setLocal((prev) => ({ ...prev, [capability]: choice })); + } + + return ( +
+
+
+

{title}

+

+ {sourcePolicy ? `${sourcePolicy.rules.length} rules` : "Not configured"} +

+
+ {custom && ( + + Advanced + + )} +
+ +
+ + +
+ {CAPABILITY_ROWS.map((row) => ( +
+ {row.label} + setCapability(row.key, choice)} + /> +
+ ))} +
+ +
+ + +
+
+
+ ); +} + +interface SegmentedChoiceProps { + value: CapabilityChoice; + disabled: boolean; + label: string; + onChange: (choice: CapabilityChoice) => void; +} + +function SegmentedChoice({ + value, + disabled, + label, + onChange, +}: SegmentedChoiceProps) { + const choices: { value: CapabilityChoice; label: string }[] = [ + { value: "none", label: "None" }, + { value: "allow", label: "Allow" }, + { value: "deny", label: "Deny" }, + ]; + return ( +
+ {choices.map((choice) => ( + + ))} +
+ ); +} diff --git a/frontend/src/features/permissions/index.ts b/frontend/src/features/permissions/index.ts new file mode 100644 index 0000000..22c6cc5 --- /dev/null +++ b/frontend/src/features/permissions/index.ts @@ -0,0 +1 @@ +export * from "./PermissionsPanel"; diff --git a/frontend/src/features/permissions/permissions.test.tsx b/frontend/src/features/permissions/permissions.test.tsx new file mode 100644 index 0000000..70219af --- /dev/null +++ b/frontend/src/features/permissions/permissions.test.tsx @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; + +import { + MockAgentGateway, + MockPermissionGateway, + MockProfileGateway, +} from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; +import type { Gateways } from "@/ports"; +import { PermissionsPanel } from "./PermissionsPanel"; + +const PROJECT_ID = "proj-permissions-test"; + +async function renderPanel() { + const agent = new MockAgentGateway(); + const permission = new MockPermissionGateway(); + await agent.createAgent(PROJECT_ID, { name: "Builder", profileId: "p1" }); + const gateways = { + agent, + permission, + profile: new MockProfileGateway(), + } as unknown as Gateways; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("Builder")).toBeTruthy(); + }); + + return { agent, permission }; +} + +function clickChoice(groupName: string, choice: string) { + const group = screen.getByRole("group", { name: groupName }); + fireEvent.click(within(group).getByRole("button", { name: choice })); +} + +describe("PermissionsPanel", () => { + it("saves project defaults from the compact policy editor", async () => { + const { permission } = await renderPanel(); + + fireEvent.change(screen.getByLabelText("Project defaults fallback"), { + target: { value: "allow" }, + }); + clickChoice("Read permission", "Allow"); + clickChoice("Bash permission", "Deny"); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(async () => { + const doc = await permission.getProjectPermissions(PROJECT_ID); + expect(doc.projectDefaults?.fallback).toBe("allow"); + expect(doc.projectDefaults?.rules).toEqual([ + { capability: "read", effect: "allow", paths: ["**"] }, + { capability: "executeBash", effect: "deny", commands: [] }, + ]); + }); + }); + + it("saves and clears an agent override", async () => { + const { permission } = await renderPanel(); + + fireEvent.click(screen.getByText("Builder")); + fireEvent.change(screen.getByLabelText("Override — Builder fallback"), { + target: { value: "deny" }, + }); + clickChoice("Write permission", "Allow"); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(async () => { + const doc = await permission.getProjectPermissions(PROJECT_ID); + expect(doc.agents?.[0]?.permissions.fallback).toBe("deny"); + expect(doc.agents?.[0]?.permissions.rules).toEqual([ + { capability: "write", effect: "allow", paths: ["**"] }, + ]); + }); + + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + + await waitFor(async () => { + const doc = await permission.getProjectPermissions(PROJECT_ID); + expect(doc.agents).toEqual([]); + }); + }); +}); diff --git a/frontend/src/features/permissions/usePermissions.ts b/frontend/src/features/permissions/usePermissions.ts new file mode 100644 index 0000000..1eb6fe3 --- /dev/null +++ b/frontend/src/features/permissions/usePermissions.ts @@ -0,0 +1,236 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { useGateways } from "@/app/di"; +import type { + Agent, + Capability, + PermissionEffect, + PermissionPosture, + PermissionRule, + PermissionSet, + ProjectPermissions, +} from "@/domain"; + +export type CapabilityChoice = "none" | PermissionEffect; + +export interface PolicyDraft { + fallback: PermissionPosture; + read: CapabilityChoice; + write: CapabilityChoice; + delete: CapabilityChoice; + executeBash: CapabilityChoice; +} + +export interface AgentPermissionRow { + agent: Agent; + override: PermissionSet | null; +} + +export interface PermissionsViewModel { + agents: Agent[]; + rows: AgentPermissionRow[]; + document: ProjectPermissions | null; + projectDraft: PolicyDraft; + busy: boolean; + error: string | null; + refresh: () => Promise; + saveProjectDefaults: (draft: PolicyDraft) => Promise; + clearProjectDefaults: () => Promise; + saveAgentOverride: (agentId: string, draft: PolicyDraft) => Promise; + clearAgentOverride: (agentId: string) => Promise; +} + +const DEFAULT_DRAFT: PolicyDraft = { + fallback: "ask", + read: "none", + write: "none", + delete: "none", + executeBash: "none", +}; + +const CAPABILITIES: Capability[] = ["read", "write", "delete", "executeBash"]; + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as { message: unknown }).message); + } + return String(e); +} + +export function draftFromSet(set?: PermissionSet | null): PolicyDraft { + if (!set) return { ...DEFAULT_DRAFT }; + const draft: PolicyDraft = { + ...DEFAULT_DRAFT, + fallback: set.fallback, + }; + for (const capability of CAPABILITIES) { + draft[capability] = ruleChoice(set.rules, capability); + } + return draft; +} + +function ruleChoice( + rules: PermissionRule[], + capability: Capability, +): CapabilityChoice { + const matching = rules.filter((rule) => rule.capability === capability); + if (matching.some((rule) => rule.effect === "deny")) return "deny"; + if (matching.some((rule) => rule.effect === "allow")) return "allow"; + return "none"; +} + +export function setFromDraft(draft: PolicyDraft): PermissionSet { + const rules: PermissionRule[] = []; + for (const capability of CAPABILITIES) { + const effect = draft[capability]; + if (effect === "none") continue; + rules.push(ruleFromChoice(capability, effect)); + } + return { fallback: draft.fallback, rules }; +} + +function ruleFromChoice( + capability: Capability, + effect: PermissionEffect, +): PermissionRule { + if (capability === "executeBash") { + return { capability, effect, commands: [] }; + } + return { capability, effect, paths: ["**"] }; +} + +export function isCustomPolicy(set?: PermissionSet | null): boolean { + if (!set) return false; + return set.rules.some((rule) => { + if (rule.capability === "executeBash") { + return (rule.commands ?? []).length > 0; + } + const paths = rule.paths ?? []; + return paths.length !== 1 || paths[0] !== "**"; + }); +} + +export function usePermissions(projectId: string): PermissionsViewModel { + const { agent, permission } = useGateways(); + const [agents, setAgents] = useState([]); + const [document, setDocument] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setBusy(true); + setError(null); + try { + const [agentList, permissionDoc] = await Promise.all([ + agent.listAgents(projectId), + permission.getProjectPermissions(projectId), + ]); + setAgents(agentList); + setDocument(permissionDoc); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + }, [agent, permission, projectId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const rows = useMemo(() => { + const overrides = new Map( + (document?.agents ?? []).map((entry) => [entry.agentId, entry.permissions]), + ); + return agents.map((candidate) => ({ + agent: candidate, + override: overrides.get(candidate.id) ?? null, + })); + }, [agents, document]); + + const projectDraft = useMemo( + () => draftFromSet(document?.projectDefaults ?? null), + [document], + ); + + const saveProjectDefaults = useCallback( + async (draft: PolicyDraft) => { + setBusy(true); + setError(null); + try { + setDocument( + await permission.updateProjectPermissions(projectId, setFromDraft(draft)), + ); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + }, + [permission, projectId], + ); + + const clearProjectDefaults = useCallback(async () => { + setBusy(true); + setError(null); + try { + setDocument(await permission.updateProjectPermissions(projectId, null)); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + }, [permission, projectId]); + + const saveAgentOverride = useCallback( + async (agentId: string, draft: PolicyDraft) => { + setBusy(true); + setError(null); + try { + setDocument( + await permission.updateAgentPermissions( + projectId, + agentId, + setFromDraft(draft), + ), + ); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + }, + [permission, projectId], + ); + + const clearAgentOverride = useCallback( + async (agentId: string) => { + setBusy(true); + setError(null); + try { + setDocument( + await permission.updateAgentPermissions(projectId, agentId, null), + ); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + }, + [permission, projectId], + ); + + return { + agents, + rows, + document, + projectDraft, + busy, + error, + refresh, + saveProjectDefaults, + clearProjectDefaults, + saveAgentOverride, + clearAgentOverride, + }; +} diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index aee8938..b5b348c 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -37,6 +37,7 @@ import { TemplatesPanel } from "@/features/templates"; import { SkillsPanel } from "@/features/skills"; import { MemoryPanel } from "@/features/memory"; import { EmbedderSettings } from "@/features/embedder"; +import { PermissionsPanel } from "@/features/permissions"; import { GitPanel, GitGraphView } from "@/features/git"; import { Button, Input, Panel, Tabs, cn } from "@/shared"; import { useGateways } from "@/app/di"; @@ -49,6 +50,7 @@ type SidebarTab = | "agents" | "templates" | "skills" + | "permissions" | "memory" | "git"; @@ -58,6 +60,7 @@ const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [ { id: "agents", label: "Agents" }, { id: "templates", label: "Templates" }, { id: "skills", label: "Skills" }, + { id: "permissions", label: "Perms" }, { id: "memory", label: "Memory" }, { id: "git", label: "Git" }, ]; @@ -289,6 +292,14 @@ export function ProjectsView() {

Open a project to manage skills.

)} + {/* Permissions panel */} + {sidebarTab === "permissions" && active && ( + + )} + {sidebarTab === "permissions" && !active && ( +

Open a project to manage permissions.

+ )} + {/* Memory panel + embedder settings */} {sidebarTab === "memory" && active && (
diff --git a/frontend/src/features/terminals/useWritePortal.ts b/frontend/src/features/terminals/useWritePortal.ts index 8873e3d..616bf26 100644 --- a/frontend/src/features/terminals/useWritePortal.ts +++ b/frontend/src/features/terminals/useWritePortal.ts @@ -225,11 +225,20 @@ export function useWritePortal( }, bindHandle(handle: TerminalHandle) { handleRef.current = handle; + // Tell the backend a frontend cell is now mounted for this agent, so the + // mediator routes its turns through `delegationReady` (this portal writes) + // rather than writing the PTY itself (the headless path for cell-less agents). + const agent = agentIdRef.current; + if (agent) void inputRef.current.setFrontAttached(agent, true).catch(() => {}); // The PTY just became available — a queued delegation may be injectable. tryInject.current(); }, unbindHandle() { handleRef.current = null; + // The cell is gone — let the backend fall back to headless delivery so a + // delegation arriving while this agent has no live cell is not lost. + const agent = agentIdRef.current; + if (agent) void inputRef.current.setFrontAttached(agent, false).catch(() => {}); }, }), [], diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index fa8999b..613d418 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -29,7 +29,10 @@ import type { MemoryIndexEntry, MemoryLink, MemoryType, + EffectivePermissions, + PermissionSet, Project, + ProjectPermissions, ProfileAvailability, ResumableAgent, Skill, @@ -540,6 +543,15 @@ export interface InputGateway { agentId: string, ticket: string, ): Promise; + /** + * Reports whether a **frontend terminal cell** is mounted for `agentId` (`true` + * when the cell's write-portal binds its PTY handle, `false` on unmount). The + * backend mediator needs this to deliver a turn to a **headless** agent — one + * delegated in the background with no cell, where nobody would consume + * `delegationReady`: it then writes the task into the PTY itself. An agent with a + * mounted cell keeps the write-portal path. Best-effort; never throws into the UI. + */ + setFrontAttached(agentId: string, attached: boolean): Promise; } /** @@ -587,6 +599,28 @@ export interface EmbedderGateway { describeEmbedderEngines(): Promise; } +/** Project and agent permission management (LP1). */ +export interface PermissionGateway { + /** Reads the full project permission document. */ + getProjectPermissions(projectId: string): Promise; + /** Replaces or removes project-level default permissions. */ + updateProjectPermissions( + projectId: string, + permissions: PermissionSet | null, + ): Promise; + /** Replaces or removes one agent-specific override. */ + updateAgentPermissions( + projectId: string, + agentId: string, + permissions: PermissionSet | null, + ): Promise; + /** Resolves effective permissions for one agent. */ + resolveAgentPermissions( + projectId: string, + agentId: string, + ): Promise; +} + /** * The full set of gateways the app depends on, injected via the DI provider. * The composition (real vs mock) is chosen in `app/`. @@ -605,4 +639,5 @@ export interface Gateways { skill: SkillGateway; memory: MemoryGateway; embedder: EmbedderGateway; + permission: PermissionGateway; } From b05d04ab7a6be53f1c6b53830ecc388ec91c07ee Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 15 Jun 2026 21:37:34 +0200 Subject: [PATCH 06/15] =?UTF-8?q?feat(permissions):=20LP4-0=20=E2=80=94=20?= =?UTF-8?q?fondations=20domaine=20de=20l'enforcement=20OS=20(pur)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fondations pures (zéro I/O, zéro dépendance landlock, aucun câblage runtime — SpawnSpec.sandbox posé mais jamais lu ⇒ zéro régression) de la voie « airtight » des permissions, complément de la voie projection LP3. - domain/sandbox.rs : SandboxPlan/PathGrant/PathAccess (RO|RW|EXEC), SandboxContext, SandboxKind/Status/Error, port SandboxEnforcer, et la fonction pure compile_sandbox_plan(EffectivePermissions → plan OS). - domain/permission.rs : render_permission_summary (bloc Markdown injecté plus tard ; mentionne explicitement fichiers OS-enforced vs commandes advisory). - domain/ports.rs : SpawnSpec.sandbox: Option (None ⇒ natif), propagé à tous les sites de construction. Sémantique de compile_sandbox_plan : - Invariant produit : eff == None ⇒ None (rien posé ⇒ CLI 100 % native). - Borne Landlock : seules les capabilities fichier produisent des grants (Read→RO, Write/Delete→RW) ; ExecuteBash reste advisory (non verrouillable par chemin). - Deny-wins PAR CLASSE D'ACCÈS (RO/RW), fail-closed intra-classe : un Deny ne ferme que les grants de sa propre classe (un Deny Write n'ampute pas un Allow Read). Choix retenu pour maximiser l'autonomie des agents : on respecte exactement la politique pré-renseignée sans sur-restreindre, donc moins de blocages qui forceraient l'agent à redemander l'utilisateur. - Globs réduits à leur préfixe statique ; grant abandonné si une barrière de même classe chevauche (égal/ancêtre/descendant) — sous-approximation conservatrice (un sandbox additif ne peut pas carver un deny sous-arbre). Tests : 16 tests purs sur sandbox + 3 sur render_permission_summary, cargo test --workspace 100 % vert, 0 ignored. Reste LP4 : LP4-1 adapter LandlockSandbox + pre_exec PTY (fail-open+warning sauf posture Deny), LP4-2 câblage application, LP4-3 composition root. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/state.rs | 2 + crates/application/src/terminal/usecases.rs | 1 + crates/application/tests/agent_lifecycle.rs | 1 + .../application/tests/change_agent_profile.rs | 1 + .../application/tests/orchestrator_service.rs | 1 + .../application/tests/structured_launch_d3.rs | 1 + crates/domain/src/lib.rs | 14 +- crates/domain/src/permission.rs | 167 ++++ crates/domain/src/ports.rs | 4 + crates/domain/src/sandbox.rs | 715 ++++++++++++++++++ crates/infrastructure/src/runtime/mod.rs | 2 + crates/infrastructure/tests/mcp_server.rs | 1 + .../tests/orchestrator_watcher.rs | 1 + crates/infrastructure/tests/pty_adapter.rs | 1 + 14 files changed, 908 insertions(+), 4 deletions(-) create mode 100644 crates/domain/src/sandbox.rs diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index b9e0694..b383983 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -2331,6 +2331,7 @@ mod mcp_serve_peer_tests { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } @@ -3311,6 +3312,7 @@ mod mcp_e2e_loopback_tests { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } 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 fe0033a..3b9988e 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -336,6 +336,7 @@ impl AgentRuntime for FakeRuntime { cwd: cwd.clone(), env: Vec::new(), context_plan: self.plan.clone(), + sandbox: None, }) } } diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs index 315b178..defb033 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -363,6 +363,7 @@ impl AgentRuntime for FakeRuntime { context_plan: Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), + sandbox: None, }) } } diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index fccb5cc..fd2ba75 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, }) } } diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index a6b2e33..c3e1b25 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, }) } } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 9c38a96..da35044 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -49,6 +49,7 @@ pub mod ports; pub mod profile; pub mod project; pub mod readiness; +pub mod sandbox; pub mod remote; pub mod skill; pub mod template; @@ -119,10 +120,15 @@ pub use layout::{ pub use events::{DomainEvent, OrchestrationSource}; pub use permission::{ - resolve as resolve_permissions, AgentPermissionOverride, Capability, CommandMatcher, - CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError, PermissionProjection, - PermissionProjector, PermissionRule, PermissionSet, Posture, ProjectedFile, ProjectionContext, - ProjectPermissions, ProjectorKey, PERMISSIONS_VERSION, + 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::{ diff --git a/crates/domain/src/permission.rs b/crates/domain/src/permission.rs index b65fd77..38e3d46 100644 --- a/crates/domain/src/permission.rs +++ b/crates/domain/src/permission.rs @@ -683,6 +683,111 @@ pub fn resolve( 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. @@ -1312,4 +1417,66 @@ mod tests { 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 627e236..84238ef 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -98,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 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/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/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 762d8d1..bbff51f 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -242,6 +242,7 @@ impl AgentRuntime for FakeRuntime { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 81cce99..5806837 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -241,6 +241,7 @@ impl AgentRuntime for FakeRuntime { cwd: cwd.clone(), env: Vec::new(), context_plan: Some(ContextInjectionPlan::Stdin), + sandbox: None, }) } } 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, } } From 17ca65ed0f1a82748613468fdb3bc3a5d21ef1a0 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 08:00:47 +0200 Subject: [PATCH 07/15] =?UTF-8?q?feat(permissions):=20LP4-1/LP4-2=20?= =?UTF-8?q?=E2=80=94=20enforcer=20Landlock=20OS=20+=20consommation=20PTY?= =?UTF-8?q?=20du=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LP4-1 : adaptateurs SandboxEnforcer concrets (LandlockSandbox Linux via le LSM Landlock, NoopSandbox ailleurs) + default_enforcer() cfg-sélectionné, prêts à être injectés au composition root (LP4-3). Aucun changement de comportement runtime tant que rien n'est câblé : SpawnSpec.sandbox reste None. LP4-2 : PortablePtyAdapter consomme SpawnSpec.sandbox via spawn_command_sandboxed (thread jetable restreint par enforce() puis fork → le domaine Landlock est hérité par l'enfant à travers execve ; les autres threads d'IdeA ne sont jamais sandboxés). Builder additif with_sandbox_enforcer ; fail-closed si enforce() échoue. Tests : domain + infrastructure verts (dont les tests Landlock réels de fencing read-only/write-only et la confine-au-thread). Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 32 ++ crates/infrastructure/Cargo.toml | 7 + crates/infrastructure/src/lib.rs | 4 + crates/infrastructure/src/pty/mod.rs | 91 +++- crates/infrastructure/src/sandbox/landlock.rs | 412 ++++++++++++++++++ crates/infrastructure/src/sandbox/mod.rs | 82 ++++ crates/infrastructure/src/sandbox/noop.rs | 33 ++ 7 files changed, 653 insertions(+), 8 deletions(-) create mode 100644 crates/infrastructure/src/sandbox/landlock.rs create mode 100644 crates/infrastructure/src/sandbox/mod.rs create mode 100644 crates/infrastructure/src/sandbox/noop.rs 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/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/lib.rs b/crates/infrastructure/src/lib.rs index 1ed8376..df9e914 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -29,6 +29,7 @@ pub mod process; pub mod pty; pub mod remote; pub mod runtime; +pub mod sandbox; pub mod session; pub mod store; @@ -55,6 +56,9 @@ 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; diff --git a/crates/infrastructure/src/pty/mod.rs b/crates/infrastructure/src/pty/mod.rs index 9322cbe..2aec7c6 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 diff --git a/crates/infrastructure/src/sandbox/landlock.rs b/crates/infrastructure/src/sandbox/landlock.rs new file mode 100644 index 0000000..05c4b39 --- /dev/null +++ b/crates/infrastructure/src/sandbox/landlock.rs @@ -0,0 +1,412 @@ +//! [`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; + + // 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); + } + + /// **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) + } +} From 7e01ac60cb357c98ae8d51dd5388e5aa32f931f2 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 08:08:40 +0200 Subject: [PATCH 08/15] =?UTF-8?q?feat(permissions):=20LP4-3=20=E2=80=94=20?= =?UTF-8?q?activation=20bout-en-bout=20de=20la=20sandbox=20OS=20au=20runti?= =?UTF-8?q?me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composition root : PortablePtyAdapter::new() injecte désormais default_enforcer() (Landlock sur Linux / Noop ailleurs) via with_sandbox_enforcer, dans state.rs et dans LocalHost (remote/mod.rs). Launch path : LaunchAgent::execute peuple spec.sandbox via le plan pur compile_sandbox_plan(effective_permissions, SandboxContext{project_root, run_dir}), en réutilisant l'EffectivePermissions déjà résolu pour la projection advisory LP3. Invariant produit conservé : eff == None ⇒ plan None ⇒ comportement natif, aucune projection OS. Portée : seul le chemin PTY brut est OS-enforcé ; le chemin structuré (Claude/Codex mode JSON) porte le plan mais ne l'enforce pas encore (lot ultérieur). Tests : ajout d'un test d'intégration bout-en-bout (pty/mod.rs, sandbox_e2e_tests) prouvant qu'un enfant spawné sous un plan RW restreint ne peut PAS écrire hors-grant (écriture bloquée par le kernel) alors que l'écriture dans le grant passe, plus un test compagnon anti faux-positif (sans plan ⇒ pas de sandbox). Skip propre si le kernel n'a pas Landlock. domain+infrastructure+application+app-tauri verts. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/state.rs | 4 +- crates/application/src/agent/lifecycle.rs | 17 +++ crates/infrastructure/src/pty/mod.rs | 175 ++++++++++++++++++++++ crates/infrastructure/src/remote/mod.rs | 4 +- 4 files changed, 198 insertions(+), 2 deletions(-) diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index b383983..b4fe994 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -393,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()); diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index bbb581f..025f2de 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -28,6 +28,7 @@ use domain::{ ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession, }; +use domain::sandbox::{compile_sandbox_plan, SandboxContext}; use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; @@ -1510,6 +1511,22 @@ impl LaunchAgent { ) .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 // run dir (étape 5) ; la CLI structurée le lira à chaque tour diff --git a/crates/infrastructure/src/pty/mod.rs b/crates/infrastructure/src/pty/mod.rs index 2aec7c6..fd7af8d 100644 --- a/crates/infrastructure/src/pty/mod.rs +++ b/crates/infrastructure/src/pty/mod.rs @@ -446,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()), + ), } } } From 6236cd727bf2f04475b422d8c973ce1be524b824 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 08:47:33 +0200 Subject: [PATCH 09/15] =?UTF-8?q?feat(permissions):=20LP4-4=20=E2=80=94=20?= =?UTF-8?q?enforcement=20Landlock=20sur=20le=20chemin=20structur=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étend l'enforcement OS au chemin structuré (sessions Claude/Codex mode JSON), jusqu'ici seulement advisory. Approche validée par l'Architecte : transposer la technique du PTY plutôt qu'un pre_exec (rejeté — landlock alloue, deadlock malloc post-fork en process multithreadé). Mécanique (cfg(target_os=linux)) : run_turn_sandboxed/drain_sandboxed exécutent enforce(plan) sur un thread jetable AVANT le spawn std, puis std::process::spawn depuis ce thread ; l'enfant hérite le domaine Landlock via les credentials de la tâche (garanti à travers fork/clone/execve, y compris posix_spawn — pas de pre_exec nécessaire, forbid(unsafe_code) préservé). Fail-closed sur Err d'enforce (aucun child). Timeout sous sandbox : oneshot killer + tokio::time::timeout → kill → EOF → reap (pas de zombie/thread bloqué). Chemin non-sandboxé (plan None / pas d'enforcer / non-Linux) = drain async tokio inchangé. Contrat : SpawnLine.sandbox ; AgentSessionFactory::start(.., sandbox) ; StructuredSessionFactory::with_sandbox_enforcer (jumeau du PTY) ; plan calculé en step 5d de lifecycle relayé à launch_structured ; default_enforcer() injecté au composition root. Tests : 7 invariants e2e (parité, companion négatif, fail-closed, no-op natif, confinement de l'irréversibilité entre tours, timeout, resume préservé) — zéro token (sh/FakeCli). 80 suites vertes, 0 failed. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/state.rs | 6 +- crates/application/src/agent/lifecycle.rs | 8 +- .../application/tests/orchestrator_service.rs | 1 + .../application/tests/structured_launch_d3.rs | 3 +- crates/domain/src/ports.rs | 8 + crates/domain/tests/structured_session_d0.rs | 3 +- crates/infrastructure/src/session/claude.rs | 20 +- crates/infrastructure/src/session/codex.rs | 18 +- .../infrastructure/src/session/conformance.rs | 1 + crates/infrastructure/src/session/factory.rs | 50 +- crates/infrastructure/src/session/mod.rs | 59 ++- crates/infrastructure/src/session/process.rs | 204 +++++++- .../infrastructure/src/session/sandbox_e2e.rs | 479 ++++++++++++++++++ 13 files changed, 821 insertions(+), 39 deletions(-) create mode 100644 crates/infrastructure/src/session/sandbox_e2e.rs diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index b4fe994..93ac7a2 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -404,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), diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 025f2de..4e8bf29 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -28,7 +28,7 @@ use domain::{ ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession, }; -use domain::sandbox::{compile_sandbox_plan, SandboxContext}; +use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan}; use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; @@ -1569,6 +1569,7 @@ impl LaunchAgent { &input.project.root, input.node_id, size, + spec.sandbox.as_ref(), ) .await; } @@ -1630,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()))?; diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index fd2ba75..bb0f68e 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -3022,6 +3022,7 @@ impl AgentSessionFactory for CountingFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { self.starts.fetch_add(1, Ordering::SeqCst); let id = { diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index c3e1b25..45d7738 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -520,6 +520,7 @@ impl AgentSessionFactory for FakeFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, session: &SessionPlan, + _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { self.starts .lock() @@ -1114,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); diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 84238ef..5050088 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -531,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. @@ -540,6 +547,7 @@ pub trait AgentSessionFactory: Send + Sync { ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, + sandbox: Option<&crate::sandbox::SandboxPlan>, ) -> Result, AgentSessionError>; } diff --git a/crates/domain/tests/structured_session_d0.rs b/crates/domain/tests/structured_session_d0.rs index 571af6c..ea136b7 100644 --- a/crates/domain/tests/structured_session_d0.rs +++ b/crates/domain/tests/structured_session_d0.rs @@ -340,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)), @@ -394,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/src/session/claude.rs b/crates/infrastructure/src/session/claude.rs index 3be6da8..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}; @@ -154,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, } } @@ -201,6 +214,7 @@ impl ClaudeSdkSession { cwd: self.cwd.clone(), env: Vec::new(), stdin: None, + sandbox: self.sandbox.clone(), } } } @@ -217,7 +231,7 @@ 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; diff --git a/crates/infrastructure/src/session/codex.rs b/crates/infrastructure/src/session/codex.rs index 94042a8..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}; @@ -123,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, } } @@ -176,6 +189,7 @@ impl CodexExecSession { cwd: self.cwd.clone(), env: Vec::new(), stdin: None, + sandbox: self.sandbox.clone(), } } } @@ -192,7 +206,7 @@ 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; diff --git a/crates/infrastructure/src/session/conformance.rs b/crates/infrastructure/src/session/conformance.rs index 79bc53c..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, } } } 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 89e3951..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,8 +103,9 @@ 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:?}"); } @@ -284,6 +290,8 @@ mod tests { fake.command(), "/", None, + None, + None, )); assert_agent_session_contract(session, "claude-conv-1", "réponse Claude").await; } @@ -296,6 +304,8 @@ mod tests { fake.command(), "/", None, + None, + None, )); assert_agent_session_contract(session, "codex-conv-1", "réponse Codex").await; } @@ -305,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 @@ -323,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:?}"), @@ -404,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; @@ -414,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; @@ -434,6 +444,7 @@ mod tests { &SessionPlan::Resume { conversation_id: "repris-42".to_owned(), }, + None, ) .await .expect("start resume ok"); @@ -525,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:?}"); @@ -757,7 +769,7 @@ 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 finals = events .iter() @@ -785,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() @@ -801,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()); } @@ -811,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"]); } @@ -872,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:?}"); @@ -894,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")); @@ -938,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"); @@ -960,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")); @@ -999,6 +1016,7 @@ mod tests { &SessionPlan::Resume { conversation_id: "cx-resume".to_owned(), }, + None, ) .await .expect("start resume codex"); @@ -1021,6 +1039,7 @@ mod tests { &SessionPlan::Assign { conversation_id: "ignored-by-engine".to_owned(), }, + None, ) .await .expect("start assign"); @@ -1045,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(_)) => {} @@ -1068,6 +1087,8 @@ mod tests { fake.command(), "/", None, + None, + None, )); assert_agent_session_contract(session, "cx-0", "direct").await; } @@ -1096,7 +1117,7 @@ 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, @@ -1133,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(); @@ -1165,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(); @@ -1207,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(); @@ -1241,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); +} From ab9162f686919b22e551abf4a8b04160889ad479 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 08:51:57 +0200 Subject: [PATCH 10/15] docs(memory): consigne l'etat du systeme de permissions/sandbox + risque residuel $HOME Co-Authored-By: Claude Opus 4.8 --- .ideai/memory/MEMORY.md | 1 + .../permissions-sandbox-system-state.md | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 .ideai/memory/permissions-sandbox-system-state.md diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 5492db9..5cefda9 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -4,3 +4,4 @@ - [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/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. From 40bce5c8bf001ae54ffd49adb4ef599ff9177e15 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 09:38:57 +0200 Subject: [PATCH 11/15] feat(sandbox): Landlock no-op sous Posture::Allow (allow-by-default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un allowlist Landlock ne peut que retirer des accès (deny-by-default). Sous une fallback Posture::Allow (allow-by-default), enforcer les grants verrouillerait tout hors du project root — binaire CLI, libs et ~/. — provoquant le 'failed to open terminal'. On n'enforce donc rien sous Allow ; seuls les Deny explicites font foi (advisory via la projection LP3). Couvre permissions.json persisté avec fallback allow. Co-Authored-By: Claude Opus 4.8 --- .ideai/permissions.json | 38 +++++++++++ crates/infrastructure/src/sandbox/landlock.rs | 67 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 .ideai/permissions.json 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/crates/infrastructure/src/sandbox/landlock.rs b/crates/infrastructure/src/sandbox/landlock.rs index 05c4b39..ebad8aa 100644 --- a/crates/infrastructure/src/sandbox/landlock.rs +++ b/crates/infrastructure/src/sandbox/landlock.rs @@ -90,6 +90,20 @@ impl SandboxEnforcer for LandlockSandbox { 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(); @@ -283,6 +297,59 @@ mod tests { 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** From 31b636037d2ec57a2e17da91cb2f4b64739a6e08 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 09:39:03 +0200 Subject: [PATCH 12/15] fix(ui): layouts responsive pour panneau agents et bandeau d'onglets sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentsPanel : formulaire de création et items d'agent passent en colonne, les libellés/états longs (running in IdeA, profil) wrappent/tronquent au lieu de déborder. ProjectsView : les onglets de la sidebar wrappent sur plusieurs lignes pour rester tous visibles, le bouton collapse reste épinglé en haut. Co-Authored-By: Claude Opus 4.8 --- frontend/src/features/agents/AgentsPanel.tsx | 21 ++++----- .../src/features/projects/ProjectsView.tsx | 43 +++++++++++-------- 2 files changed, 35 insertions(+), 29 deletions(-) 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 */} -
+