//! ToolInvoker app-tauri pour l'adapter OpenAI-compatible. //! //! C'est la porte locale qui donne aux modèles HTTP la même surface `idea_*` que //! le serveur MCP : même catalogue, même mapping en `OrchestratorCommand`, même //! `OrchestratorService::dispatch`. use std::str::FromStr; use std::sync::{Arc, Mutex}; use application::OrchestratorService; use async_trait::async_trait; use domain::ports::{ AgentToolPolicyStore, McpToolPermissionStore, ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec, }; use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project}; use infrastructure::TicketToolProvider; use serde_json::Value; const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; const REQUESTER_ARG: &str = "__ideaRequester"; /// Invoker d'outils OpenAI-compatible branché sur l'orchestrateur applicatif. pub struct AppOpenAiToolInvoker { orchestrator: Arc, projects: Arc, policies: Arc, mcp_tool_permissions: Arc, ticket_tools: Arc, } /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la /// composition root. Il casse uniquement le cycle de wiring, pas le contrat runtime. #[derive(Default)] pub struct LateBoundOpenAiToolInvoker { inner: Mutex>>, } impl LateBoundOpenAiToolInvoker { /// Construit un proxy vide. #[must_use] pub fn new() -> Self { Self { inner: Mutex::new(None), } } /// Lie l'implémentation réelle. Appelé une fois par la composition root. pub fn bind(&self, inner: Arc) { *self.inner.lock().expect("mutex sain") = Some(inner); } } #[async_trait] impl ToolInvoker for LateBoundOpenAiToolInvoker { fn tools(&self) -> Vec { self.inner .lock() .expect("mutex sain") .as_ref() .map_or_else(Vec::new, |inner| inner.tools()) } async fn call(&self, name: &str, args_json: &str) -> Result { let inner = self .inner .lock() .expect("mutex sain") .clone() .ok_or_else(|| { ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned()) })?; inner.call(name, args_json).await } } impl AppOpenAiToolInvoker { /// Construit l'invoker depuis le service orchestrateur et le store projet. #[must_use] pub fn new( orchestrator: Arc, projects: Arc, policies: Arc, mcp_tool_permissions: Arc, ticket_tools: Arc, ) -> Self { Self { orchestrator, projects, policies, mcp_tool_permissions, ticket_tools, } } } #[async_trait] impl ToolInvoker for AppOpenAiToolInvoker { fn tools(&self) -> Vec { infrastructure::orchestrator::mcp::catalogue() .into_iter() .map(|tool| ToolSpec { name: tool.name.to_owned(), description: tool.description.to_owned(), input_schema: tool.input_schema, }) .collect() } async fn call(&self, name: &str, args_json: &str) -> Result { let value: Value = serde_json::from_str(args_json) .map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?; let args = value.as_object().ok_or_else(|| { ToolInvocationError::InvalidArguments( "les arguments d'outil doivent être un objet JSON".to_owned(), ) })?; let project_root = args .get(PROJECT_ROOT_ARG) .and_then(Value::as_str) .map(str::to_owned) .ok_or_else(|| { ToolInvocationError::InvalidArguments( "contexte projet interne absent pour l'outil".to_owned(), ) })?; let requester = args .get(REQUESTER_ARG) .and_then(Value::as_str) .map(str::to_owned) .ok_or_else(|| { ToolInvocationError::InvalidArguments( "identité requester interne absente pour l'outil".to_owned(), ) })?; let ephemeral_policy = self.policies.get_policy(&requester); if let Some(policy) = &ephemeral_policy { enforce_policy(policy, &requester, name, &value)?; } let project = self .projects .list_projects() .await .map_err(|e| ToolInvocationError::Execution(e.to_string()))? .into_iter() .find(|project| project.root.as_str() == project_root) .ok_or_else(|| { ToolInvocationError::Execution(format!( "projet introuvable pour root `{project_root}`" )) })?; enforce_durable_tool_policy( self.mcp_tool_permissions.as_ref(), &project, ephemeral_policy.as_ref(), &requester, name, ) .await?; if infrastructure::orchestrator::mcp::tools::is_ticket_tool(name) { let value = self .ticket_tools .handle_ticket_tool(&project, &requester, name, value) .await .map_err(|e| { let detail = serde_json::to_string(&e.to_value()).unwrap_or_else(|_| e.to_string()); ToolInvocationError::Execution(detail) })?; return serde_json::to_string(&value) .map_err(|e| ToolInvocationError::Execution(format!("JSON ticket tool: {e}"))); } let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester) .map_err(|e| match e { infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => { ToolInvocationError::NotFound(tool) } infrastructure::orchestrator::mcp::ToolMapError::BadArguments(tool) => { ToolInvocationError::InvalidArguments(format!( "arguments invalides pour `{tool}`" )) } infrastructure::orchestrator::mcp::ToolMapError::Invalid(err) => { ToolInvocationError::InvalidArguments(err.to_string()) } })?; let outcome = self .orchestrator .dispatch(&project, command) .await .map_err(|e| ToolInvocationError::Execution(e.to_string()))?; Ok(outcome.reply.unwrap_or(outcome.detail)) } } async fn enforce_durable_tool_policy( store: &dyn McpToolPermissionStore, project: &Project, ephemeral_policy: Option<&AgentToolPolicy>, requester: &str, name: &str, ) -> Result<(), ToolInvocationError> { let Some(policy) = durable_tool_policy(store, project, ephemeral_policy, requester).await? else { return Ok(()); }; if policy.permits(name) { return Ok(()); } let requester = if requester.is_empty() { "mcp" } else { requester }; Err(ToolInvocationError::Rejected(format!( "MCP tool `{name}` is not permitted for requester {requester}" ))) } async fn durable_tool_policy( store: &dyn McpToolPermissionStore, project: &Project, ephemeral_policy: Option<&AgentToolPolicy>, requester: &str, ) -> Result, ToolInvocationError> { let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names(); let policy = if let Some(agent_id) = requester_agent_id(requester) { let doc = store .load_mcp_tool_permissions(project) .await .map_err(|e| { ToolInvocationError::Execution(format!("failed to load MCP tool permissions: {e}")) })?; doc.effective_policy( agent_id, infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS, &known_tools, ) .map_err(|e| ToolInvocationError::Execution(format!("invalid MCP tool permissions: {e}")))? } else if requester.is_empty() || requester == "mcp" || ephemeral_policy.is_none() { McpToolPolicy::read_only( infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS, &known_tools, ) .map_err(|e| { ToolInvocationError::Execution(format!("invalid read-only MCP tool fallback: {e}")) })? } else { return Ok(None); }; Ok(Some(policy)) } fn requester_agent_id(requester: &str) -> Option { uuid::Uuid::parse_str(requester) .ok() .map(AgentId::from_uuid) } fn enforce_policy( policy: &AgentToolPolicy, requester: &str, name: &str, arguments: &Value, ) -> Result<(), ToolInvocationError> { if !policy.permits(name) { return Err(ToolInvocationError::Rejected(format!( "tool `{name}` is not permitted for requester {requester}" ))); } if is_ticket_policy_mutation_tool(name) { let raw_ref = arguments .get("ref") .and_then(Value::as_str) .ok_or_else(|| { ToolInvocationError::InvalidArguments(format!( "tool `{name}` requires a ticket ref under the active policy" )) })?; let issue_ref = IssueRef::from_str(raw_ref).map_err(|e| { ToolInvocationError::InvalidArguments(format!("invalid ticket ref: {e}")) })?; if !policy.permits_ticket_mutation(name, issue_ref) { return Err(ToolInvocationError::Rejected(format!( "tool `{name}` is not permitted for ticket {issue_ref}" ))); } } Ok(()) } fn is_ticket_policy_mutation_tool(name: &str) -> bool { matches!( name, "idea_ticket_update" | "idea_ticket_update_status" | "idea_ticket_update_priority" | "idea_ticket_update_carnet" | "idea_ticket_link" | "idea_ticket_unlink" ) } #[cfg(test)] mod tests { use std::collections::HashMap; use std::str::FromStr; use std::sync::{Arc, Mutex}; use domain::ports::{AgentToolPolicyStore, McpToolPermissionStore, ProjectStore}; use domain::{ AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId, ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace, }; use infrastructure::TicketToolError; use serde_json::json; use uuid::Uuid; use super::*; #[derive(Default)] struct FakePolicies(Mutex>); impl AgentToolPolicyStore for FakePolicies { fn set_policy(&self, requester: String, policy: AgentToolPolicy) { self.0.lock().unwrap().insert(requester, policy); } fn get_policy(&self, requester: &str) -> Option { self.0.lock().unwrap().get(requester).cloned() } fn clear_policy(&self, requester: &str) { self.0.lock().unwrap().remove(requester); } } #[derive(Default)] struct FakeProjects { projects: Mutex>, } impl FakeProjects { fn with(project: Project) -> Self { Self { projects: Mutex::new(vec![project]), } } } #[async_trait] impl ProjectStore for FakeProjects { async fn list_projects(&self) -> Result, StoreError> { Ok(self.projects.lock().unwrap().clone()) } async fn load_project(&self, id: ProjectId) -> Result { self.projects .lock() .unwrap() .iter() .find(|project| project.id == id) .cloned() .ok_or(StoreError::NotFound) } async fn save_project(&self, project: &Project) -> Result<(), StoreError> { self.projects.lock().unwrap().push(project.clone()); Ok(()) } async fn save_workspace(&self, _workspace: &Workspace) -> Result<(), StoreError> { Ok(()) } async fn load_workspace(&self) -> Result { Ok(Workspace::default()) } } struct FakeMcpToolPermissions { doc: Mutex, } impl FakeMcpToolPermissions { fn new(doc: ProjectMcpToolPermissions) -> Self { Self { doc: Mutex::new(doc), } } } #[async_trait] impl McpToolPermissionStore for FakeMcpToolPermissions { async fn load_mcp_tool_permissions( &self, _project: &Project, ) -> Result { Ok(self.doc.lock().unwrap().clone()) } async fn save_mcp_tool_permissions( &self, _project: &Project, permissions: &ProjectMcpToolPermissions, ) -> Result<(), StoreError> { *self.doc.lock().unwrap() = permissions.clone(); Ok(()) } } #[derive(Default)] struct FakeTicketTools { calls: Mutex>, } #[async_trait] impl TicketToolProvider for FakeTicketTools { async fn handle_ticket_tool( &self, _project: &Project, requester: &str, name: &str, arguments: Value, ) -> Result { self.calls.lock().unwrap().push(( requester.to_owned(), name.to_owned(), arguments.clone(), )); Ok(json!({ "ok": true, "requester": requester, "ref": arguments.get("ref").and_then(Value::as_str).unwrap_or_default(), })) } } fn project() -> Project { Project::new( ProjectId::from_uuid(Uuid::from_u128(1)), "demo", ProjectPath::new("/tmp/project").unwrap(), RemoteRef::local(), 1_000, ) .unwrap() } fn mcp_permissions(doc: ProjectMcpToolPermissions) -> Arc { Arc::new(FakeMcpToolPermissions::new(doc)) } fn allow_doc(agent: AgentId, allowed_tools: &[&str]) -> ProjectMcpToolPermissions { let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names(); ProjectMcpToolPermissions::new( None, vec![AgentMcpToolPolicyOverride::new( agent, McpToolPolicy::new( allowed_tools .iter() .map(|tool| (*tool).to_owned()) .collect(), &known_tools, ) .unwrap(), )], &known_tools, ) .unwrap() } fn issue_ref(raw: &str) -> IssueRef { IssueRef::from_str(raw).unwrap() } #[tokio::test] async fn openai_general_agent_without_durable_override_is_read_only() { let temp = std::env::temp_dir().join(format!( "idea-app-tauri-openai-mcp-permissions-readonly-{}", Uuid::new_v4() )); let core = backend::BackendCore::build(temp.clone()); let requester = AgentId::from_uuid(Uuid::from_u128(82)).to_string(); let ticket_tools = Arc::new(FakeTicketTools::default()); let invoker = AppOpenAiToolInvoker::new( Arc::clone(&core.orchestrator_service), Arc::new(FakeProjects::with(project())) as Arc, Arc::new(FakePolicies::default()), mcp_permissions(ProjectMcpToolPermissions::default()), ticket_tools.clone(), ); invoker .call( "idea_ticket_list", &json!({ PROJECT_ROOT_ARG: "/tmp/project", REQUESTER_ARG: requester, }) .to_string(), ) .await .expect("read tool must pass the durable read-only policy"); for (tool, arguments) in [ ( "idea_memory_write", json!({ "slug": "note-a", "content": "body" }), ), ( "idea_ask_agent", json!({ "target": "architect", "task": "do it" }), ), ( "idea_ticket_update_carnet", json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }), ), ( "idea_run_in_background", json!({ "label": "task", "command": "echo" }), ), ] { let mut payload = arguments.as_object().unwrap().clone(); payload.insert(PROJECT_ROOT_ARG.to_owned(), json!("/tmp/project")); payload.insert(REQUESTER_ARG.to_owned(), json!(requester.clone())); let err = invoker .call(tool, &Value::Object(payload).to_string()) .await .expect_err("write tool must be rejected without durable override"); assert!( matches!(err, ToolInvocationError::Rejected(ref message) if message.contains(tool)), "expected readable rejection for {tool}, got {err:?}" ); } let calls = ticket_tools.calls.lock().unwrap(); assert_eq!(calls.len(), 1, "only the read ticket tool should run"); assert_eq!(calls[0].1, "idea_ticket_list"); let _ = std::fs::remove_dir_all(temp); } #[tokio::test] async fn openai_durable_agent_override_allows_explicit_write_tool() { let temp = std::env::temp_dir().join(format!( "idea-app-tauri-openai-mcp-permissions-{}", Uuid::new_v4() )); let core = backend::BackendCore::build(temp.clone()); let agent = AgentId::from_uuid(Uuid::from_u128(83)); let requester = agent.to_string(); let ticket_tools = Arc::new(FakeTicketTools::default()); let invoker = AppOpenAiToolInvoker::new( Arc::clone(&core.orchestrator_service), Arc::new(FakeProjects::with(project())) as Arc, Arc::new(FakePolicies::default()), mcp_permissions(allow_doc(agent, &["idea_ticket_update_carnet"])), ticket_tools.clone(), ); let result = invoker .call( "idea_ticket_update_carnet", &json!({ PROJECT_ROOT_ARG: "/tmp/project", REQUESTER_ARG: requester, "ref": "#7", "expectedVersion": 1, "carnet": "body", }) .to_string(), ) .await .expect("durable override should let the write tool reach the provider"); let result: Value = serde_json::from_str(&result).unwrap(); assert_eq!(result["requester"], requester); assert_eq!(result["ref"], "#7"); let calls = ticket_tools.calls.lock().unwrap(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].1, "idea_ticket_update_carnet"); let _ = std::fs::remove_dir_all(temp); } #[tokio::test] async fn openai_ticket_assistant_policy_still_bounds_ticket() { let temp = std::env::temp_dir().join(format!( "idea-app-tauri-openai-ticket-policy-{}", Uuid::new_v4() )); let core = backend::BackendCore::build(temp.clone()); let requester = "ticket-assistant:00000000000000000000000000000001:7"; let policies = Arc::new(FakePolicies::default()); policies.set_policy( requester.to_owned(), AgentToolPolicy::new( vec!["idea_ticket_update_carnet".to_owned()], Some(issue_ref("#7")), true, ), ); let ticket_tools = Arc::new(FakeTicketTools::default()); let invoker = AppOpenAiToolInvoker::new( Arc::clone(&core.orchestrator_service), Arc::new(FakeProjects::with(project())) as Arc, policies, mcp_permissions(ProjectMcpToolPermissions::default()), ticket_tools.clone(), ); let denied = invoker .call( "idea_ticket_update_carnet", &json!({ PROJECT_ROOT_ARG: "/tmp/project", REQUESTER_ARG: requester, "ref": "#8", "expectedVersion": 1, "carnet": "denied", }) .to_string(), ) .await .expect_err("out-of-scope ticket must be rejected"); assert!(matches!(denied, ToolInvocationError::Rejected(message) if message.contains("#8"))); assert!( ticket_tools.calls.lock().unwrap().is_empty(), "ticket policy rejection happens before TicketToolProvider dispatch" ); let allowed = invoker .call( "idea_ticket_update_carnet", &json!({ PROJECT_ROOT_ARG: "/tmp/project", REQUESTER_ARG: requester, "ref": "#7", "expectedVersion": 1, "carnet": "allowed", }) .to_string(), ) .await .expect("bound ticket update allowed"); let allowed: Value = serde_json::from_str(&allowed).unwrap(); assert_eq!(allowed["requester"], requester); assert_eq!(allowed["ref"], "#7"); let calls = ticket_tools.calls.lock().unwrap(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, requester); assert_eq!(calls[0].1, "idea_ticket_update_carnet"); let _ = std::fs::remove_dir_all(temp); } }