@ -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 , Permission Projector , ProfileId ,
ProjectedFile , ProjectionContext , ProjectorKey , Project , ProjectPath , ProviderSessionStore ,
PtySize , SessionId , SessionKind , SessionStatus , Skill , TerminalSession ,
} ;
@ -367,6 +371,13 @@ pub struct ChangeAgentProfile {
/// lieu de tuer un PTY. Injecté au câblage via [`Self::with_structured`] ; `None`
/// ⇒ seul le registre PTY est consulté (mode legacy / tests existants).
structured : Option < Arc < StructuredSessions > > ,
/// Registre des **projecteurs de permissions** (lot LP3-4), pour nettoyer au swap
/// les fichiers `Replace` orphelins possédés par l'ANCIEN profil que le nouveau ne
/// réécrira pas. Injecté via [`Self::with_permission_projectors`] (le câblage passe
/// le **même** `Arc` que celui donné au `LaunchAgent` interne). `None` ⇒ aucun
/// nettoyage (comportement legacy ; la re-projection du nouveau profil, elle, reste
/// assurée par le `LaunchAgent` composé).
projectors : Option < Arc < PermissionProjectorRegistry > > ,
}
impl ChangeAgentProfile {
@ -393,6 +404,7 @@ impl ChangeAgentProfile {
launch ,
events ,
structured : None ,
projectors : None ,
}
}
@ -406,6 +418,20 @@ impl ChangeAgentProfile {
self
}
/// Branche le **registre de projecteurs de permissions** (lot LP3-4) pour le
/// nettoyage des fichiers orphelins au swap cross-profile. Le câblage passe le
/// **même** `Arc<PermissionProjectorRegistry>` que celui injecté au `LaunchAgent`
/// interne (source unique de vérité). Builder additif : signature de [`Self::new`]
/// inchangée ; `None` ⇒ pas de nettoyage (legacy).
#[ must_use ]
pub fn with_permission_projectors (
mut self ,
projectors : Arc < PermissionProjectorRegistry > ,
) -> Self {
self . projectors = Some ( projectors ) ;
self
}
/// Executes the hot-swap, following the 7-step algorithm of §15.1.
///
/// # Errors
@ -425,6 +451,10 @@ impl ChangeAgentProfile {
. find ( | e | e . agent_id = = input . agent_id )
. ok_or_else ( | | AppError ::NotFound ( format! ( " agent {} " , input . agent_id ) ) ) ? ;
// Capture the PREVIOUS profile id BEFORE mutation (lot LP3-4): it identifies
// the projector whose now-orphan `Replace` files must be cleaned up at the swap.
let previous_profile_id = entry . profile_id ;
// 2. Same profile ⇒ no-op: return the agent unchanged, no kill/relaunch,
// no event.
if entry . profile_id = = input . profile_id {
@ -438,15 +468,16 @@ impl ChangeAgentProfile {
}
// 3. Validate that the target profile is a known one (ProfileStore.list).
let known = self
. profiles
. list ( )
. await ?
. into_iter ( )
. any ( | p | p . id = = input . profile_id ) ;
if ! known {
// Capture both the NEW profile (validation) and the PREVIOUS profile (for
// the LP3-4 cleanup; absent if it was deleted ⇒ cleanup is skipped).
let profiles = self . profiles . list ( ) . await ? ;
let Some ( new_profile ) = profiles . iter ( ) . find ( | p | p . id = = input . profile_id ) . cloned ( ) else {
return Err ( AppError ::NotFound ( format! ( " profile {} " , input . profile_id ) ) ) ;
}
} ;
let previous_profile = profiles
. iter ( )
. find ( | p | p . id = = previous_profile_id )
. cloned ( ) ;
// 4. Mutate the entry (new profile), re-validate (to_agent + manifest)
// and persist.
@ -478,6 +509,20 @@ impl ChangeAgentProfile {
. invalidate_engine_link ( & input . project , & input . agent_id )
. await ? ;
// 5b. (lot LP3-4) Clean up the PREVIOUS profile's now-orphan permission files
// in the agent's (stable) run dir, BEFORE the relaunch re-projects the new
// profile — so we never delete a file the new profile is about to write.
// Only `Replace`-owned paths of the old projector that the new projector
// does NOT also own are removed (best-effort). `MergeToml` files are never
// touched. No-op without a projector registry.
self . cleanup_swapped_out_files (
& input . project . root ,
& input . agent_id ,
previous_profile . as_ref ( ) ,
& new_profile ,
)
. await ;
// 6. A live session? Kill its PTY then relaunch in the same cell with the
// new profile, carrying the **preserved** pair id (so the handoff is
// re-injected and resume routes via providers.json[new provider]).
@ -555,6 +600,66 @@ impl ChangeAgentProfile {
Ok ( pair_id )
}
/// Removes the **previous** profile's now-orphan permission files at the swap
/// (lot LP3-4), best-effort. Because the run dir is **stable per agent id**
/// (`agent_run_dir`), the old CLI's config (e.g. `.claude/settings.local.json`)
/// survives a swap in the very same directory; left in place it is stale.
///
/// Cleanup rule (Architect-validated): delete exactly
/// `owned_replace_paths(old) − owned_replace_paths(new)` — only the
/// **`Replace`-owned** files of the old projector that the new projector will not
/// re-own (and thus re-write). `MergeToml` (co-owned, e.g. `.codex/config.toml`)
/// files are **never** deleted. Examples: Claude→Codex removes
/// `.claude/settings.local.json`; Claude→Claude removes nothing (empty diff);
/// Codex→Claude removes nothing on the Replace side (Codex owns no Replace file).
///
/// No-op when: no registry is wired, the previous profile is unknown (deleted),
/// or it maps to no projector. Every delete is best-effort (`remove_file` is
/// idempotent), so a missing file never fails the swap. This does **not** touch
/// the stable pair id or the handoff (P8d) — it only removes engine config files.
async fn cleanup_swapped_out_files (
& self ,
project_root : & ProjectPath ,
agent_id : & AgentId ,
previous_profile : Option < & AgentProfile > ,
new_profile : & AgentProfile ,
) {
let Some ( registry ) = & self . projectors else {
return ;
} ;
let Some ( previous_profile ) = previous_profile else {
return ;
} ;
let Some ( old_key ) = select_projector_key ( previous_profile ) else {
return ;
} ;
let Some ( old_projector ) = registry . get ( old_key ) else {
return ;
} ;
let old_owned = old_projector . owned_replace_paths ( ) ;
if old_owned . is_empty ( ) {
return ;
}
// Paths the NEW projector will re-own (and re-write) ⇒ never delete these.
let kept : Vec < String > = select_projector_key ( new_profile )
. and_then ( | key | registry . get ( key ) )
. map ( | p | p . owned_replace_paths ( ) )
. unwrap_or_default ( ) ;
let run_dir = match agent_run_dir ( project_root , agent_id ) {
Ok ( dir ) = > dir ,
Err ( _ ) = > return ,
} ;
for rel in old_owned {
if kept . iter ( ) . any ( | k | k = = & rel ) {
continue ;
}
let path = RemotePath ::new ( join ( & run_dir , & rel ) ) ;
// Best-effort: a missing file / delete failure must never fail the swap.
let _ = self . fs . remove_file ( & path ) . await ;
}
}
/// Kills the agent's live PTY (if any) and relaunches it in the same cell with
/// the new profile (step 6), composing [`LaunchAgent::execute`]. Returns the
/// relaunched session, or `None` when the agent had no live session.
@ -832,6 +937,81 @@ pub struct LaunchAgentOutput {
pub structured : Option < StructuredSessionDescriptor > ,
}
/// Registry mapping each [`ProjectorKey`] to its concrete
/// [`PermissionProjector`] (lot LP3-3). Injected into [`LaunchAgent`] via the
/// optional builder [`LaunchAgent::with_permission_projectors`]; the concrete
/// projectors (Claude / Codex) live in `infrastructure` and are inserted at the
/// composition root (lot LP3-5). Absent registry ⇒ no projection (legacy
/// behaviour preserved).
#[ derive(Default, Clone) ]
pub struct PermissionProjectorRegistry {
by_key : HashMap < ProjectorKey , Arc < dyn PermissionProjector > > ,
}
impl PermissionProjectorRegistry {
/// An empty registry.
#[ must_use ]
pub fn new ( ) -> Self {
Self ::default ( )
}
/// Registers a projector under its own [`PermissionProjector::key`] and returns
/// `self` (builder style), so a registry can be assembled fluently.
#[ must_use ]
pub fn with ( mut self , projector : Arc < dyn PermissionProjector > ) -> Self {
self . insert ( projector ) ;
self
}
/// Registers (or replaces) a projector under its own key.
pub fn insert ( & mut self , projector : Arc < dyn PermissionProjector > ) {
self . by_key . insert ( projector . key ( ) , projector ) ;
}
/// Returns the projector registered for `key`, if any.
#[ must_use ]
pub fn get ( & self , key : ProjectorKey ) -> Option < & Arc < dyn PermissionProjector > > {
self . by_key . get ( & key )
}
}
/// Selects the [`ProjectorKey`] for a launched agent's profile (lot LP3-3).
///
/// Primary source: the profile's explicit `projector` field. **Migration
/// fallback** for legacy profiles (e.g. an old `profiles.json` written before the
/// field existed): the historical heuristic — a `CLAUDE.md` convention file ⇒
/// Claude; a `StructuredAdapter::Codex` or a `TomlConfigHome` MCP strategy ⇒
/// Codex. Anything else ⇒ `None` (no projection).
fn select_projector_key ( profile : & AgentProfile ) -> Option < ProjectorKey > {
if let Some ( key ) = profile . projector {
return Some ( key ) ;
}
// Legacy fallback: Claude is recognised by its `CLAUDE.md` convention file.
let is_claude = matches! (
& profile . context_injection ,
ContextInjection ::ConventionFile { target }
if target
. rsplit ( [ '/' , '\\' ] )
. next ( )
. unwrap_or ( target )
. eq_ignore_ascii_case ( " CLAUDE.md " )
) ;
if is_claude {
return Some ( ProjectorKey ::Claude ) ;
}
// Legacy fallback: Codex is recognised by its structured adapter or its
// CODEX_HOME-isolated TOML MCP strategy.
if matches! ( profile . structured_adapter , Some ( StructuredAdapter ::Codex ) ) {
return Some ( ProjectorKey ::Codex ) ;
}
if let Some ( mcp ) = & profile . mcp {
if matches! ( mcp . config , McpConfigStrategy ::TomlConfigHome { .. } ) {
return Some ( ProjectorKey ::Codex ) ;
}
}
None
}
/// Launches an agent: resolve profile + context, prepare the invocation, apply
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
///
@ -859,6 +1039,10 @@ pub struct LaunchAgent {
/// reads the project memory. `None` keeps the launcher independent of it (legacy
/// wiring / tests). A failure here never affects the launch.
embedder_suggestion : Option < Arc < crate ::embedder ::CheckEmbedderSuggestion > > ,
/// Optional permissions store (LP1). When absent, the launcher keeps its
/// historical hardcoded CLI permission seeds. When present and a project or
/// agent policy is configured, the resolved policy is projected to the CLI.
permissions : Option < Arc < dyn PermissionStore > > ,
/// Fabrique des sessions **structurées** (IA, §17). Injectée au câblage
/// (composition root) via [`Self::with_structured`]. `None` ⇒ le routage §17.4
/// est désactivé et **tout** profil suit le chemin PTY historique (mode legacy /
@ -882,6 +1066,10 @@ pub struct LaunchAgent {
/// régression pour les call sites/tests legacy). Une écriture en échec ⇒ lancement
/// normal, jamais d'échec.
provider_sessions : Option < Arc < dyn ProviderSessionProvider > > ,
/// Registre des **projecteurs de permissions** par CLI (lot LP3-3). Injecté au
/// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection
/// (comportement historique préservé pour les call sites/tests legacy).
projectors : Option < Arc < PermissionProjectorRegistry > > ,
}
impl LaunchAgent {
@ -913,13 +1101,36 @@ impl LaunchAgent {
ids ,
recall ,
embedder_suggestion ,
permissions : None ,
session_factory : None ,
structured : None ,
handoffs : None ,
provider_sessions : None ,
projectors : None ,
}
}
/// Injects the per-CLI permission **projector registry** (lot LP3-3) used to
/// translate the resolved [`EffectivePermissions`] into the launched CLI's
/// concrete permission config. Without this call (legacy call sites / tests),
/// no projection happens — signature of [`Self::new`] unchanged.
#[ must_use ]
pub fn with_permission_projectors (
mut self ,
projectors : Arc < PermissionProjectorRegistry > ,
) -> Self {
self . projectors = Some ( projectors ) ;
self
}
/// Injects the project permission store used to resolve effective agent
/// permissions at launch time.
#[ must_use ]
pub fn with_permission_store ( mut self , permissions : Arc < dyn PermissionStore > ) -> Self {
self . permissions = Some ( permissions ) ;
self
}
/// Branche le provider de **store de sessions provider (lot P8b)** sur ce launcher :
/// après un lancement structuré exposant un id de session moteur, range
/// `(pair_conversation_id, provider_key) → engine_session_id` dans `providers.json`
@ -1205,16 +1416,14 @@ impl LaunchAgent {
. create_dir_all ( & RemotePath ::new ( run_dir . as_str ( ) . to_owned ( ) ) )
. await ? ;
// 3b. Seed the CLI's permission config in the run dir so the agent runs
// with the project's full autonomy and never blocks on per-command
// permission prompts. The agent's cwd is the run dir, so the CLI
// writes/reads its permission file there; without a seed, the CLI
// accumulates narrow per-command approvals and keeps prompting.
// Pragmatic per-CLI seed pending the universal `.ideai/permissions.json`
// + OS-sandbox model. Non-clobbering and best-effort.
self . seed_cli_permissions ( & profile , & run_dir , & input . project . root )
let effective_permissions = self
. resolve_effective_permissions ( & input . project , agent . id )
. await ? ;
// 3b. (Permission projection moved to step 5c, after the convention file and
// the MCP config, so both the structured and PTY paths inherit it — see
// `apply_permission_projection`.)
// 4. Prepare the invocation (pure): command + args + injection plan + cwd.
// The run dir is passed as the cwd base; the profile's `{agentRunDir}`
// placeholder resolves against it.
@ -1277,8 +1486,29 @@ impl LaunchAgent {
// IdeA matérialise SA config MCP au format de CETTE CLI, dans le **même**
// run dir isolé que le convention file et le seed de permissions. `None`
// ⇒ aucun write/flag/env (chemin actuel inchangé, zéro régression).
self . apply_mcp_config ( & profile , & run_dir , input . mcp_runtime . as_ref ( ) , & mut spec )
. await ;
self . apply_mcp_config (
& profile ,
& run_dir ,
& input . project . root ,
input . mcp_runtime . as_ref ( ) ,
& mut spec ,
)
. await ;
// 5c. ── PROJECTION DES PERMISSIONS (lot LP3-3) ──
// Strictement APRÈS le convention file (5) ET la conf MCP (5a), donc
// AVANT le split structuré/PTY (5b) et le spawn : les DEUX chemins
// héritent de la projection (fichiers du plan écrits dans le run dir,
// `args`/`env` foldés dans `spec`). `None` registre / profil non
// projetable / `eff == None` ⇒ no-op (prompting natif conservé).
self . apply_permission_projection (
& profile ,
& run_dir ,
& input . project . root ,
effective_permissions . as_ref ( ) ,
& mut spec ,
)
. await ? ;
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le
@ -1575,53 +1805,114 @@ impl LaunchAgent {
}
}
/// Seeds the agent's run dir with the CLI permission config matching it s
/// 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 . f s. 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 . arg s. 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} \n trust_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] \n command = \" 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] \n command = \" new \" " ) ) ;
assert! ( rendered . contains ( " [mcp_servers.other] \n command = \" 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] \n command = \" 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 \" \n approval_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] \n command = \" idea-mcp \" " ) ) ;
}
}