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; }