diff --git a/crates/app-tauri/src/openai_tools.rs b/crates/app-tauri/src/openai_tools.rs index fc1eceb..c9c35ab 100644 --- a/crates/app-tauri/src/openai_tools.rs +++ b/crates/app-tauri/src/openai_tools.rs @@ -4,11 +4,16 @@ //! 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::{ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec}; +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"; @@ -18,6 +23,8 @@ const REQUESTER_ARG: &str = "__ideaRequester"; pub struct AppOpenAiToolInvoker { orchestrator: Arc, projects: Arc, + policies: Arc, + ticket_tools: Arc, } /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la @@ -68,10 +75,17 @@ impl ToolInvoker for LateBoundOpenAiToolInvoker { impl AppOpenAiToolInvoker { /// Construit l'invoker depuis le service orchestrateur et le store projet. #[must_use] - pub fn new(orchestrator: Arc, projects: Arc) -> Self { + pub fn new( + orchestrator: Arc, + projects: Arc, + policies: Arc, + ticket_tools: Arc, + ) -> Self { Self { orchestrator, projects, + policies, + ticket_tools, } } } @@ -100,6 +114,7 @@ impl ToolInvoker for AppOpenAiToolInvoker { 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(), @@ -108,11 +123,13 @@ impl ToolInvoker for AppOpenAiToolInvoker { 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() @@ -125,7 +142,20 @@ impl ToolInvoker for AppOpenAiToolInvoker { "projet introuvable pour root `{project_root}`" )) })?; - let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, requester) + 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) @@ -147,3 +177,59 @@ impl ToolInvoker for AppOpenAiToolInvoker { 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" + ) +} diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 35f6003..f6b2f12 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -1853,7 +1853,15 @@ impl LaunchAgent { // Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`, // déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée. let session = factory - .start(profile, prepared, run_dir, session_plan, env, sandbox) + .start( + profile, + prepared, + run_dir, + session_plan, + Some(&agent.id.to_string()), + env, + sandbox, + ) .await .map_err(|e| AppError::Process(e.to_string()))?; diff --git a/crates/application/src/ticket_assistant.rs b/crates/application/src/ticket_assistant.rs index 3215913..0528e6e 100644 --- a/crates/application/src/ticket_assistant.rs +++ b/crates/application/src/ticket_assistant.rs @@ -143,6 +143,7 @@ impl OpenTicketAssistant { &prepared, &environment.cwd, &SessionPlan::None, + Some(&requester), &environment.env, None, ) diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 55e848c..ba51572 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -627,6 +627,7 @@ impl AgentSessionFactory for FakeStructuredFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _requester: Option<&str>, _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 753ac5f..98e45a4 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -1488,6 +1488,7 @@ impl AgentSessionFactory for CompletionFactory { ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _requester: Option<&str>, _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { @@ -4096,6 +4097,7 @@ impl AgentSessionFactory for CountingFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _requester: Option<&str>, _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index c6b74de..7336ff6 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -528,6 +528,7 @@ impl AgentSessionFactory for FakeFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, session: &SessionPlan, + _requester: Option<&str>, _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { @@ -1126,7 +1127,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() { let cwd = ProjectPath::new(ROOT).unwrap(); let session = f .factory - .start(&profile, &ctx, &cwd, &SessionPlan::None, &[], None) + .start(&profile, &ctx, &cwd, &SessionPlan::None, None, &[], None) .await .expect("seed structured session"); f.structured.insert(session, agent.id, host); diff --git a/crates/application/tests/ticket_assistant.rs b/crates/application/tests/ticket_assistant.rs index 852577a..ff9dc1d 100644 --- a/crates/application/tests/ticket_assistant.rs +++ b/crates/application/tests/ticket_assistant.rs @@ -251,6 +251,7 @@ struct FakeFactory { PreparedContext, ProjectPath, SessionPlan, + Option, Vec<(String, String)>, Option, )>, @@ -270,6 +271,7 @@ impl AgentSessionFactory for FakeFactory { ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, + requester: Option<&str>, env: &[(String, String)], sandbox: Option<&domain::SandboxPlan>, ) -> Result, AgentSessionError> { @@ -277,6 +279,7 @@ impl AgentSessionFactory for FakeFactory { ctx.clone(), cwd.clone(), session.clone(), + requester.map(str::to_owned), env.to_vec(), sandbox.cloned(), )); @@ -381,14 +384,15 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_ let starts = factory.starts.lock().unwrap(); assert_eq!(starts[0].1.as_str(), "/tmp/app-data/assistant/tickets/1/7"); assert!(matches!(starts[0].2, SessionPlan::None)); + assert_eq!(starts[0].3.as_deref(), Some(output.requester.as_str())); assert_eq!( - starts[0].3, + starts[0].4, vec![( "CODEX_HOME".to_owned(), "/tmp/app-data/assistant/tickets/1/7/.codex".to_owned() )] ); - assert!(starts[0].4.is_none()); + assert!(starts[0].5.is_none()); drop(starts); close diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 2ee5bcc..620280d 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -2345,6 +2345,8 @@ impl BackendCore { openai_tool_invoker.bind(Arc::new(AppOpenAiToolInvoker::new( Arc::clone(&orchestrator_service), Arc::clone(&store_port), + Arc::clone(&tool_policy_registry) as Arc, + Arc::clone(&ticket_tool_provider), )) as Arc); let stop_live_agent = Arc::new( diff --git a/crates/backend/src/openai_tools.rs b/crates/backend/src/openai_tools.rs index fe0b17b..f3b9a6d 100644 --- a/crates/backend/src/openai_tools.rs +++ b/crates/backend/src/openai_tools.rs @@ -4,11 +4,16 @@ //! 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::{ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec}; +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"; @@ -18,6 +23,8 @@ const REQUESTER_ARG: &str = "__ideaRequester"; pub struct AppOpenAiToolInvoker { orchestrator: Arc, projects: Arc, + policies: Arc, + ticket_tools: Arc, } /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la @@ -68,10 +75,17 @@ impl ToolInvoker for LateBoundOpenAiToolInvoker { impl AppOpenAiToolInvoker { /// Construit l'invoker depuis le service orchestrateur et le store projet. #[must_use] - pub fn new(orchestrator: Arc, projects: Arc) -> Self { + pub fn new( + orchestrator: Arc, + projects: Arc, + policies: Arc, + ticket_tools: Arc, + ) -> Self { Self { orchestrator, projects, + policies, + ticket_tools, } } } @@ -100,6 +114,7 @@ impl ToolInvoker for AppOpenAiToolInvoker { 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(), @@ -108,11 +123,13 @@ impl ToolInvoker for AppOpenAiToolInvoker { 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() @@ -125,7 +142,20 @@ impl ToolInvoker for AppOpenAiToolInvoker { "projet introuvable pour root `{project_root}`" )) })?; - let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, requester) + 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) @@ -147,3 +177,313 @@ impl ToolInvoker for AppOpenAiToolInvoker { 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); + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 2a20fa2..973bd07 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -952,6 +952,7 @@ pub trait AgentSessionFactory: Send + Sync { ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, + requester: Option<&str>, env: &[(String, String)], sandbox: Option<&crate::sandbox::SandboxPlan>, ) -> Result, AgentSessionError>; diff --git a/crates/domain/tests/structured_session_d0.rs b/crates/domain/tests/structured_session_d0.rs index 5514931..7503abc 100644 --- a/crates/domain/tests/structured_session_d0.rs +++ b/crates/domain/tests/structured_session_d0.rs @@ -340,6 +340,7 @@ impl AgentSessionFactory for FakeFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _requester: Option<&str>, _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { @@ -397,7 +398,7 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() { }; let cwd = ProjectPath::new("/srv/run").unwrap(); let session = factory - .start(&structured, &ctx, &cwd, &SessionPlan::None, &[], None) + .start(&structured, &ctx, &cwd, &SessionPlan::None, None, &[], None) .await .expect("factory starts a session"); assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7))); diff --git a/crates/infrastructure/src/session/factory.rs b/crates/infrastructure/src/session/factory.rs index dadcfa7..61cf573 100644 --- a/crates/infrastructure/src/session/factory.rs +++ b/crates/infrastructure/src/session/factory.rs @@ -136,6 +136,7 @@ impl AgentSessionFactory for StructuredSessionFactory { ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, + requester: Option<&str>, env: &[(String, String)], sandbox: Option<&SandboxPlan>, ) -> Result, AgentSessionError> { @@ -150,11 +151,9 @@ impl AgentSessionFactory for StructuredSessionFactory { let command = profile.command.clone(); let cwd = cwd.as_str().to_owned(); let seed = seed_conversation_id(session); - let requester = std::path::Path::new(&cwd) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default() - .to_owned(); + let requester = requester + .map(str::to_owned) + .unwrap_or_else(|| fallback_requester_from_cwd(&cwd)); // Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par // instance** (champ). Tous deux sont relayés à l'adapter, qui remplira @@ -216,3 +215,70 @@ impl AgentSessionFactory for StructuredSessionFactory { Ok(session) } } + +fn fallback_requester_from_cwd(cwd: &str) -> String { + std::path::Path::new(cwd) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_owned() +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use serde_json::json; + + use super::*; + + #[derive(Default)] + struct RecordingToolInvoker { + call: Mutex>, + } + + #[async_trait] + impl ToolInvoker for RecordingToolInvoker { + fn tools(&self) -> Vec { + Vec::new() + } + + async fn call(&self, name: &str, args_json: &str) -> Result { + *self.call.lock().unwrap() = Some((name.to_owned(), args_json.to_owned())); + Ok("ok".to_owned()) + } + } + + #[tokio::test] + async fn project_scoped_tool_invoker_injects_explicit_requester_identity() { + let recorder = Arc::new(RecordingToolInvoker::default()); + let invoker = ProjectScopedToolInvoker { + inner: recorder.clone(), + project_root: "/project/root".to_owned(), + requester: "ticket-assistant:project:7".to_owned(), + }; + + let out = invoker + .call("idea_ticket_update", r##"{"ref":"#7"}"##) + .await + .expect("tool call ok"); + + assert_eq!(out, "ok"); + let (name, args_json) = recorder.call.lock().unwrap().clone().expect("recorded"); + assert_eq!(name, "idea_ticket_update"); + let args: Value = serde_json::from_str(&args_json).unwrap(); + assert_eq!( + args, + json!({ + "ref": "#7", + PROJECT_ROOT_ARG: "/project/root", + REQUESTER_ARG: "ticket-assistant:project:7", + }) + ); + } + + #[test] + fn fallback_requester_uses_cwd_file_name_only_when_explicit_identity_absent() { + assert_eq!(fallback_requester_from_cwd("/tmp/run/7"), "7"); + } +} diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index ecd6a05..b1e4ddb 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -510,6 +510,7 @@ mod tests { &prepared_ctx(), &cwd(), &SessionPlan::None, + None, &[], None, ) @@ -527,6 +528,7 @@ mod tests { &prepared_ctx(), &cwd(), &SessionPlan::None, + None, &[], None, ) @@ -558,6 +560,7 @@ mod tests { &prepared_ctx(), &temp_cwd("factory-openai"), &SessionPlan::None, + None, &[], None, ) @@ -589,7 +592,7 @@ mod tests { }; let session = factory - .start(&codex, &ctx, &cwd(), &SessionPlan::None, &[], None) + .start(&codex, &ctx, &cwd(), &SessionPlan::None, None, &[], None) .await .expect("start Codex ok"); let content = drain_final(session.as_ref()).await; @@ -623,6 +626,7 @@ mod tests { &SessionPlan::Resume { conversation_id: "repris-42".to_owned(), }, + None, &[], None, ) @@ -1437,6 +1441,7 @@ mod tests { &SessionPlan::Resume { conversation_id: "cx-resume".to_owned(), }, + None, &[], None, ) @@ -1461,6 +1466,7 @@ mod tests { &SessionPlan::Assign { conversation_id: "ignored-by-engine".to_owned(), }, + None, &[], None, ) @@ -1487,7 +1493,15 @@ mod tests { ) .expect("profil valide"); match factory - .start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, &[], None) + .start( + &tui, + &prepared_ctx(), + &cwd(), + &SessionPlan::None, + None, + &[], + None, + ) .await { Err(AgentSessionError::Start(_)) => {} diff --git a/crates/infrastructure/src/session/sandbox_e2e.rs b/crates/infrastructure/src/session/sandbox_e2e.rs index f1c67b5..87325fb 100644 --- a/crates/infrastructure/src/session/sandbox_e2e.rs +++ b/crates/infrastructure/src/session/sandbox_e2e.rs @@ -470,7 +470,15 @@ async fn structured_sandboxed_turn_preserves_conversation_id() { let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés let session = factory - .start(&profile, &ctx, &cwd, &SessionPlan::None, &[], Some(&plan)) + .start( + &profile, + &ctx, + &cwd, + &SessionPlan::None, + None, + &[], + Some(&plan), + ) .await .expect("start sandboxé ok"); diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 93784dc..04d6414 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -480,6 +480,7 @@ impl AgentSessionFactory for BlockingReplyFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _requester: Option<&str>, _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> {