feat(permissions): voie projection CLI (LP0→LP3) + checkpoint Codex/input

Jalon vert regroupant deux chantiers entrelacés dans le working tree,
indissociables au niveau fichier mais tous deux verts (cargo test
--workspace + tests frontend permissions au vert).

Permissions — voie « projection CLI » (advisory), complète :
- LP0 domaine pur : modèle PermissionSet/EffectivePermissions, resolve
  deny-wins + postures Allow<Ask<Deny (crates/domain/src/permission.rs).
- LP1 store : FsPermissionStore (.ideai/permissions.json).
- LP2 use cases : Get/Update project, Update agent override, Resolve.
- LP3 projecteurs Claude/Codex (settings.local.json / config.toml),
  câblage launch-path + PermissionProjectorRegistry, nettoyage des
  fichiers Replace orphelins au swap de profil (LP3-4), composition root
  + commandes Tauri, UI PermissionsPanel (projet + override agent).
- ports.rs : PermissionStore + FileSystem::remove_file (cleanup au swap).

Reste ouvert (hors scope, marqué dans le code) : LP4 enforcement OS
airtight (Landlock fichiers) + résumé de permissions injecté.

Inclut aussi le chantier Codex/input/sessions structurées en cours
(McpConfigStrategy, StructuredAdapter, gestion d'input) partageant les
mêmes fichiers (lifecycle.rs, commands.rs, dto.rs, state.rs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 20:39:18 +02:00
parent 46492506e1
commit 27597eb64e
41 changed files with 6513 additions and 269 deletions

View File

@ -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** (`<exe> mcp-server …` declare dans chaque `.ideai/run/<id>/.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 <demandeur> · ticket <id>]` 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::<Option<Vec<u8>>>` ; 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/<run-dir>/*.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<Mutex>`), `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/<encoded-run-dir>/*.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/<run-dir>/*.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]].

View File

@ -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<ProjectPermissionsDto, ErrorDto> {
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<ProjectPermissionsDto, ErrorDto> {
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<ProjectPermissionsDto, ErrorDto> {
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<Option<EffectivePermissionsDto>, 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).
///

View File

@ -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<PermissionSet>,
}
/// 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<PermissionSet>,
}
/// 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)]

View File

@ -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,

View File

@ -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<InspectConversation>,
/// Project registry — used by agent commands to resolve a `Project` from an id.
pub project_store: Arc<dyn ProjectStore>,
/// Read the project permission document.
pub get_project_permissions: Arc<GetProjectPermissions>,
/// Update project-level default permissions.
pub update_project_permissions: Arc<UpdateProjectPermissions>,
/// Update one agent permission override.
pub update_agent_permissions: Arc<UpdateAgentPermissions>,
/// Resolve effective permissions for one agent.
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
// --- Windows (L10) ---
/// Detach a tab into a new OS window (persists the workspace topology).
pub move_tab: Arc<MoveTabToNewWindow>,
@ -498,6 +509,10 @@ impl AppState {
let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
let contexts_port = Arc::clone(&contexts) as Arc<dyn AgentContextStore>;
// --- Project permissions (LP1) ---
let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port)));
let permission_store_port = Arc::clone(&permission_store) as Arc<dyn PermissionStore>;
// --- 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<dyn domain::PermissionProjector>)
.with(Arc::new(CodexPermissionProjector)
as Arc<dyn domain::PermissionProjector>),
);
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 (`<root>/.ideai/conversations/`),
// son résumé est réinjecté dans le convention file. Best-effort, additif :
@ -658,7 +688,11 @@ impl AppState {
// `<root>/.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<dyn application::ProviderSessionProvider>),
as Arc<dyn application::ProviderSessionProvider>)
// 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<dyn domain::input::InputMediator>;
let input_mediator = Arc::clone(&mediated_inbox) as Arc<dyn domain::input::InputMediator>;
// 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<dyn Fn(&str) + Send + Sync> = 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(),

View File

@ -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<AgentProfile> {
)
.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<AgentProfile> {
)
.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)"
);
}
}
}

View File

@ -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<Arc<StructuredSessions>>,
/// 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<Arc<PermissionProjectorRegistry>>,
}
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<PermissionProjectorRegistry>` 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<PermissionProjectorRegistry>,
) -> 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<String> = 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<StructuredSessionDescriptor>,
}
/// 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<ProjectorKey, Arc<dyn PermissionProjector>>,
}
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<dyn PermissionProjector>) -> Self {
self.insert(projector);
self
}
/// Registers (or replaces) a projector under its own key.
pub fn insert(&mut self, projector: Arc<dyn PermissionProjector>) {
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<dyn PermissionProjector>> {
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<ProjectorKey> {
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<Arc<crate::embedder::CheckEmbedderSuggestion>>,
/// 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<Arc<dyn PermissionStore>>,
/// 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<Arc<dyn ProviderSessionProvider>>,
/// 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<Arc<PermissionProjectorRegistry>>,
}
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<PermissionProjectorRegistry>,
) -> 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<dyn PermissionStore>) -> 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 `<run_dir>/<rel_path>` 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<Option<EffectivePermissions>, 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 `<cwd>/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<String> {
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 = <toml value>`): 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\""));
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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<dyn PermissionStore>,
}
impl GetProjectPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
Self { store }
}
/// Executes the read.
pub async fn execute(
&self,
input: GetProjectPermissionsInput,
) -> Result<GetProjectPermissionsOutput, AppError> {
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<dyn PermissionStore>,
}
impl UpdateProjectPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
Self { store }
}
/// Executes the mutation.
pub async fn execute(
&self,
input: UpdateProjectPermissionsInput,
) -> Result<GetProjectPermissionsOutput, AppError> {
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<PermissionSet>,
}
/// Replaces one agent override.
pub struct UpdateAgentPermissions {
store: Arc<dyn PermissionStore>,
}
impl UpdateAgentPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
Self { store }
}
/// Executes the mutation.
pub async fn execute(
&self,
input: UpdateAgentPermissionsInput,
) -> Result<GetProjectPermissionsOutput, AppError> {
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<PermissionSet>,
}
/// Resolves effective permissions for one agent.
pub struct ResolveAgentPermissions {
store: Arc<dyn PermissionStore>,
}
impl ResolveAgentPermissions {
/// Builds the use case.
#[must_use]
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
Self { store }
}
/// Executes the resolution.
pub async fn execute(
&self,
input: ResolveAgentPermissionsInput,
) -> Result<ResolveAgentPermissionsOutput, AppError> {
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<EffectivePermissions>,
}

View File

@ -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<Mutex<Vec<String>>>,
/// 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<Mutex<HashMap<String, String>>>,
}
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<u8>)> {
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<Vec<u8>, 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<ProjectPermissions, StoreError> {
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<String> {
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<String> {
Vec::new()
}
}
/// A registry carrying both faithful projector doubles.
fn full_registry() -> Arc<PermissionProjectorRegistry> {
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<ContextInjectionPlan>,
registry: Option<Arc<PermissionProjectorRegistry>>,
perm_doc: Option<ProjectPermissions>,
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
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<ContextInjectionPlan> {
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"
);
}

View File

@ -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<String, Vec<u8>>,
dirs: HashSet<String>,
write_count: usize,
/// Paths passed to `remove_file`, in order (lot LP3-4 cleanup assertions).
removed: Vec<String>,
}
#[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<String> {
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<Vec<DirEntry>, FsError> {
Ok(Vec::new())
}
@ -366,6 +392,16 @@ impl FakePty {
fn kills(&self) -> Vec<SessionId> {
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<String> {
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<ProjectPermissions, StoreError> {
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<String> {
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<String> {
Vec::new()
}
}
/// A registry carrying both faithful projector doubles.
fn full_registry() -> Arc<PermissionProjectorRegistry> {
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<AgentProfile>,
registry: Arc<PermissionProjectorRegistry>,
perm_doc: Option<ProjectPermissions>,
) -> 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(&registry));
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(&registry));
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
// ---------------------------------------------------------------------------

View File

@ -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<ProjectPermissions>,
saves: Mutex<usize>,
}
#[async_trait]
impl PermissionStore for FakePermissionStore {
async fn load_permissions(&self, _project: &Project) -> Result<ProjectPermissions, StoreError> {
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);
}

View File

@ -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);

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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<bool, FsError>;
/// 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<ProjectPermissions, StoreError>;
/// 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]

View File

@ -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<u32>,
/// 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<ProjectorKey>,
}
/// 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.

View File

@ -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

View File

@ -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<HashMap<AgentId, LivenessState>>,
/// **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<HashSet<AgentId>>,
/// **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<HashMap<AgentId, DeferredDelegation>>,
events: Option<Arc<dyn EventBus>>,
/// 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<HeadlessSink>,
}
/// 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<String>,
submit_delay_ms: Option<u32>,
}
/// 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<dyn Fn(AgentId, DeferredDelegation) -> Option<DeferredDelegation> + 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<AgentId>> {
self.starting
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn lock_deferred(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, DeferredDelegation>> {
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<AgentId, AgentBusyState>> {
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<Arc<dyn PtyPort>>,
/// Per-agent live input handle (one stream per agent), fed by `bind_handle`.
handles: Mutex<HashMap<AgentId, PtyHandle>>,
/// `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<Mutex<HashMap<AgentId, PtyHandle>>>,
/// 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<Mutex<HashSet<AgentId>>>,
/// 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<InMemoryMailbox>, clock: Arc<dyn MillisClock>) -> 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<Arc<dyn EventBus>>,
pty: Option<&Arc<dyn PtyPort>>,
handles: &Arc<Mutex<HashMap<AgentId, PtyHandle>>>,
front_owned: &Arc<Mutex<HashSet<AgentId>>>,
) -> Arc<BusyTracker> {
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<dyn PtyPort>,
handles: Arc<Mutex<HashMap<AgentId, PtyHandle>>>,
front_owned: Arc<Mutex<HashSet<AgentId>>>,
) -> 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<dyn EventBus>) -> 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<dyn MillisClock>,
pty: Arc<dyn PtyPort>,
) -> 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<dyn PtyPort>,
);
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
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<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
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<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
// 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<dyn EventBus>);
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<dyn EventBus>);
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<dyn EventBus>);
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<dyn EventBus>);
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<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
// 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<dyn PtyPort>,
)
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
// 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);

View File

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

View File

@ -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<Arc<dyn Fn(&str) + Send + Sync>>,
/// 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<dyn Fn(&str) + Send + Sync>) -> 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<T: Transport>(&self, transport: &mut T) {
let (tx, mut rx) = mpsc::unbounded_channel::<Option<Vec<u8>>>();
// 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<Value>,
) -> Result<Value, JsonRpcError> {
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.

View File

@ -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<String> {
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<String>) -> Vec<String> {
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<String> {
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<String>, 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<String>, 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<String>, 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::<Vec<_>>()
.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<PermissionRule>, 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<String> {
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()
]
);
}
}

View File

@ -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<String> {
// 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"
);
}
}
}

View File

@ -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))
}

View File

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

View File

@ -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 `<project>/.ideai/permissions.json`.
#[derive(Clone)]
pub struct FsPermissionStore {
fs: Arc<dyn FileSystem>,
}
impl FsPermissionStore {
/// Builds the store from an injected filesystem port.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> 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<ProjectPermissions, StoreError> {
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()))
}
}

View File

@ -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<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let sink_buf = Arc::clone(&captured);
let ready_sink: Arc<dyn Fn(&str) + Send + Sync> =
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<Vec<u8>>,
outbound: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
close: Arc<tokio::sync::Notify>,
}
impl GatedTransport {
fn new(
messages: Vec<Vec<u8>>,
) -> (
Self,
tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
Arc<tokio::sync::Notify>,
) {
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<Vec<u8>, 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");
}

View File

@ -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<dyn FileSystem> = 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");
}

View File

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

View File

@ -32,4 +32,10 @@ export class TauriInputGateway implements InputGateway {
request: { projectId, agentId, ticket },
});
}
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
await invoke("set_front_attached", {
request: { agentId, attached },
});
}
}

View File

@ -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<void> {
this.interrupts.push({ projectId, agentId });
@ -1565,6 +1571,72 @@ export class MockInputGateway implements InputGateway {
): Promise<void> {
this.delivered.push({ projectId, agentId, ticket });
}
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
this.frontAttached.push({ agentId, attached });
}
}
/** In-memory permissions gateway. */
export class MockPermissionGateway implements PermissionGateway {
private docs = new Map<string, ProjectPermissions>();
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<ProjectPermissions> {
return structuredClone(this.doc(projectId));
}
async updateProjectPermissions(
projectId: string,
permissions: PermissionSet | null,
): Promise<ProjectPermissions> {
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<ProjectPermissions> {
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<EffectivePermissions | null> {
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(),
};
}

View File

@ -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<ProjectPermissions> {
return invoke<ProjectPermissions>("get_project_permissions", { projectId });
}
updateProjectPermissions(
projectId: string,
permissions: PermissionSet | null,
): Promise<ProjectPermissions> {
return invoke<ProjectPermissions>("update_project_permissions", {
request: { projectId, permissions },
});
}
updateAgentPermissions(
projectId: string,
agentId: string,
permissions: PermissionSet | null,
): Promise<ProjectPermissions> {
return invoke<ProjectPermissions>("update_agent_permissions", {
request: { projectId, agentId, permissions },
});
}
resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<EffectivePermissions | null> {
return invoke<EffectivePermissions | null>("resolve_agent_permissions", {
request: { projectId, agentId },
});
}
}

View File

@ -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).
// ---------------------------------------------------------------------------

View File

@ -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<PolicyDraft, "fallback">;
label: string;
}[] = [
{ key: "read", label: "Read" },
{ key: "write", label: "Write" },
{ key: "delete", label: "Delete" },
{ key: "executeBash", label: "Bash" },
];
const POSTURE_LABELS: Record<PermissionPosture, string> = {
ask: "Ask",
allow: "Allow",
deny: "Deny",
};
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
const vm = usePermissions(projectId);
const [target, setTarget] = useState<EditorTarget>({ 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 (
<Panel
title="Permissions"
actions={
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => void vm.refresh()}
>
Refresh
</Button>
}
className="flex flex-col"
flush
>
{vm.error && (
<p
role="alert"
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
<div className="flex flex-col gap-4 p-4">
<PolicyCard
title="Project defaults"
subtitle={hasProjectDefaults ? "Configured" : "Native CLI behavior"}
active={target.type === "project"}
custom={isCustomPolicy(vm.document?.projectDefaults)}
onSelect={() => setTarget({ type: "project" })}
policy={vm.document?.projectDefaults ?? null}
/>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
Agents
</h4>
{vm.busy && <Spinner size={14} />}
</div>
{vm.rows.length === 0 ? (
<p className="text-sm text-muted">No agents yet.</p>
) : (
<ul className="flex flex-col gap-2">
{vm.rows.map((row) => {
const active =
target.type === "agent" && target.agentId === row.agent.id;
return (
<li key={row.agent.id}>
<PolicyCard
title={row.agent.name}
subtitle={row.override ? "Override" : "Inherited"}
active={active}
custom={isCustomPolicy(row.override)}
onSelect={() =>
setTarget({
type: "agent",
agentId: row.agent.id,
agentName: row.agent.name,
})
}
policy={row.override}
/>
</li>
);
})}
</ul>
)}
</div>
<PermissionEditor
key={target.type === "project" ? "project" : target.agentId}
title={
target.type === "project"
? "Project defaults"
: `Override — ${target.agentName}`
}
draft={activeDraft}
sourcePolicy={activePolicy}
custom={activeCustom}
canClear={
target.type === "project"
? vm.document?.projectDefaults != null
: selectedAgent?.override != null
}
busy={vm.busy}
onSave={(draft) => void handleSave(draft)}
onClear={() => void handleClear()}
/>
</div>
</Panel>
);
}
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 (
<button
type="button"
onClick={onSelect}
aria-pressed={active}
className={cn(
"flex w-full min-w-0 items-center justify-between gap-3 rounded-md border px-3 py-2 text-left",
"transition-colors hover:border-border-strong hover:bg-raised",
active ? "border-primary bg-raised" : "border-border bg-surface",
)}
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium text-content">{title}</span>
<span className="text-xs text-muted">{subtitle}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5">
{custom && (
<span className="rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning">
Custom
</span>
)}
{policy && (
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary">
{POSTURE_LABELS[policy.fallback]}
</span>
)}
</span>
</button>
);
}
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<PolicyDraft>(draft);
useEffect(() => {
setLocal(draft);
}, [draft]);
const changed = useMemo(
() => JSON.stringify(local) !== JSON.stringify(draft),
[draft, local],
);
function setCapability(
capability: keyof Omit<PolicyDraft, "fallback">,
choice: CapabilityChoice,
) {
setLocal((prev) => ({ ...prev, [capability]: choice }));
}
return (
<section className="rounded-md border border-border bg-surface">
<header className="flex items-start justify-between gap-3 border-b border-border px-3 py-2.5">
<div className="min-w-0">
<h4 className="truncate text-sm font-semibold text-content">{title}</h4>
<p className="text-xs text-muted">
{sourcePolicy ? `${sourcePolicy.rules.length} rules` : "Not configured"}
</p>
</div>
{custom && (
<span className="shrink-0 rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning">
Advanced
</span>
)}
</header>
<div className="flex flex-col gap-3 p-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Fallback</span>
<select
aria-label={`${title} fallback`}
value={local.fallback}
disabled={busy}
onChange={(e) =>
setLocal((prev) => ({
...prev,
fallback: e.target.value as PermissionPosture,
}))
}
className={cn(
"h-9 rounded-md border border-border bg-raised px-3 text-sm text-content",
"outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<option value="ask">Ask</option>
<option value="allow">Allow</option>
<option value="deny">Deny</option>
</select>
</label>
<div className="grid grid-cols-1 gap-2">
{CAPABILITY_ROWS.map((row) => (
<div
key={row.key}
className="grid grid-cols-[4.5rem_1fr] items-center gap-2"
>
<span className="text-xs font-medium text-muted">{row.label}</span>
<SegmentedChoice
value={local[row.key]}
disabled={busy}
label={row.label}
onChange={(choice) => setCapability(row.key, choice)}
/>
</div>
))}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
<Button
size="sm"
variant="ghost"
disabled={busy || !canClear}
onClick={onClear}
>
Clear
</Button>
<Button
size="sm"
variant="primary"
disabled={busy || !changed}
loading={busy && changed}
onClick={() => onSave(local)}
>
Save
</Button>
</div>
</div>
</section>
);
}
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 (
<div
role="group"
aria-label={`${label} permission`}
className="grid grid-cols-3 overflow-hidden rounded-md border border-border"
>
{choices.map((choice) => (
<button
key={choice.value}
type="button"
disabled={disabled}
aria-pressed={value === choice.value}
onClick={() => onChange(choice.value)}
className={cn(
"h-8 min-w-0 border-r border-border px-2 text-xs font-medium last:border-r-0",
"transition-colors disabled:cursor-not-allowed disabled:opacity-50",
value === choice.value
? choice.value === "deny"
? "bg-danger/20 text-danger"
: choice.value === "allow"
? "bg-success/20 text-success"
: "bg-raised text-content"
: "bg-surface text-muted hover:bg-raised hover:text-content",
)}
>
{choice.label}
</button>
))}
</div>
);
}

View File

@ -0,0 +1 @@
export * from "./PermissionsPanel";

View File

@ -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(
<DIProvider gateways={gateways}>
<PermissionsPanel projectId={PROJECT_ID} />
</DIProvider>,
);
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([]);
});
});
});

View File

@ -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<void>;
saveProjectDefaults: (draft: PolicyDraft) => Promise<void>;
clearProjectDefaults: () => Promise<void>;
saveAgentOverride: (agentId: string, draft: PolicyDraft) => Promise<void>;
clearAgentOverride: (agentId: string) => Promise<void>;
}
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<Agent[]>([]);
const [document, setDocument] = useState<ProjectPermissions | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<AgentPermissionRow[]>(() => {
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,
};
}

View File

@ -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() {
<p className="text-sm text-muted">Open a project to manage skills.</p>
)}
{/* Permissions panel */}
{sidebarTab === "permissions" && active && (
<PermissionsPanel projectId={active.id} />
)}
{sidebarTab === "permissions" && !active && (
<p className="text-sm text-muted">Open a project to manage permissions.</p>
)}
{/* Memory panel + embedder settings */}
{sidebarTab === "memory" && active && (
<div className="flex flex-col gap-4">

View File

@ -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(() => {});
},
}),
[],

View File

@ -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<void>;
/**
* 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<void>;
}
/**
@ -587,6 +599,28 @@ export interface EmbedderGateway {
describeEmbedderEngines(): Promise<EmbedderEngines>;
}
/** Project and agent permission management (LP1). */
export interface PermissionGateway {
/** Reads the full project permission document. */
getProjectPermissions(projectId: string): Promise<ProjectPermissions>;
/** Replaces or removes project-level default permissions. */
updateProjectPermissions(
projectId: string,
permissions: PermissionSet | null,
): Promise<ProjectPermissions>;
/** Replaces or removes one agent-specific override. */
updateAgentPermissions(
projectId: string,
agentId: string,
permissions: PermissionSet | null,
): Promise<ProjectPermissions>;
/** Resolves effective permissions for one agent. */
resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<EffectivePermissions | null>;
}
/**
* 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;
}