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:
@ -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]].
|
||||
|
||||
@ -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).
|
||||
///
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,9 +1486,30 @@ 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)
|
||||
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
|
||||
// run dir (étape 5) ; la CLI structurée le lira à chaque tour
|
||||
@ -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\""));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
150
crates/application/src/permission.rs
Normal file
150
crates/application/src/permission.rs
Normal 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>,
|
||||
}
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(®istry));
|
||||
if let Some(doc) = perm_doc {
|
||||
launch = launch.with_permission_store(Arc::new(FakePermissionStore(doc)));
|
||||
}
|
||||
|
||||
let swap = ChangeAgentProfile::new(
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(profiles),
|
||||
Arc::new(store),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(pty.clone()),
|
||||
Arc::new(launch),
|
||||
Arc::new(bus.clone()),
|
||||
)
|
||||
.with_permission_projectors(Arc::clone(®istry));
|
||||
|
||||
Fixture {
|
||||
swap,
|
||||
contexts,
|
||||
fs,
|
||||
pty,
|
||||
bus,
|
||||
sessions,
|
||||
runtime,
|
||||
handoffs,
|
||||
}
|
||||
}
|
||||
|
||||
/// The full `permissions.json` posing a project-level `Allow` policy ⇒ a relaunch
|
||||
/// resolves `Some(eff)` and the projector writes the new CLI config.
|
||||
fn allow_perm_doc() -> ProjectPermissions {
|
||||
ProjectPermissions::new(Some(PermissionSet::new(vec![], Posture::Allow)), Vec::new())
|
||||
}
|
||||
|
||||
const CLAUDE_SEED_REL: &str = ".claude/settings.local.json";
|
||||
const CODEX_CONFIG_REL: &str = ".codex/config.toml";
|
||||
|
||||
/// Seeds a live agent session + a persisted layout cell hosting it, so the swap
|
||||
/// reaches its relaunch step (kill → relaunch in the same cell).
|
||||
fn seed_live_for_relaunch(f: &Fixture, agent: &AgentId) {
|
||||
let host = nid(1);
|
||||
seed_live_agent_session(&f.sessions, *agent, host, sid(42));
|
||||
let tree = LayoutTree::single(agent_leaf(host, Some(*agent), Some(&pair_uuid()), true));
|
||||
seed_layouts(&f.fs, lid(1), &tree);
|
||||
}
|
||||
|
||||
/// A stable UUID-shaped pair id used by the relaunch scenarios.
|
||||
fn pair_uuid() -> String {
|
||||
Uuid::from_u128(0xABCD).to_string()
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// LP3-4 — cleanup of orphan permission files at a cross-profile swap
|
||||
// ===========================================================================
|
||||
|
||||
/// (1) **Claude→Codex (phare)**: the pre-existing `.claude/settings.local.json`
|
||||
/// is removed at the swap, and the relaunch projects the Codex sandbox config +
|
||||
/// args. The orphan Replace file of the old projector is cleaned up; the new
|
||||
/// projector (Codex) owns no Replace file, so nothing protects it from deletion.
|
||||
#[tokio::test]
|
||||
async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
let f = fixture_with_projection(
|
||||
&agent,
|
||||
vec![claude_profile(pid(1)), codex_profile(pid(2))],
|
||||
full_registry(),
|
||||
Some(allow_perm_doc()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let run_dir = run_dir_of(&agent.id);
|
||||
let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}");
|
||||
let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}");
|
||||
// The old Claude seed exists in the (stable) run dir before the swap.
|
||||
f.fs.put(&seed_path, b"{\"permissions\":{}}");
|
||||
|
||||
seed_live_for_relaunch(&f, &agent.id);
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds");
|
||||
|
||||
// The orphan Claude seed was deleted (recorded AND gone from the FS state).
|
||||
assert!(
|
||||
f.fs.removed().iter().any(|p| p == &seed_path),
|
||||
"the orphan .claude seed must be removed; removed={:?}",
|
||||
f.fs.removed()
|
||||
);
|
||||
assert!(!f.fs.has_file(&seed_path), "the seed is gone from the FS state");
|
||||
|
||||
// The relaunch projected the Codex sandbox config + args.
|
||||
assert!(
|
||||
f.fs.has_file(&codex_path),
|
||||
"the relaunch must project the Codex config"
|
||||
);
|
||||
let toml = String::from_utf8(f.fs.read_file(&codex_path).unwrap()).unwrap();
|
||||
assert!(toml.contains("sandbox_mode = \"workspace-write\""), "{toml}");
|
||||
assert!(
|
||||
f.pty.last_spawn_args().contains(&"--sandbox".to_owned()),
|
||||
"sandbox args folded into the relaunch spawn: {:?}",
|
||||
f.pty.last_spawn_args()
|
||||
);
|
||||
}
|
||||
|
||||
/// (2) **Claude→Claude**: `owned(old) − owned(new)` is empty, so the seed is NOT
|
||||
/// removed (it is re-clobbered by the relaunch, never deleted).
|
||||
#[tokio::test]
|
||||
async fn swap_claude_to_claude_does_not_remove_seed() {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
let f = fixture_with_projection(
|
||||
&agent,
|
||||
vec![claude_profile(pid(1)), claude_profile(pid(2))],
|
||||
full_registry(),
|
||||
Some(allow_perm_doc()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
|
||||
f.fs.put(&seed_path, b"{\"permissions\":{}}");
|
||||
|
||||
seed_live_for_relaunch(&f, &agent.id);
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds");
|
||||
|
||||
assert!(
|
||||
!f.fs.removed().iter().any(|p| p == &seed_path),
|
||||
"same-family swap must NOT delete the shared seed; removed={:?}",
|
||||
f.fs.removed()
|
||||
);
|
||||
// It is still present (re-clobbered by the relaunch's projection).
|
||||
assert!(f.fs.has_file(&seed_path), "the seed survives the same-family swap");
|
||||
}
|
||||
|
||||
/// (3) **Codex→Claude**: Codex owns no Replace file ⇒ nothing is removed on the
|
||||
/// cleanup side; the relaunch writes the Claude seed; the co-owned
|
||||
/// `.codex/config.toml` is never deleted.
|
||||
#[tokio::test]
|
||||
async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
let f = fixture_with_projection(
|
||||
&agent,
|
||||
vec![codex_profile(pid(1)), claude_profile(pid(2))],
|
||||
full_registry(),
|
||||
Some(allow_perm_doc()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let run_dir = run_dir_of(&agent.id);
|
||||
let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}");
|
||||
let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}");
|
||||
// A pre-existing co-owned Codex config that must survive the swap.
|
||||
f.fs.put(&codex_path, b"sandbox_mode = \"read-only\"\n");
|
||||
|
||||
seed_live_for_relaunch(&f, &agent.id);
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds");
|
||||
|
||||
// Nothing removed (Codex has no Replace-owned path).
|
||||
assert!(
|
||||
f.fs.removed().is_empty(),
|
||||
"Codex→Claude removes no permission file; removed={:?}",
|
||||
f.fs.removed()
|
||||
);
|
||||
// The co-owned Codex config is untouched by cleanup…
|
||||
assert!(f.fs.has_file(&codex_path), "the .codex/config.toml is never deleted");
|
||||
assert!(
|
||||
!f.fs.removed().iter().any(|p| p == &codex_path),
|
||||
"the .codex/config.toml is not in the removed list"
|
||||
);
|
||||
// …and the relaunch projected the new Claude seed.
|
||||
assert!(f.fs.has_file(&seed_path), "the relaunch writes the Claude seed");
|
||||
}
|
||||
|
||||
/// (4a) **No-op (no registry)**: without a projector registry wired on the swap,
|
||||
/// no cleanup runs at all — even on a Claude→Codex swap with the seed present.
|
||||
#[tokio::test]
|
||||
async fn swap_without_registry_removes_nothing() {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
// The default fixture wires NO projector registry on the swap.
|
||||
let f = fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await;
|
||||
|
||||
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
|
||||
f.fs.put(&seed_path, b"{\"permissions\":{}}");
|
||||
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds");
|
||||
|
||||
assert!(
|
||||
f.fs.removed().is_empty(),
|
||||
"no registry ⇒ no cleanup; removed={:?}",
|
||||
f.fs.removed()
|
||||
);
|
||||
assert!(f.fs.has_file(&seed_path), "the seed is left untouched");
|
||||
}
|
||||
|
||||
/// (4b) **No-op (previous profile deleted)**: if the old profile id is no longer
|
||||
/// in the store, the cleanup is skipped (the old projector can't be resolved) and
|
||||
/// the swap still succeeds.
|
||||
#[tokio::test]
|
||||
async fn swap_with_unknown_previous_profile_skips_cleanup() {
|
||||
// Agent records pid(1), but the store only knows pid(2) (the target) and pid(3):
|
||||
// pid(1) was deleted between launch and swap.
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
let f = fixture_with_projection(
|
||||
&agent,
|
||||
vec![codex_profile(pid(2)), claude_profile(pid(3))],
|
||||
full_registry(),
|
||||
Some(allow_perm_doc()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
|
||||
f.fs.put(&seed_path, b"{\"permissions\":{}}");
|
||||
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds even with an unknown previous profile");
|
||||
|
||||
assert!(
|
||||
f.fs.removed().is_empty(),
|
||||
"unknown previous profile ⇒ cleanup skipped; removed={:?}",
|
||||
f.fs.removed()
|
||||
);
|
||||
}
|
||||
|
||||
/// (5) **Best-effort**: when the orphan seed file does NOT exist on disk, the
|
||||
/// delete is still attempted (idempotent) and the swap succeeds without error.
|
||||
#[tokio::test]
|
||||
async fn swap_claude_to_codex_succeeds_when_seed_absent() {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
let f = fixture_with_projection(
|
||||
&agent,
|
||||
vec![claude_profile(pid(1)), codex_profile(pid(2))],
|
||||
full_registry(),
|
||||
Some(allow_perm_doc()),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Intentionally do NOT seed `.claude/settings.local.json`.
|
||||
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
|
||||
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds even when the orphan file is already gone");
|
||||
|
||||
// The delete was attempted (best-effort, idempotent) on the missing path.
|
||||
assert!(
|
||||
f.fs.removed().iter().any(|p| p == &seed_path),
|
||||
"a remove was still attempted on the absent seed; removed={:?}",
|
||||
f.fs.removed()
|
||||
);
|
||||
}
|
||||
|
||||
/// (6) **Non-regression P8d**: the cleanup must not disturb the preserved pair id.
|
||||
/// A live Claude→Codex swap with a handoff seeded under the leaf's pair id still
|
||||
/// re-injects that handoff into the new engine's convention file (proving the
|
||||
/// pair `conversation_id` survived the cleanup step unchanged).
|
||||
#[tokio::test]
|
||||
async fn swap_with_cleanup_preserves_pair_id_and_handoff() {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
let f = fixture_with_projection(
|
||||
&agent,
|
||||
vec![claude_profile(pid(1)), codex_profile(pid(2))],
|
||||
full_registry(),
|
||||
Some(allow_perm_doc()),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Seed the orphan Claude seed (so cleanup actually fires) and a handoff under
|
||||
// the leaf's pair id.
|
||||
let run_dir = run_dir_of(&agent.id);
|
||||
f.fs.put(&format!("{run_dir}/{CLAUDE_SEED_REL}"), b"{}");
|
||||
let pair = pair_uuid();
|
||||
f.handoffs.seed(&pair, "État au dernier tour : LP3-4.", Some("objectif"));
|
||||
|
||||
let host = nid(1);
|
||||
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
|
||||
let tree = LayoutTree::single(agent_leaf(host, Some(agent.id), Some(&pair), true));
|
||||
seed_layouts(&f.fs, lid(1), &tree);
|
||||
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds");
|
||||
|
||||
// The cleanup fired…
|
||||
assert!(
|
||||
f.fs.removed()
|
||||
.iter()
|
||||
.any(|p| p == &format!("{run_dir}/{CLAUDE_SEED_REL}")),
|
||||
"the orphan seed was cleaned up"
|
||||
);
|
||||
// …and the pair id was preserved on the persisted leaf (P8d invariant).
|
||||
let (conv, running) = leaf_state(&f.fs, host).expect("leaf persisted");
|
||||
assert_eq!(conv.as_deref(), Some(pair.as_str()), "pair id preserved");
|
||||
assert!(!running, "engine running flag reset by invalidate_engine_link");
|
||||
|
||||
// …and the handoff (keyed by that pair id) was re-injected into the new engine's
|
||||
// convention file — proving the relaunch threaded the preserved pair id. (The
|
||||
// shared FakeRuntime always materialises the convention file as `CLAUDE.md`.)
|
||||
let conv_md = String::from_utf8(
|
||||
f.fs.read_file(&format!("{run_dir}/CLAUDE.md"))
|
||||
.expect("the relaunch wrote the new engine's convention file"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
conv_md.contains("LP3-4"),
|
||||
"handoff re-injected under the preserved pair id: {conv_md}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
128
crates/application/tests/permission_usecases.rs
Normal file
128
crates/application/tests/permission_usecases.rs
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
1315
crates/domain/src/permission.rs
Normal file
1315
crates/domain/src/permission.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -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]
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,9 +699,6 @@ impl InputMediator for MediatedInbox {
|
||||
busy: true,
|
||||
});
|
||||
let submit = self.submit().get(&agent).cloned().unwrap_or_default();
|
||||
events.publish(DomainEvent::DelegationReady {
|
||||
agent_id: agent,
|
||||
ticket: ticket_id,
|
||||
// Préfixe de délégation : c'est le **signal** qui dit à la cible
|
||||
// « ceci est une tâche IdeA, réponds via `idea_reply` (jamais en
|
||||
// texte) en renvoyant ce `ticket` ». Sans lui la cible traite la
|
||||
@ -487,10 +707,24 @@ impl InputMediator for MediatedInbox {
|
||||
// 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),
|
||||
let text = delegation_preamble(&ticket.requester, ticket_id, &ticket.task);
|
||||
// **Fix race cold-launch** : si l'agent est en démarrage à froid (inscrit
|
||||
// par `mark_starting` quand un watcher est armé), ON DIFFÈRE la
|
||||
// `DelegationReady` jusqu'au prompt-ready (le CLI n'a pas encore affiché
|
||||
// son prompt — la livrer maintenant perdrait le premier tour). Le watcher
|
||||
// la publiera via `prompt_ready`. Agent chaud (pas dans `starting`) ⇒
|
||||
// publication immédiate, comportement inchangé.
|
||||
let deferred = DeferredDelegation {
|
||||
ticket: ticket_id,
|
||||
text,
|
||||
submit_sequence: submit.sequence,
|
||||
submit_delay_ms: submit.delay_ms,
|
||||
});
|
||||
};
|
||||
if self.tracker.lock_starting().contains(&agent) {
|
||||
self.tracker.lock_deferred().insert(agent, deferred);
|
||||
} else {
|
||||
self.tracker.publish_deferred(agent, deferred);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mailbox.enqueue(agent, ticket)
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,21 +165,78 @@ 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;
|
||||
};
|
||||
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.
|
||||
|
||||
362
crates/infrastructure/src/permission/claude.rs
Normal file
362
crates/infrastructure/src/permission/claude.rs
Normal 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()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
190
crates/infrastructure/src/permission/codex.rs
Normal file
190
crates/infrastructure/src/permission/codex.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
crates/infrastructure/src/permission/mod.rs
Normal file
46
crates/infrastructure/src/permission/mod.rs
Normal 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))
|
||||
}
|
||||
@ -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;
|
||||
|
||||
65
crates/infrastructure/src/store/permission.rs
Normal file
65
crates/infrastructure/src/store/permission.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
|
||||
86
crates/infrastructure/tests/permission_store.rs
Normal file
86
crates/infrastructure/tests/permission_store.rs
Normal 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");
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
43
frontend/src/adapters/permission.ts
Normal file
43
frontend/src/adapters/permission.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
381
frontend/src/features/permissions/PermissionsPanel.tsx
Normal file
381
frontend/src/features/permissions/PermissionsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/features/permissions/index.ts
Normal file
1
frontend/src/features/permissions/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./PermissionsPanel";
|
||||
89
frontend/src/features/permissions/permissions.test.tsx
Normal file
89
frontend/src/features/permissions/permissions.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
236
frontend/src/features/permissions/usePermissions.ts
Normal file
236
frontend/src/features/permissions/usePermissions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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(() => {});
|
||||
},
|
||||
}),
|
||||
[],
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user