From d0b65a92bd76fe56045b877406dc5a2da0d4d55e Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 29 Jul 2026 22:51:38 +0200 Subject: [PATCH] =?UTF-8?q?fix(#108):=20restreint=20Code=20Mode=20Codex=20?= =?UTF-8?q?au=20namespace=20MCP=20IdeA=20et=20documente=20les=20tools=20au?= =?UTF-8?q?=20d=C3=A9marrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex Code Mode pouvait appeler n'importe quel outil MCP directement, contournant la médiation d'approbation. On force [features.code_mode].direct_only_tool_namespaces = ["mcp__idea"] sur chaque surface qui écrit le config.toml Codex (permission projector, lifecycle, migration run-dir, assistant de ticket), et on ajoute initialize.instructions côté serveur MCP pour orienter Codex vers le bon outil idea_* dès la connexion, sans dépendre de la recherche sémantique différée. QA : domain 283/0, application 126/0 + agent_lifecycle 73/0 + change_agent_profile 19/0 + ticket_assistant 5/0, infrastructure 339/0 dont mcp_server 37/0, backend 68/0 (7 ignored). Les échecs web-server observés sur cargo test --workspace (Too many open files, cookies) sont une contamination de ressources inter-tests ; les deux tests concernés repassent isolément. Co-Authored-By: Claude Opus 4.8 --- crates/application/src/agent/lifecycle.rs | 10 ++- crates/application/tests/agent_lifecycle.rs | 41 ++++++++++-- .../application/tests/change_agent_profile.rs | 13 +++- crates/backend/src/lib.rs | 20 ++++++ crates/domain/src/profile.rs | 15 ++++- crates/infrastructure/src/assistant/mod.rs | 64 ++++++++++++++++++- .../src/orchestrator/mcp/server.rs | 7 +- crates/infrastructure/src/permission/codex.rs | 61 ++++++++++++------ crates/infrastructure/tests/mcp_server.rs | 29 +++++++++ 9 files changed, 228 insertions(+), 32 deletions(-) diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 0cd6da2..9f8b474 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -20,7 +20,10 @@ use domain::ports::{ ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore, SpawnSpec, StoreError, StructuredProviderLaunchPolicy, SystemPermissionStore, }; -use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter}; +use domain::profile::{ + McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE, + CODEX_CODE_MODE_FEATURES_TOML, +}; use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan}; use domain::{ bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, @@ -3118,6 +3121,11 @@ fn codex_config_toml( text = set_top_level_toml_value(&text, "model", model); } text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end()); + text = replace_toml_table( + &text, + CODEX_CODE_MODE_FEATURES_TABLE, + CODEX_CODE_MODE_FEATURES_TOML.trim_end(), + ); text = ensure_codex_trust(&text, run_dir); text = ensure_codex_trust(&text, project_root); if !text.ends_with('\n') { diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 1a916b6..2e4c08b 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -3089,6 +3089,16 @@ impl PermissionProjector for FakeClaudeProjector { struct FakeCodexProjector; impl FakeCodexProjector { + const CODE_MODE_TOML: &'static str = + "[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]\n"; + + fn managed_tables() -> Vec { + vec![ + "sandbox_workspace_write".to_owned(), + "features.code_mode".to_owned(), + ] + } + fn modes(fallback: Posture) -> (&'static str, &'static str) { match fallback { Posture::Deny => ("read-only", "on-request"), @@ -3121,9 +3131,12 @@ impl PermissionProjector for FakeCodexProjector { let network_access = matches!(network, Some(NetworkPolicy::Allow)); let network_file = ProjectedFile::MergeToml { rel_path: ".codex/config.toml".to_owned(), - managed_tables: vec!["sandbox_workspace_write".to_owned()], + managed_tables: Self::managed_tables(), managed_keys: Vec::new(), - contents: format!("[sandbox_workspace_write]\nnetwork_access = {network_access}\n"), + contents: format!( + "[sandbox_workspace_write]\nnetwork_access = {network_access}\n\n{}", + Self::CODE_MODE_TOML + ), }; let Some(eff) = eff else { return PermissionProjection { @@ -3134,7 +3147,8 @@ impl PermissionProjector for FakeCodexProjector { }; let (sandbox, approval) = Self::modes(eff.fallback()); let contents = format!( - "sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n\n[sandbox_workspace_write]\nnetwork_access = {network_access}\n" + "sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n\n[sandbox_workspace_write]\nnetwork_access = {network_access}\n\n{}", + Self::CODE_MODE_TOML ); let mut args = vec![ "--sandbox".to_owned(), @@ -3149,7 +3163,7 @@ impl PermissionProjector for FakeCodexProjector { PermissionProjection { files: vec![ProjectedFile::MergeToml { rel_path: ".codex/config.toml".to_owned(), - managed_tables: vec!["sandbox_workspace_write".to_owned()], + managed_tables: Self::managed_tables(), managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()], contents, }], @@ -3665,7 +3679,7 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() { // 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", + "user_key = \"keep-me\"\n[mcp_servers.idea]\ncommand = \"idea\"\n\n[features.preview]\nenabled = true\n", ); // First projection. @@ -3689,6 +3703,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() { first.contains("[mcp_servers.idea]"), "unmanaged table preserved: {first}" ); + assert!( + first.contains("[features.preview]\nenabled = true"), + "unmanaged sibling features table preserved: {first}" + ); assert!( first.contains("sandbox_mode = \"workspace-write\""), "managed sandbox_mode upserted: {first}" @@ -3697,6 +3715,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() { first.contains("approval_policy = \"never\""), "managed approval_policy upserted: {first}" ); + assert!( + first.contains("[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]"), + "managed Code Mode table upserted: {first}" + ); // Second projection (relaunch): managed keys are replaced in place, not dup'd. sessions.remove(&sid(777)); @@ -3722,10 +3744,19 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() { 1, "idempotent: no duplicate approval_policy: {second}" ); + assert_eq!( + second.matches("[features.code_mode]").count(), + 1, + "idempotent: no duplicate code mode feature table: {second}" + ); assert!( second.contains("user_key = \"keep-me\""), "unmanaged key still preserved" ); + assert!( + second.contains("[features.preview]\nenabled = true"), + "unmanaged sibling features table still preserved: {second}" + ); } // ---- (4) args/env fold into the spawned spec -------------------------------- diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs index 905a5b2..06857af 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -874,6 +874,11 @@ impl PermissionProjector for FakeClaudeProjector { /// `--sandbox`/`--ask-for-approval` args. Owns **no** `Replace` file. struct FakeCodexProjector; +impl FakeCodexProjector { + const CODE_MODE_TOML: &'static str = + "[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]\n"; +} + impl PermissionProjector for FakeCodexProjector { fn key(&self) -> ProjectorKey { ProjectorKey::Codex @@ -890,10 +895,12 @@ impl PermissionProjector for FakeCodexProjector { PermissionProjection { files: vec![ProjectedFile::MergeToml { rel_path: ".codex/config.toml".to_owned(), - managed_tables: Vec::new(), + managed_tables: vec!["features.code_mode".to_owned()], managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()], - contents: "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n" - .to_owned(), + contents: format!( + "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n\n{}", + Self::CODE_MODE_TOML + ), }], args: vec![ "--sandbox".to_owned(), diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 086e991..485f6d4 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -69,6 +69,7 @@ use domain::ports::{ }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, + CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML, }; use domain::remote::RemoteKind; use domain::{ @@ -3450,6 +3451,11 @@ fn codex_config_toml_for_migration( "mcp_servers.idea", mcp_declaration.trim_end(), ); + text = replace_toml_table_block( + &text, + CODEX_CODE_MODE_FEATURES_TABLE, + CODEX_CODE_MODE_FEATURES_TOML.trim_end(), + ); text = ensure_codex_project_trust(&text, run_dir); text = ensure_codex_project_trust(&text, project_root); if !text.ends_with('\n') { @@ -3944,11 +3950,18 @@ mod run_dir_migration_tests { run_dir.join(".codex/config.toml"), r#"approval_policy = "never" sandbox_mode = "workspace-write" +user_key = "keep-me" [mcp_servers.idea] command = "stale" args = ["mcp-server"] transport = "stdio" + +[features.code_mode] +direct_only_tool_namespaces = ["old"] + +[features.preview] +enabled = true "#, ) .unwrap(); @@ -3975,10 +3988,17 @@ transport = "stdio" let config = std::fs::read_to_string(run_dir.join(".codex/config.toml")).unwrap(); assert!(config.contains(r#"approval_policy = "never""#)); assert!(config.contains(r#"sandbox_mode = "workspace-write""#)); + assert!(config.contains(r#"user_key = "keep-me""#)); assert!(config.contains("[mcp_servers.idea]")); assert!(config.contains(r#"default_tools_approval_mode = "approve""#)); assert!(config.contains("tool_timeout_sec = 86400")); + assert!( + config.contains("[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]") + ); + assert_eq!(config.matches("[features.code_mode]").count(), 1); assert!(!config.contains(r#"command = "stale""#)); + assert!(!config.contains(r#"direct_only_tool_namespaces = ["old"]"#)); + assert!(config.contains("[features.preview]\nenabled = true")); assert!(config.contains(&format!(r#"[projects."{}"]"#, run_dir.to_string_lossy()))); assert!(config.contains(&format!(r#"[projects."{}"]"#, project.root.as_str()))); diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index d40afa3..74e92bd 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -10,6 +10,15 @@ use crate::error::DomainError; use crate::ids::{LocalModelServerId, ProfileId}; use crate::permission::ProjectorKey; +/// Codex feature table managed by IdeA to restrict Code Mode direct tool calls +/// to the local IdeA MCP namespace. +pub const CODEX_CODE_MODE_FEATURES_TABLE: &str = "features.code_mode"; + +/// TOML block injected in Codex `config.toml` so Code Mode can call IdeA MCP +/// tools directly while every other namespace stays mediated by Codex. +pub const CODEX_CODE_MODE_FEATURES_TOML: &str = + "[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]\n"; + /// Strategy for injecting an agent's `.md` context into the launched CLI. /// /// Invariants: @@ -803,7 +812,7 @@ impl McpServerWiring { let transport = self.transport_label(); let tool_timeout_sec = Self::IDEA_TOOL_TIMEOUT_SEC; format!( - "[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\ndefault_tools_approval_mode = \"approve\"\ntool_timeout_sec = {tool_timeout_sec}\n" + "[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\ndefault_tools_approval_mode = \"approve\"\ntool_timeout_sec = {tool_timeout_sec}\n\n{CODEX_CODE_MODE_FEATURES_TOML}" ) } } @@ -2129,6 +2138,10 @@ mod mcp_tests { toml.contains("tool_timeout_sec = 86400"), "IdeA MCP tools must outlive Codex's short default tool timeout; got: {toml}" ); + assert!( + toml.contains("[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]"), + "Code Mode direct tools must be restricted to IdeA MCP; got: {toml}" + ); } // -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------ diff --git a/crates/infrastructure/src/assistant/mod.rs b/crates/infrastructure/src/assistant/mod.rs index a6dd61b..3ce03e4 100644 --- a/crates/infrastructure/src/assistant/mod.rs +++ b/crates/infrastructure/src/assistant/mod.rs @@ -5,7 +5,10 @@ use std::sync::Arc; use application::McpRuntime; use async_trait::async_trait; use domain::ports::{SecretStore, SessionPlan}; -use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter}; +use domain::profile::{ + McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter, CODEX_CODE_MODE_FEATURES_TABLE, + CODEX_CODE_MODE_FEATURES_TOML, +}; use domain::{ AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider, ContextInjectionPlan, EffectivePermissions, FileSystem, FsError, Issue, IssueRef, MarkdownDoc, @@ -522,6 +525,11 @@ fn codex_config_toml( text = set_top_level_toml_value(&text, "model", model); } text = replace_toml_table_block(&text, "mcp_servers.idea", mcp_declaration.trim_end()); + text = replace_toml_table_block( + &text, + CODEX_CODE_MODE_FEATURES_TABLE, + CODEX_CODE_MODE_FEATURES_TOML.trim_end(), + ); text = ensure_codex_project_trust(&text, run_dir); text = ensure_codex_project_trust(&text, project_root); if !text.ends_with('\n') { @@ -645,6 +653,60 @@ fn parent_dir(base: &ProjectPath, rel: &str) -> String { } } +#[cfg(test)] +mod codex_config_toml_tests { + use domain::profile::McpTransport; + + use super::*; + + #[test] + fn assistant_codex_config_replaces_code_mode_and_preserves_user_content() { + let declaration = McpServerWiring::new( + "idea".to_owned(), + vec!["mcp-server".to_owned()], + McpTransport::Stdio, + ) + .to_config_toml(); + let existing = "user_key = \"keep\"\n\n[features.code_mode]\ndirect_only_tool_namespaces = [\"old\"]\n\n[features.preview]\nenabled = true\n\n[user.table]\nvalue = 1\n"; + + let rendered = codex_config_toml( + Some(existing), + &declaration, + "/run/assistant", + "/proj", + None, + ); + + assert!( + rendered.contains("user_key = \"keep\""), + "unmanaged top-level key preserved: {rendered}" + ); + assert!( + rendered.contains("[user.table]\nvalue = 1"), + "unmanaged table preserved: {rendered}" + ); + assert!( + rendered.contains("[features.preview]\nenabled = true"), + "unmanaged sibling features table preserved: {rendered}" + ); + assert!( + rendered + .contains("[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]"), + "assistant Codex config should inject direct-only IdeA MCP namespace: {rendered}" + ); + assert_eq!( + rendered.matches("[features.code_mode]").count(), + 1, + "assistant Codex config should replace, not duplicate, code mode table: {rendered}" + ); + assert!( + rendered.contains("[projects.\"/run/assistant\"]") + && rendered.contains("[projects.\"/proj\"]"), + "trust entries should still be generated: {rendered}" + ); + } +} + #[cfg(test)] mod opencode_provider_config_json_tests { use domain::ports::SecretRef; diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index d1038af..5e245fa 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -42,6 +42,10 @@ use super::tools::{self, ToolMapError}; /// The MCP protocol version this server speaks (advertised on `initialize`). const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; +/// Compact MCP instructions advertised at initialization. The first 512 chars are +/// intentionally self-contained because some clients truncate this field. +const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*. Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning."; + /// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`]. /// /// Cheap to clone the dependencies it holds; one instance serves one project's @@ -340,7 +344,8 @@ impl McpServer { json!({ "protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": { "tools": {} }, - "serverInfo": { "name": "idea-orchestrator", "version": env!("CARGO_PKG_VERSION") } + "serverInfo": { "name": "idea-orchestrator", "version": env!("CARGO_PKG_VERSION") }, + "instructions": MCP_INSTRUCTIONS }) } diff --git a/crates/infrastructure/src/permission/codex.rs b/crates/infrastructure/src/permission/codex.rs index c51ee96..8e4d9c4 100644 --- a/crates/infrastructure/src/permission/codex.rs +++ b/crates/infrastructure/src/permission/codex.rs @@ -19,6 +19,7 @@ use domain::permission::{ EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile, ProjectionContext, ProjectorKey, }; +use domain::profile::{CODEX_CODE_MODE_FEATURES_TABLE, CODEX_CODE_MODE_FEATURES_TOML}; use domain::NetworkPolicy; use super::toml_string; @@ -70,13 +71,15 @@ impl PermissionProjector for CodexPermissionProjector { // `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a // permission concern and stay with the MCP wiring (LP3-3). let mut contents = format!( - "sandbox_mode = {}\napproval_policy = {}\n\n[{}]\nnetwork_access = {}\n", + "sandbox_mode = {}\napproval_policy = {}\n", toml_string(sandbox), toml_string(approval), - SANDBOX_WORKSPACE_WRITE_TABLE, - network_access, ); append_codex_model_config(&mut contents, ctx); + contents.push_str(&format!( + "\n[{}]\nnetwork_access = {}\n\n{}", + SANDBOX_WORKSPACE_WRITE_TABLE, network_access, CODEX_CODE_MODE_FEATURES_TOML, + )); let mut args = vec![ "--sandbox".to_owned(), @@ -111,12 +114,17 @@ fn codex_model_and_network_file( ctx: &ProjectionContext, network: Option, ) -> ProjectedFile { - let mut contents = format!( - "[{}]\nnetwork_access = {}\n", + let mut contents = String::new(); + append_codex_model_config(&mut contents, ctx); + if !contents.is_empty() { + contents.push('\n'); + } + contents.push_str(&format!( + "[{}]\nnetwork_access = {}\n\n{}", SANDBOX_WORKSPACE_WRITE_TABLE, codex_network_access(network), - ); - append_codex_model_config(&mut contents, ctx); + CODEX_CODE_MODE_FEATURES_TOML, + )); ProjectedFile::MergeToml { rel_path: CONFIG_REL_PATH.to_owned(), managed_tables: codex_managed_tables(ctx), @@ -137,7 +145,10 @@ fn codex_managed_keys(ctx: &ProjectionContext, include_permissions: bool) -> Vec } fn codex_managed_tables(_ctx: &ProjectionContext) -> Vec { - vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()] + vec![ + SANDBOX_WORKSPACE_WRITE_TABLE.to_owned(), + CODEX_CODE_MODE_FEATURES_TABLE.to_owned(), + ] } fn append_codex_model_config(contents: &mut String, ctx: &ProjectionContext) { @@ -198,6 +209,13 @@ mod tests { resolve(Some(&PermissionSet::new(vec![], fallback)), None).unwrap() } + fn expected_managed_tables() -> Vec { + vec![ + SANDBOX_WORKSPACE_WRITE_TABLE.to_owned(), + CODEX_CODE_MODE_FEATURES_TABLE.to_owned(), + ] + } + // ---- product invariant + ownership ---------------------------------- #[test] @@ -213,15 +231,18 @@ mod tests { contents, } => { assert_eq!(rel_path, CONFIG_REL_PATH); - assert_eq!( - managed_tables, - &vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()] - ); + assert_eq!(managed_tables, &expected_managed_tables()); assert!(managed_keys.is_empty()); assert!( contents.contains("[sandbox_workspace_write]\nnetwork_access = false"), "network is denied by default: {contents:?}" ); + assert!( + contents.contains( + "[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]" + ), + "Code Mode direct tools must be restricted to IdeA MCP: {contents:?}" + ); } ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"), } @@ -255,10 +276,7 @@ mod tests { } => { assert!(managed_keys.contains(&"model".to_owned())); assert!(!managed_keys.contains(&"model_provider".to_owned())); - assert_eq!( - managed_tables, - &vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()] - ); + assert_eq!(managed_tables, &expected_managed_tables()); assert!(contents.contains("model = \"gpt-5\""), "{contents}"); assert!(!contents.contains("model_provider"), "{contents}"); assert!(!contents.contains("model_providers"), "{contents}"); @@ -296,10 +314,7 @@ mod tests { contents, } => { assert_eq!(rel_path, CONFIG_REL_PATH); - assert_eq!( - managed_tables, - &vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()] - ); + assert_eq!(managed_tables, &expected_managed_tables()); assert_eq!( managed_keys, &vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()] @@ -316,6 +331,12 @@ mod tests { contents.contains("[sandbox_workspace_write]\nnetwork_access = false"), "network is denied by default: {contents:?}" ); + assert!( + contents.contains( + "[features.code_mode]\ndirect_only_tool_namespaces = [\"mcp__idea\"]" + ), + "Code Mode direct tools must be restricted to IdeA MCP: {contents:?}" + ); } ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"), } diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index fd2100c..a30dd30 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -1887,6 +1887,35 @@ async fn initialize_answers_minimal_handshake() { // Capability for tools is advertised, and the server identifies itself. assert!(result["capabilities"]["tools"].is_object()); assert_eq!(result["serverInfo"]["name"], json!("idea-orchestrator")); + let instructions = result["instructions"] + .as_str() + .expect("initialize should advertise compact instructions"); + let first_512 = instructions.chars().take(512).collect::(); + for needle in [ + "idea_ticket_*", + "idea_ask_agent", + "idea_ask_agents", + "idea_memory_*", + "idea_context_*", + ] { + assert!( + first_512.contains(needle), + "first 512 chars should route natural intent to {needle}; got {first_512:?}" + ); + } + for needle in [ + "idea_skill_*", + "idea_template_*", + "idea_workstate_*", + "background", + "agent lifecycle", + "sprint", + ] { + assert!( + instructions.contains(needle), + "instructions should cover {needle}; got {instructions:?}" + ); + } } /// Readiness de démarrage (fix race cold-launch, signal MCP) : un `initialize` reçu sur