//! ToolInvoker for the OpenAI-compatible adapter. //! //! 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, ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec, }; use domain::{AgentToolPolicy, IssueRef}; 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, 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, ticket_tools: Arc, ) -> Self { Self { orchestrator, projects, policies, 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(), ) })?; enforce_tool_policy(self.policies.as_ref(), &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}`" )) })?; 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)) } } fn enforce_tool_policy( policies: &dyn AgentToolPolicyStore, requester: &str, name: &str, arguments: &Value, ) -> Result<(), ToolInvocationError> { let Some(policy) = policies.get_policy(requester) else { return Ok(()); }; enforce_policy(&policy, requester, name, arguments) } 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::sync::Mutex; use domain::ports::{AgentToolPolicyStore, ProjectStore}; use domain::{Project, ProjectId, 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()) } } #[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 issue_ref(raw: &str) -> IssueRef { IssueRef::from_str(raw).unwrap() } fn project() -> Project { Project::new( ProjectId::from_uuid(Uuid::from_u128(1)), "demo", ProjectPath::new("/tmp/project").unwrap(), RemoteRef::local(), 1_000, ) .unwrap() } #[test] fn openai_tool_policy_rejects_tool_outside_allowlist() { let policies = FakePolicies::default(); policies.set_policy( "ticket-assistant:p:#7".to_owned(), AgentToolPolicy::new( vec!["idea_ticket_read".to_owned()], Some(issue_ref("#7")), true, ), ); let err = enforce_tool_policy( &policies, "ticket-assistant:p:#7", "idea_ask_agent", &json!({}), ) .expect_err("tool must be rejected"); assert!( matches!(err, ToolInvocationError::Rejected(message) if message.contains("idea_ask_agent")) ); } #[test] fn openai_tool_policy_rejects_ticket_mutation_outside_bound_issue() { let policies = FakePolicies::default(); policies.set_policy( "ticket-assistant:p:#7".to_owned(), AgentToolPolicy::new( vec!["idea_ticket_update".to_owned()], Some(issue_ref("#7")), true, ), ); let err = enforce_tool_policy( &policies, "ticket-assistant:p:#7", "idea_ticket_update", &json!({ "ref": "#8", "patch": { "title": "nope" } }), ) .expect_err("ticket mutation must be rejected"); assert!(matches!(err, ToolInvocationError::Rejected(message) if message.contains("#8"))); } #[test] fn openai_tool_policy_allows_bound_ticket_mutation() { let policies = FakePolicies::default(); policies.set_policy( "ticket-assistant:p:#7".to_owned(), AgentToolPolicy::new( vec!["idea_ticket_update".to_owned()], Some(issue_ref("#7")), true, ), ); enforce_tool_policy( &policies, "ticket-assistant:p:#7", "idea_ticket_update", &json!({ "ref": "#7", "patch": { "title": "ok" } }), ) .expect("bound ticket mutation allowed"); } #[tokio::test] async fn openai_ticket_tool_uses_injected_requester_and_ticket_policy_provider() { let temp = std::env::temp_dir().join(format!("idea-openai-ticket-tool-test-{}", Uuid::new_v4())); let core = crate::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".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, ticket_tools.clone(), ); let denied = invoker .call( "idea_ticket_update", &json!({ PROJECT_ROOT_ARG: "/tmp/project", REQUESTER_ARG: requester, "ref": "#8", "patch": { "title": "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(), "policy rejection happens before TicketToolProvider dispatch" ); let allowed = invoker .call( "idea_ticket_update", &json!({ PROJECT_ROOT_ARG: "/tmp/project", REQUESTER_ARG: requester, "ref": "#7", "patch": { "title": "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"); assert_eq!(calls[0].2[REQUESTER_ARG], requester); assert_eq!(calls[0].2[PROJECT_ROOT_ARG], "/tmp/project"); let _ = std::fs::remove_dir_all(temp); } }