diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 620280d..f0559e2 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -51,10 +51,10 @@ use domain::ports::{ AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, - IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, - ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, - StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason, - WindowStateStore, + IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore, + PermissionStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, + SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, + WakeError, WakeReason, WindowStateStore, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -74,17 +74,17 @@ use infrastructure::{ CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, - FsIssueStore, FsLiveStateStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, - FsPermissionStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSkillStore, - FsSprintStore, FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, - HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore, - InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, - LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, - NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, - StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer, - TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, - VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, - VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, + FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore, + FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsWindowStateStore, + Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, + HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, + InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, + LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, + PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, + TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, + ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, + ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; pub mod dto; @@ -942,6 +942,8 @@ pub struct BackendCore { pub close_ticket_assistant: Arc, /// Shared MCP tool policy registry used by ticket assistant sessions. pub tool_policy_registry: Arc, + /// Durable per-agent MCP tool permission store. + pub mcp_tool_permission_store: Arc, /// Registre des sessions structurées (IA / cellules chat, §17.5). Partagé avec /// `LaunchAgent`/`ChangeAgentProfile` ; consommé par les commandes de chat (D4) /// pour résoudre la session vivante d'un `sessionId` et l'arrêter à la fermeture. @@ -1505,6 +1507,10 @@ impl BackendCore { // --- Project permissions (LP1) --- let permission_store = Arc::new(FsPermissionStore::new(Arc::clone(&fs_port))); let permission_store_port = Arc::clone(&permission_store) as Arc; + let mcp_tool_permission_store = + Arc::new(FsMcpToolPermissionStore::new(Arc::clone(&fs_port))); + let mcp_tool_permission_store_port = + Arc::clone(&mcp_tool_permission_store) as Arc; // --- Skill store (L12) --- // Global skills live in the machine-local app-data dir; project skills are @@ -2438,6 +2444,7 @@ impl BackendCore { open_ticket_assistant, close_ticket_assistant, tool_policy_registry, + mcp_tool_permission_store: mcp_tool_permission_store_port, structured_sessions, create_agent, list_agents, @@ -2599,7 +2606,8 @@ impl BackendCore { .with_events(events) .with_ready_sink(ready_sink) .with_ticket_tools(Arc::clone(&self.ticket_tool_provider)) - .with_tool_policies(Arc::clone(&self.tool_policy_registry)), + .with_tool_policies(Arc::clone(&self.tool_policy_registry)) + .with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)), endpoint, listener, project_id, diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 87197e4..88176ad 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -21,7 +21,10 @@ use std::sync::Arc; use std::time::Instant; use application::OrchestratorService; -use domain::{AgentToolPolicy, DomainEvent, IssueRef, OrchestrationSource, Project}; +use domain::ports::McpToolPermissionStore; +use domain::{ + AgentId, AgentToolPolicy, DomainEvent, IssueRef, McpToolPolicy, OrchestrationSource, Project, +}; use serde_json::{json, Value}; use tokio::sync::mpsc; @@ -69,6 +72,9 @@ pub struct McpServer { ticket_tools: Option>, /// Optional per-requester MCP tool policy registry for constrained sessions. tool_policies: Option>, + /// Optional durable MCP tool permission store. When wired, absence of project or + /// agent policy resolves to the canonical read-only fallback. + mcp_tool_permissions: Option>, } impl McpServer { @@ -85,6 +91,7 @@ impl McpServer { ready_sink: None, ticket_tools: None, tool_policies: None, + mcp_tool_permissions: None, } } @@ -123,6 +130,16 @@ impl McpServer { self } + /// Attaches the durable MCP tool permission store. + #[must_use] + pub fn with_mcp_tool_permissions( + mut self, + mcp_tool_permissions: Arc, + ) -> Self { + self.mcp_tool_permissions = Some(mcp_tool_permissions); + self + } + /// Returns a per-connection clone of this server tagged with the connected /// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4). /// @@ -141,6 +158,7 @@ impl McpServer { ready_sink: self.ready_sink.clone(), ticket_tools: self.ticket_tools.clone(), tool_policies: self.tool_policies.clone(), + mcp_tool_permissions: self.mcp_tool_permissions.clone(), } } @@ -282,7 +300,7 @@ impl McpServer { self.notify_ready(); Ok(self.initialize_result()) } - "tools/list" => Ok(self.tools_list_result()), + "tools/list" => self.tools_list_result().await, "tools/call" => self.tools_call(params.unwrap_or(Value::Null)).await, other => Err(JsonRpcError::new( error_codes::METHOD_NOT_FOUND, @@ -312,17 +330,18 @@ impl McpServer { } /// The `tools/list` result: the catalogue as MCP tool descriptors. - fn tools_list_result(&self) -> Value { - let policy = self - .tool_policies - .as_ref() - .and_then(|registry| registry.get(&self.requester)); + async fn tools_list_result(&self) -> Result { + let ephemeral_policy = self.ephemeral_tool_policy(); + let durable_policy = self.durable_tool_policy().await?; let tools: Vec = tools::catalogue() .into_iter() .filter(|t| { - policy + ephemeral_policy .as_ref() .map_or(true, |policy| policy.permits(t.name)) + && durable_policy + .as_ref() + .map_or(true, |policy| policy.permits(t.name)) }) .map(|t| { json!({ @@ -332,7 +351,82 @@ impl McpServer { }) }) .collect(); - json!({ "tools": tools }) + Ok(json!({ "tools": tools })) + } + + fn ephemeral_tool_policy(&self) -> Option { + self.tool_policies + .as_ref() + .and_then(|registry| registry.get(&self.requester)) + } + + async fn durable_tool_policy(&self) -> Result, JsonRpcError> { + let Some(store) = &self.mcp_tool_permissions else { + return Ok(None); + }; + + let known_tools = tools::classified_tool_names(); + let policy = if let Some(agent_id) = self.requester_agent_id() { + let doc = store + .load_mcp_tool_permissions(&self.project) + .await + .map_err(|e| { + JsonRpcError::new( + error_codes::INTERNAL_ERROR, + format!("failed to load MCP tool permissions: {e}"), + ) + })?; + doc.effective_policy(agent_id, tools::READ_ONLY_TOOLS, &known_tools) + .map_err(|e| { + JsonRpcError::new( + error_codes::INTERNAL_ERROR, + format!("invalid MCP tool permissions: {e}"), + ) + })? + } else if self.requester.is_empty() + || self.requester == "mcp" + || self.ephemeral_tool_policy().is_none() + { + // Anonymous/legacy peers cannot be mapped to an agent override. Fail + // closed to the canonical read-only policy. Non-agent requesters with an + // ephemeral policy (ticket assistants) are governed by that narrower, + // session-scoped policy instead of the durable per-agent store. + McpToolPolicy::read_only(tools::READ_ONLY_TOOLS, &known_tools).map_err(|e| { + JsonRpcError::new( + error_codes::INTERNAL_ERROR, + format!("invalid read-only MCP tool fallback: {e}"), + ) + })? + } else { + return Ok(None); + }; + + Ok(Some(policy)) + } + + fn requester_agent_id(&self) -> Option { + uuid::Uuid::parse_str(&self.requester) + .ok() + .map(AgentId::from_uuid) + } + + fn enforce_durable_tool_policy( + &self, + policy: &McpToolPolicy, + name: &str, + ) -> Result<(), JsonRpcError> { + if policy.permits(name) { + return Ok(()); + } + let requester = if self.requester.is_empty() { + "mcp" + } else { + &self.requester + }; + Err(JsonRpcError::new( + error_codes::INVALID_PARAMS, + format!("MCP tool `{name}` is not permitted for requester {requester}"), + )) } /// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and @@ -346,13 +440,12 @@ impl McpServer { .to_owned(); let arguments = params.get("arguments").cloned().unwrap_or(json!({})); - if let Some(policy) = self - .tool_policies - .as_ref() - .and_then(|registry| registry.get(&self.requester)) - { + if let Some(policy) = self.ephemeral_tool_policy() { self.enforce_tool_policy(&policy, &name, &arguments)?; } + if let Some(policy) = self.durable_tool_policy().await? { + self.enforce_durable_tool_policy(&policy, &name)?; + } // Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace // l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs. diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 7a8095b..1b0945a 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -33,9 +33,9 @@ use domain::ids::{AgentId, ProfileId, ProjectId}; use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, - ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore, - PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, - StoreError, + ExitStatus, FileSystem, FsError, IdGenerator, McpToolPermissionStore, OutputStream, + PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, + SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::profile::{ AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, @@ -45,7 +45,10 @@ use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; use domain::terminal::{SessionKind, TerminalSession}; -use domain::{AgentToolPolicy, IssueRef, PtySize, SessionId}; +use domain::{ + AgentMcpToolPolicyOverride, AgentToolPolicy, IssueRef, McpToolPolicy, + ProjectMcpToolPermissions, PtySize, SessionId, +}; use uuid::Uuid; use application::{ @@ -53,6 +56,7 @@ use application::{ OrchestratorService, TerminalSessions, UpdateAgentContext, }; use infrastructure::orchestrator::mcp::jsonrpc::error_codes; +use infrastructure::orchestrator::mcp::tools::classified_tool_names; use infrastructure::orchestrator::mcp::{TicketToolError, TicketToolProvider}; use infrastructure::{ InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport, @@ -274,6 +278,38 @@ impl FileSystem for FakeFs { } } +#[derive(Clone)] +struct FakeMcpToolPermissionStore { + doc: Arc>, +} + +impl FakeMcpToolPermissionStore { + fn new(doc: ProjectMcpToolPermissions) -> Self { + Self { + doc: Arc::new(Mutex::new(doc)), + } + } +} + +#[async_trait] +impl McpToolPermissionStore for FakeMcpToolPermissionStore { + 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(Clone)] struct FakePty; #[async_trait] @@ -433,6 +469,34 @@ fn server(service: Arc) -> McpServer { McpServer::new(service, project()) } +fn server_with_mcp_permissions( + service: Arc, + permissions: ProjectMcpToolPermissions, +) -> McpServer { + McpServer::new(service, project()) + .with_mcp_tool_permissions(Arc::new(FakeMcpToolPermissionStore::new(permissions))) +} + +fn allow_doc(agent: AgentId, allowed_tools: &[&str]) -> ProjectMcpToolPermissions { + let known_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() +} + /// A capturing event sink (the MCP twin of the file watcher's publish closure): /// records every [`DomainEvent`] the server emits so a test can assert the /// `OrchestratorRequestProcessed` source tag. Returns the closure to wire via @@ -507,6 +571,11 @@ impl TicketToolProvider for FakeTicketTools { ) -> Result { self.calls.lock().unwrap().push(name.to_owned()); match name { + "idea_ticket_read" => Ok(json!({ + "item": { "ref": "#7", "title": "Seeded ticket", "version": 1 } + })), + "idea_ticket_list" => Ok(json!({ "items": [] })), + "idea_ticket_read_carnet" => Ok(json!({ "ref": "#7", "carnet": "" })), "idea_sprint_list" => Ok(json!({ "items": self.sprints.lock().unwrap().clone() })), @@ -666,10 +735,232 @@ async fn requester_without_tool_policy_keeps_the_full_tools_list() { assert!(names.len() > 3, "unfiltered requester got {names:?}"); } +#[tokio::test] +async fn requester_with_durable_store_but_no_override_sees_read_only_tools() { + let (service, _s) = build_service(FakeContexts::new()); + let agent = AgentId::from_uuid(Uuid::from_u128(82)); + let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default()) + .for_requester(agent.to_string()); + + let raw = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/list" + })) + .unwrap(); + let response = server.handle_raw(&raw).await.expect("reply owed"); + assert!(response.error.is_none(), "got error: {:?}", response.error); + let result = response.result.expect("result"); + let names: Vec<&str> = result["tools"] + .as_array() + .expect("tools array") + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect(); + + assert!(names.contains(&"idea_memory_read")); + assert!(names.contains(&"idea_ticket_list")); + assert!(!names.contains(&"idea_memory_write")); + assert!(!names.contains(&"idea_ask_agent")); + assert!(!names.contains(&"idea_ticket_update_carnet")); + assert!(!names.contains(&"idea_run_in_background")); +} + // --------------------------------------------------------------------------- // 2. tools/call → the right OrchestratorCommand (observed through the fakes) // --------------------------------------------------------------------------- +#[tokio::test] +async fn general_agent_without_mcp_override_is_read_only_for_tools_call() { + let contexts = FakeContexts::new(); + contexts.seed_agent("architect"); + let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts); + let ticket_tools = Arc::new(FakeTicketTools::default()); + let agent = AgentId::from_uuid(Uuid::from_u128(83)); + let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default()) + .with_ticket_tools(ticket_tools.clone()) + .for_requester(agent.to_string()); + + for (id, tool, arguments) in [ + (101, "idea_memory_read", json!({})), + (102, "idea_ticket_list", json!({})), + ] { + let response = server + .handle_raw(&tools_call(id, tool, arguments)) + .await + .expect("reply owed"); + assert!( + response.error.is_none(), + "read tool {tool} must not be rejected by MCP policy: {:?}", + response.error + ); + assert!(response.result.is_some(), "read tool {tool} should run"); + } + + for (id, tool, arguments) in [ + ( + 111, + "idea_memory_write", + json!({ "slug": "note-a", "content": "body" }), + ), + ( + 112, + "idea_ask_agent", + json!({ "target": "architect", "task": "do it" }), + ), + ( + 113, + "idea_ticket_update_carnet", + json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }), + ), + ( + 114, + "idea_run_in_background", + json!({ "label": "task", "command": "echo" }), + ), + ] { + let response = server + .handle_raw(&tools_call(id, tool, arguments)) + .await + .expect("reply owed"); + let error = response.error.expect("MCP policy rejection expected"); + assert_eq!(error.code, error_codes::INVALID_PARAMS, "tool {tool}"); + assert!( + error.message.contains("not permitted"), + "message should be readable for {tool}; got {}", + error.message + ); + assert!(response.result.is_none(), "denied {tool} must not run"); + } + + assert_eq!( + ticket_tools.calls(), + vec!["idea_ticket_list".to_owned()], + "denied ticket mutation must not reach the provider" + ); + assert_eq!(ticket_tools.mutation_attempts(), 0); +} + +#[tokio::test] +async fn legacy_mcp_requester_with_durable_store_is_read_only() { + let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new()); + let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default()) + .for_requester("mcp"); + + let response = server + .handle_raw(&tools_call( + 121, + "idea_run_in_background", + json!({ "label": "task", "command": "echo" }), + )) + .await + .expect("reply owed"); + + let error = response.error.expect("legacy requester must fail closed"); + assert_eq!(error.code, error_codes::INVALID_PARAMS); + assert!(error.message.contains("not permitted")); +} + +#[tokio::test] +async fn empty_requester_with_durable_store_is_read_only() { + let (service, _s) = build_service(FakeContexts::new()); + let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default()) + .for_requester(""); + + let response = server + .handle_raw(&tools_call( + 122, + "idea_memory_write", + json!({ "slug": "note-a", "content": "body" }), + )) + .await + .expect("reply owed"); + + let error = response.error.expect("empty requester must fail closed"); + assert_eq!(error.code, error_codes::INVALID_PARAMS); + assert!(error.message.contains("not permitted")); + assert!(response.result.is_none()); +} + +#[tokio::test] +async fn durable_agent_override_allows_an_explicit_write_tool() { + let (service, _s) = build_service(FakeContexts::new()); + let ticket_tools = Arc::new(FakeTicketTools::default()); + let agent = AgentId::from_uuid(Uuid::from_u128(84)); + let server = + server_with_mcp_permissions(service, allow_doc(agent, &["idea_ticket_update_carnet"])) + .with_ticket_tools(ticket_tools.clone()) + .for_requester(agent.to_string()); + + let response = server + .handle_raw(&tools_call( + 131, + "idea_ticket_update_carnet", + json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }), + )) + .await + .expect("reply owed"); + + assert!( + response.error.is_none(), + "durable override should pass MCP policy, got {:?}", + response.error + ); + let result = response.result.expect("tool result"); + assert_eq!( + result["isError"], + json!(true), + "fake provider reports execution error after policy passes" + ); + assert_eq!(ticket_tools.calls(), vec!["idea_ticket_update_carnet"]); + assert_eq!(ticket_tools.mutation_attempts(), 1); +} + +#[tokio::test] +async fn ticket_assistant_policy_still_bounds_ticket_with_durable_store_present() { + let (service, _s) = build_service(FakeContexts::new()); + let registry = Arc::new(ToolPolicyRegistry::new()); + registry.set( + "ticket-assistant:demo:7", + AgentToolPolicy::new( + vec!["idea_ticket_update_carnet".to_owned()], + Some(IssueRef::from_str("#7").unwrap()), + true, + ), + ); + let ticket_tools = Arc::new(FakeTicketTools::default()); + let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default()) + .with_ticket_tools(ticket_tools.clone()) + .with_tool_policies(registry) + .for_requester("ticket-assistant:demo:7"); + + let rejected = server + .handle_raw(&tools_call( + 141, + "idea_ticket_update_carnet", + json!({ "ref": "#8", "expectedVersion": 1, "carnet": "body" }), + )) + .await + .expect("reply owed"); + let error = rejected.error.expect("bound ticket rejection expected"); + assert_eq!(error.code, error_codes::INVALID_PARAMS); + assert!(error.message.contains("#8")); + assert!(ticket_tools.calls().is_empty()); + + let allowed = server + .handle_raw(&tools_call( + 142, + "idea_ticket_update_carnet", + json!({ "ref": "#7", "expectedVersion": 1, "carnet": "body" }), + )) + .await + .expect("reply owed"); + assert!( + allowed.error.is_none(), + "bound ticket should pass policy, got {:?}", + allowed.error + ); + assert_eq!(ticket_tools.calls(), vec!["idea_ticket_update_carnet"]); +} + #[tokio::test] async fn launch_agent_call_creates_and_launches_the_agent() { let contexts = FakeContexts::new();