From 8570adb8e07cfb42c8ce262c95a8895de28a5ee0 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 13 Jul 2026 09:10:23 +0200 Subject: [PATCH] =?UTF-8?q?fix(ticket-assistant):=20=C3=A9dition=20de=20ti?= =?UTF-8?q?cket=20via=20les=20tools=20MCP=20idea=5Fticket=5F*=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'assistant IA d'édition de ticket éditait les fichiers du ticket en direct, hors de tout contrôle. Il passe désormais par les tools MCP idea_ticket_* : préparation d'un environnement structuré dédié et policy d'enforcement scopée au ticket courant, de sorte que l'assistant ne peut agir que sur son ticket via la surface MCP plutôt que sur le système de fichiers. Couvert par de nouveaux tests QA (mcp_server, assistant_context_store). Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/state.rs | 29 +- crates/application/src/ticket_assistant.rs | 78 +++- crates/application/tests/ticket_assistant.rs | 88 +++- crates/domain/src/lib.rs | 3 +- crates/domain/src/ports.rs | 26 ++ crates/infrastructure/src/assistant/mod.rs | 411 +++++++++++++++++- crates/infrastructure/src/lib.rs | 2 +- .../src/orchestrator/mcp/server.rs | 10 +- .../tests/assistant_context_store.rs | 143 +++++- crates/infrastructure/tests/mcp_server.rs | 123 ++++++ 10 files changed, 873 insertions(+), 40 deletions(-) diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index ccdff37..14aba45 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -49,8 +49,9 @@ use domain::ports::{ BackgroundTaskStore, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, - ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore, - ToolInvoker, WakeError, WakeReason, WindowStateStore, + ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, + StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason, + WindowStateStore, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -76,10 +77,10 @@ use infrastructure::{ IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, - StructuredSessionFactory, SystemClock, SystemMillisClock, TicketToolProvider, - TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, - DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, - VECTOR_ONNX_ENABLED, + 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, }; use crate::chat::ChatBridge; @@ -1355,10 +1356,26 @@ impl AppState { Arc::clone(&fs_port), app_data_dir.to_string_lossy().into_owned(), )) as Arc; + let assistant_environment = Arc::new(TicketAssistantEnvironmentPreparer::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + Arc::clone(&runtime_port), + Arc::new(|project: &Project, requester: &str| { + Some(McpRuntime { + exe: crate::mcp_endpoint::idea_exe_path()?, + endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id) + .as_cli_arg() + .to_owned(), + project_id: project.id.as_uuid().simple().to_string(), + requester: requester.to_owned(), + }) + }), + )) as Arc; let open_ticket_assistant = Arc::new(OpenTicketAssistant::new( Arc::clone(&issue_store_port), Arc::clone(&profile_store_port), assistant_context_provider, + assistant_environment, Arc::clone(&session_factory), Arc::clone(&structured_sessions), Arc::clone(&tool_policy_store), diff --git a/crates/application/src/ticket_assistant.rs b/crates/application/src/ticket_assistant.rs index c3b3c8b..3215913 100644 --- a/crates/application/src/ticket_assistant.rs +++ b/crates/application/src/ticket_assistant.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use domain::ports::{AgentSessionFactory, SessionPlan}; +use domain::ports::{AgentSessionFactory, SessionPlan, StructuredSessionEnvironmentPreparer}; use domain::AgentProfile; use domain::{ AgentToolPolicy, AgentToolPolicyStore, AssistantContextProvider, DomainEvent, EventBus, @@ -17,6 +17,7 @@ pub struct OpenTicketAssistant { issues: Arc, profiles: Arc, contexts: Arc, + environment: Arc, factory: Arc, structured: Arc, policies: Arc, @@ -52,6 +53,7 @@ impl OpenTicketAssistant { issues: Arc, profiles: Arc, contexts: Arc, + environment: Arc, factory: Arc, structured: Arc, policies: Arc, @@ -61,6 +63,7 @@ impl OpenTicketAssistant { issues, profiles, contexts, + environment, factory, structured, policies, @@ -94,39 +97,74 @@ impl OpenTicketAssistant { .prepare_ticket_assistant_context(&input.project, &issue) .await .map_err(|e| AppError::Store(e.to_string()))?; + let requester = ticket_assistant_requester(&input.project, input.issue_ref); + let had_existing = self + .structured + .ticket_assistant_requester(input.issue_ref) + .is_some(); + let policy = AgentToolPolicy::new( + vec![ + "idea_ticket_read".to_owned(), + "idea_ticket_update".to_owned(), + "idea_ticket_update_status".to_owned(), + "idea_ticket_update_priority".to_owned(), + "idea_ticket_read_carnet".to_owned(), + "idea_ticket_update_carnet".to_owned(), + "idea_ticket_link".to_owned(), + "idea_ticket_unlink".to_owned(), + ], + Some(input.issue_ref), + true, + ); + self.policies.set_policy(requester.clone(), policy); + let environment = match self + .environment + .prepare_ticket_assistant( + &input.project, + input.issue_ref, + &profile, + &prepared, + &requester, + ) + .await + { + Ok(environment) => environment, + Err(err) => { + if !had_existing { + self.policies.clear_policy(&requester); + } + return Err(AppError::from(err)); + } + }; let session = self .factory .start( &profile, &prepared, - &input.project.root, + &environment.cwd, &SessionPlan::None, - &[], + &environment.env, None, ) .await - .map_err(AppError::from)?; + .map_err(|err| { + if !had_existing { + self.policies.clear_policy(&requester); + } + AppError::from(err) + })?; let session_id = session.id(); - let requester = session_id.to_string(); - let policy = AgentToolPolicy::new( - vec![ - "idea_ticket_read".to_owned(), - "idea_ticket_update".to_owned(), - "idea_ticket_update_carnet".to_owned(), - ], - Some(input.issue_ref), - true, - ); if let Some(old_requester) = self.structured.ticket_assistant_requester(input.issue_ref) { - self.policies.clear_policy(&old_requester); + if old_requester != requester { + self.policies.clear_policy(&old_requester); + } } let replaced = self.structured.insert_ticket_assistant( input.issue_ref, requester.clone(), Arc::clone(&session), ); - self.policies.set_policy(requester.clone(), policy); if let Some(old) = replaced { let _ = old.shutdown().await; } @@ -143,6 +181,14 @@ impl OpenTicketAssistant { } } +fn ticket_assistant_requester(project: &Project, issue_ref: IssueRef) -> String { + format!( + "ticket-assistant:{}:{}", + project.id.as_uuid().simple(), + issue_ref.number().get() + ) +} + /// Closes an ephemeral ticket assistant chat session. pub struct CloseTicketAssistant { structured: Arc, diff --git a/crates/application/tests/ticket_assistant.rs b/crates/application/tests/ticket_assistant.rs index 48d9d7d..852577a 100644 --- a/crates/application/tests/ticket_assistant.rs +++ b/crates/application/tests/ticket_assistant.rs @@ -8,7 +8,8 @@ use application::{ }; use async_trait::async_trait; use domain::ports::{ - AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, SessionPlan, + AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, RuntimeError, SessionPlan, + StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, }; use domain::profile::StructuredAdapter; use domain::{ @@ -189,6 +190,35 @@ impl AssistantContextProvider for FakeAssistantContext { } } +#[derive(Default)] +struct FakeEnvironmentPreparer { + calls: Mutex>, +} + +#[async_trait] +impl StructuredSessionEnvironmentPreparer for FakeEnvironmentPreparer { + async fn prepare_ticket_assistant( + &self, + _project: &Project, + issue_ref: IssueRef, + _profile: &AgentProfile, + prepared: &PreparedContext, + requester: &str, + ) -> Result { + self.calls + .lock() + .unwrap() + .push((issue_ref, requester.to_owned(), prepared.clone())); + Ok(StructuredSessionEnvironment { + cwd: ProjectPath::new("/tmp/app-data/assistant/tickets/1/7").unwrap(), + env: vec![( + "CODEX_HOME".to_owned(), + "/tmp/app-data/assistant/tickets/1/7/.codex".to_owned(), + )], + }) + } +} + struct FakeSession { id: SessionId, shutdowns: Arc>, @@ -216,7 +246,15 @@ impl AgentSession for FakeSession { #[derive(Default)] struct FakeFactory { - starts: Mutex)>>, + starts: Mutex< + Vec<( + PreparedContext, + ProjectPath, + SessionPlan, + Vec<(String, String)>, + Option, + )>, + >, shutdowns: Arc>, } @@ -230,15 +268,18 @@ impl AgentSessionFactory for FakeFactory { &self, _profile: &AgentProfile, ctx: &PreparedContext, - _cwd: &ProjectPath, + cwd: &ProjectPath, session: &SessionPlan, - _env: &[(String, String)], + env: &[(String, String)], sandbox: Option<&domain::SandboxPlan>, ) -> Result, AgentSessionError> { - self.starts - .lock() - .unwrap() - .push((ctx.clone(), session.clone(), sandbox.cloned())); + self.starts.lock().unwrap().push(( + ctx.clone(), + cwd.clone(), + session.clone(), + env.to_vec(), + sandbox.cloned(), + )); Ok(Arc::new(FakeSession { id: SessionId::new_random(), shutdowns: Arc::clone(&self.shutdowns), @@ -284,6 +325,7 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_ let policies = Arc::new(FakePolicies::default()); let events = Arc::new(SpyBus::default()); let context = Arc::new(FakeAssistantContext::default()); + let environment = Arc::new(FakeEnvironmentPreparer::default()); let factory = Arc::new(FakeFactory::default()); let open = OpenTicketAssistant::new( Arc::new(FakeIssues { @@ -293,6 +335,7 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_ profile: profile(profile_id), }), context.clone(), + environment.clone(), factory.clone(), structured.clone(), policies.clone(), @@ -310,17 +353,42 @@ async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_ .unwrap(); assert!(structured.session(&output.session_id).is_some()); + assert_eq!( + output.requester, + "ticket-assistant:00000000000000000000000000000001:7" + ); let policy = policies.get_policy(&output.requester).expect("policy set"); assert!(policy.permits("idea_ticket_read")); assert!(policy.permits_ticket_mutation("idea_ticket_update", issue.reference())); + assert!(policy.permits_ticket_mutation("idea_ticket_update_status", issue.reference())); + assert!(policy.permits_ticket_mutation("idea_ticket_update_priority", issue.reference())); + assert!(policy.permits_ticket_mutation("idea_ticket_update_carnet", issue.reference())); + assert!(policy.permits_ticket_mutation("idea_ticket_link", issue.reference())); + assert!(policy.permits_ticket_mutation("idea_ticket_unlink", issue.reference())); + assert!(!policy.permits("idea_ticket_create")); + assert!(!policy.permits("idea_ticket_list")); + assert!(!policy.permits("idea_ask_agent")); assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8"))); let prepared = context.prepared.lock().unwrap().clone().unwrap(); assert!(prepared.content.as_str().contains("#7")); assert!(prepared.content.as_str().contains("Ticket description")); assert!(prepared.content.as_str().contains("Ticket carnet")); + let environment_calls = environment.calls.lock().unwrap(); + assert_eq!(environment_calls.len(), 1); + assert_eq!(environment_calls[0].0, issue.reference()); + assert_eq!(environment_calls[0].1, output.requester); + drop(environment_calls); let starts = factory.starts.lock().unwrap(); - assert!(matches!(starts[0].1, SessionPlan::None)); - assert!(starts[0].2.is_none()); + 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, + vec![( + "CODEX_HOME".to_owned(), + "/tmp/app-data/assistant/tickets/1/7/.codex".to_owned() + )] + ); + assert!(starts[0].4.is_none()); drop(starts); close diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 50d0648..3f9b8f2 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -202,6 +202,7 @@ pub use ports::{ IssueStoreError, LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, - ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, TemplateStore, + ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, + StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, TemplateStore, WindowStateStore, }; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 6f62457..a7a4592 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -128,6 +128,15 @@ pub struct PreparedContext { pub project_root: String, } +/// Prepared environment for a structured assistant session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StructuredSessionEnvironment { + /// Isolated cwd where the structured CLI must run. + pub cwd: ProjectPath, + /// Environment variables to pass to the structured session. + pub env: Vec<(String, String)>, +} + /// Errors returned while preparing the IdeA-owned ticket assistant context. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum AssistantContextError { @@ -153,6 +162,23 @@ pub trait AssistantContextProvider: Send + Sync { ) -> Result; } +/// Prepares the isolated run environment for a structured ticket assistant. +#[async_trait] +pub trait StructuredSessionEnvironmentPreparer: Send + Sync { + /// Materialises context and MCP configuration for a ticket assistant session. + /// + /// # Errors + /// [`RuntimeError`] when the structured environment cannot be prepared. + async fn prepare_ticket_assistant( + &self, + project: &Project, + issue_ref: IssueRef, + profile: &AgentProfile, + prepared: &PreparedContext, + requester: &str, + ) -> Result; +} + /// Stores requester-scoped MCP tool policies for ephemeral assistant sessions. pub trait AgentToolPolicyStore: Send + Sync { /// Sets or replaces the policy for `requester`. diff --git a/crates/infrastructure/src/assistant/mod.rs b/crates/infrastructure/src/assistant/mod.rs index ce6861c..262f75d 100644 --- a/crates/infrastructure/src/assistant/mod.rs +++ b/crates/infrastructure/src/assistant/mod.rs @@ -2,16 +2,26 @@ use std::sync::Arc; +use application::McpRuntime; use async_trait::async_trait; +use domain::ports::SessionPlan; +use domain::profile::{McpConfigStrategy, StructuredAdapter}; use domain::{ - AssistantContextError, AssistantContextProvider, FileSystem, FsError, Issue, MarkdownDoc, - PreparedContext, Project, RemotePath, + AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider, + ContextInjectionPlan, FileSystem, FsError, Issue, IssueRef, MarkdownDoc, McpServerWiring, + PreparedContext, Project, ProjectPath, RemotePath, RuntimeError, StructuredSessionEnvironment, + StructuredSessionEnvironmentPreparer, }; +use serde_json::{json, Map, Value}; const ASSISTANT_DIR: &str = "assistant"; const TICKET_ASSISTANT_FILE: &str = "ticket-assistant.md"; const DEFAULT_TICKET_ASSISTANT_CONTEXT: &str = include_str!("default_ticket_assistant.md"); +/// Resolves the live MCP runtime facts for a ticket assistant requester. +pub type TicketAssistantMcpRuntimeResolver = + dyn Fn(&Project, &str) -> Option + Send + Sync; + /// File-backed provider for the IdeA-owned ticket assistant context. #[derive(Clone)] pub struct FsAssistantContextStore { @@ -74,3 +84,400 @@ impl AssistantContextProvider for FsAssistantContextStore { }) } } + +/// File-backed environment preparer for structured ticket assistant sessions. +#[derive(Clone)] +pub struct TicketAssistantEnvironmentPreparer { + fs: Arc, + app_data_dir: String, + runtime: Arc, + mcp_runtime: Arc, +} + +impl TicketAssistantEnvironmentPreparer { + /// Builds the preparer from filesystem, app-data dir, runtime and MCP resolver. + #[must_use] + pub fn new( + fs: Arc, + app_data_dir: impl Into, + runtime: Arc, + mcp_runtime: Arc, + ) -> Self { + Self { + fs, + app_data_dir: app_data_dir.into(), + runtime, + mcp_runtime, + } + } + + fn run_dir(&self, project: &Project, issue_ref: IssueRef) -> Result { + let base = self.app_data_dir.trim_end_matches(['/', '\\']); + let project_id = project.id.as_uuid().simple(); + let issue_number = issue_ref.number().get(); + ProjectPath::new(format!( + "{base}/{ASSISTANT_DIR}/tickets/{project_id}/{issue_number}" + )) + .map_err(|e| RuntimeError::Invocation(e.to_string())) + } + + async fn create_dir(&self, path: &str) -> Result<(), RuntimeError> { + self.fs + .create_dir_all(&RemotePath::new(path.to_owned())) + .await + .map_err(|e| RuntimeError::Invocation(e.to_string())) + } + + async fn write_file(&self, path: &str, bytes: &[u8]) -> Result<(), RuntimeError> { + if let Some(parent) = parent_abs(path) { + self.create_dir(parent).await?; + } + self.fs + .write(&RemotePath::new(path.to_owned()), bytes) + .await + .map_err(|e| RuntimeError::Invocation(e.to_string())) + } + + async fn read_optional(&self, path: &str) -> Result, RuntimeError> { + match self.fs.read(&RemotePath::new(path.to_owned())).await { + Ok(bytes) => String::from_utf8(bytes) + .map(Some) + .map_err(|e| RuntimeError::Invocation(e.to_string())), + Err(FsError::NotFound(_)) => Ok(None), + Err(e) => Err(RuntimeError::Invocation(e.to_string())), + } + } + + async fn materialise_context( + &self, + plan: Option, + cwd: &ProjectPath, + prepared: &PreparedContext, + env: &mut Vec<(String, String)>, + ) -> Result<(), RuntimeError> { + match plan { + Some(ContextInjectionPlan::File { target }) => { + self.write_file(&join(cwd, &target), prepared.content.as_str().as_bytes()) + .await?; + } + Some(ContextInjectionPlan::Env { var }) => { + let path = join(cwd, &prepared.relative_path); + self.write_file(&path, prepared.content.as_str().as_bytes()) + .await?; + env.push((var, path)); + } + Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => { + let path = join(cwd, &prepared.relative_path); + self.write_file(&path, prepared.content.as_str().as_bytes()) + .await?; + } + } + Ok(()) + } + + async fn materialise_mcp( + &self, + project: &Project, + profile: &AgentProfile, + cwd: &ProjectPath, + requester: &str, + env: &mut Vec<(String, String)>, + ) -> Result<(), RuntimeError> { + let Some(mcp) = &profile.mcp else { + return Ok(()); + }; + let runtime = (self.mcp_runtime)(project, requester); + match &mcp.config { + McpConfigStrategy::ConfigFile { target } => { + let declaration = mcp_server_wiring(mcp.transport, runtime.as_ref()).to_mcp_json(); + self.write_file(&join(cwd, target), declaration.as_bytes()) + .await?; + } + McpConfigStrategy::TomlConfigHome { target, home_env } => { + let path = join(cwd, target); + let declaration = + mcp_server_wiring(mcp.transport, runtime.as_ref()).to_config_toml(); + let existing = self.read_optional(&path).await?; + let rendered = codex_config_toml( + existing.as_deref(), + &declaration, + cwd.as_str(), + project.root.as_str(), + ); + self.write_file(&path, rendered.as_bytes()).await?; + env.push((home_env.clone(), parent_dir(cwd, target))); + } + McpConfigStrategy::OpenCodeConfig { target } => { + if profile.structured_adapter != Some(StructuredAdapter::OpenCode) { + return Ok(()); + } + let Some(opencode) = profile.opencode.as_ref() else { + return Ok(()); + }; + let config_path = join(cwd, target); + let opencode_home = join(cwd, ".opencode"); + let xdg_config = format!("{opencode_home}/config"); + let xdg_data = format!("{opencode_home}/data"); + let xdg_cache = format!("{opencode_home}/cache"); + for dir in [&opencode_home, &xdg_config, &xdg_data, &xdg_cache] { + self.create_dir(dir).await?; + } + let body = opencode_config_json(opencode, project.root.as_str(), runtime.as_ref()) + .to_string(); + self.write_file(&config_path, body.as_bytes()).await?; + env.extend([ + ("OPENCODE_CONFIG".to_owned(), config_path), + ("HOME".to_owned(), opencode_home), + ("XDG_CONFIG_HOME".to_owned(), xdg_config), + ("XDG_DATA_HOME".to_owned(), xdg_data), + ("XDG_CACHE_HOME".to_owned(), xdg_cache), + ("OPENCODE_DISABLE_AUTOUPDATE".to_owned(), "1".to_owned()), + ]); + } + McpConfigStrategy::Flag { flag } => { + env.push((flag.clone(), cwd.as_str().to_owned())); + } + McpConfigStrategy::Env { var } => { + env.push((var.clone(), cwd.as_str().to_owned())); + } + } + Ok(()) + } +} + +#[async_trait] +impl StructuredSessionEnvironmentPreparer for TicketAssistantEnvironmentPreparer { + async fn prepare_ticket_assistant( + &self, + project: &Project, + issue_ref: IssueRef, + profile: &AgentProfile, + prepared: &PreparedContext, + requester: &str, + ) -> Result { + let run_dir = self.run_dir(project, issue_ref)?; + self.create_dir(run_dir.as_str()).await?; + let spec = + self.runtime + .prepare_invocation(profile, prepared, &run_dir, &SessionPlan::None)?; + let mut env = spec.env; + self.materialise_context(spec.context_plan, &spec.cwd, prepared, &mut env) + .await?; + self.materialise_mcp(project, profile, &spec.cwd, requester, &mut env) + .await?; + Ok(StructuredSessionEnvironment { cwd: spec.cwd, env }) + } +} + +fn mcp_server_wiring( + transport: domain::profile::McpTransport, + runtime: Option<&McpRuntime>, +) -> McpServerWiring { + let (command, args) = match runtime { + Some(rt) => ( + rt.exe.clone(), + vec![ + "mcp-server".to_owned(), + "--endpoint".to_owned(), + rt.endpoint.clone(), + "--project".to_owned(), + rt.project_id.clone(), + "--requester".to_owned(), + rt.requester.clone(), + ], + ), + None => ("idea".to_owned(), vec!["mcp-server".to_owned()]), + }; + McpServerWiring::new(command, args, transport) +} + +fn opencode_config_json( + config: &domain::profile::OpenCodeConfig, + project_root: &str, + runtime: Option<&McpRuntime>, +) -> Value { + let model = config.model.as_str(); + let opencode_model = format!("llamacpp/{model}"); + let mut root = Map::new(); + root.insert( + "$schema".to_owned(), + Value::String("https://opencode.ai/config.json".to_owned()), + ); + root.insert("model".to_owned(), Value::String(opencode_model.clone())); + + let mut options = Map::new(); + options.insert("baseURL".to_owned(), Value::String(config.base_url.clone())); + if let Some(api_key) = config.api_key.as_ref() { + options.insert("apiKey".to_owned(), Value::String(api_key.clone())); + } + + root.insert( + "provider".to_owned(), + json!({ + "llamacpp": { + "npm": "@ai-sdk/openai-compatible", + "name": "llama.cpp", + "options": options, + "models": { + model: { + "name": opencode_model, + "tool_call": true, + "reasoning": config.reasoning_enabled(), + "attachment": config.attachment_enabled() + } + } + } + }), + ); + + let (command, args) = match runtime { + Some(rt) => ( + rt.exe.clone(), + vec![ + "mcp-server".to_owned(), + "--endpoint".to_owned(), + rt.endpoint.clone(), + "--project".to_owned(), + rt.project_id.clone(), + "--requester".to_owned(), + rt.requester.clone(), + ], + ), + None => ("idea".to_owned(), vec!["mcp-server".to_owned()]), + }; + let command_array = std::iter::once(command) + .chain(args) + .map(Value::String) + .collect::>(); + root.insert( + "mcp".to_owned(), + json!({ + "idea": { + "type": "local", + "command": command_array, + "cwd": project_root, + "enabled": true, + "timeout": 15000 + } + }), + ); + root.insert( + "permission".to_owned(), + json!({ + "bash": "ask", + "edit": "ask" + }), + ); + root.insert( + "disabled_providers".to_owned(), + json!(["anthropic", "openai", "gemini", "ollama"]), + ); + Value::Object(root) +} + +fn codex_config_toml( + existing: Option<&str>, + mcp_declaration: &str, + run_dir: &str, + project_root: &str, +) -> String { + let mut text = replace_toml_table_block( + existing.unwrap_or_default(), + "mcp_servers.idea", + mcp_declaration.trim_end(), + ); + text = ensure_codex_project_trust(&text, run_dir); + text = ensure_codex_project_trust(&text, project_root); + if !text.ends_with('\n') { + text.push('\n'); + } + text +} + +fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String { + let header = format!("[{table}]"); + let mut out = Vec::new(); + let mut skipping = false; + let mut inserted = false; + + for line in existing.lines() { + let trimmed = line.trim(); + if trimmed == header { + if !inserted { + out.extend(replacement.lines().map(ToOwned::to_owned)); + inserted = true; + } + skipping = true; + continue; + } + if skipping && trimmed.starts_with('[') && trimmed.ends_with(']') { + skipping = false; + } + if !skipping { + out.push(line.to_owned()); + } + } + + if !inserted { + if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) { + out.push(String::new()); + } + out.extend(replacement.lines().map(ToOwned::to_owned)); + } + + out.join("\n") +} + +fn ensure_codex_project_trust(existing: &str, path: &str) -> String { + if path.is_empty() { + return existing.to_owned(); + } + let header = format!(r#"[projects.{}]"#, toml_quoted(path)); + if existing.lines().any(|line| line.trim() == header) { + return existing.to_owned(); + } + let mut text = existing.trim_end().to_owned(); + if !text.is_empty() { + text.push_str("\n\n"); + } + text.push_str(&header); + text.push_str("\ntrust_level = \"trusted\"\n"); + text +} + +fn toml_quoted(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +fn join(base: &ProjectPath, rel: &str) -> String { + let base = base.as_str().trim_end_matches(['/', '\\']); + format!("{base}/{rel}") +} + +fn parent_abs(path: &str) -> Option<&str> { + path.rsplit_once(['/', '\\']) + .map(|(parent, _)| parent) + .filter(|parent| !parent.is_empty()) +} + +fn parent_dir(base: &ProjectPath, rel: &str) -> String { + let full = join(base, rel); + match full.rsplit_once(['/', '\\']) { + Some((parent, _)) if !parent.is_empty() => parent.to_owned(), + _ => base.as_str().trim_end_matches(['/', '\\']).to_owned(), + } +} diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index c6668c0..23743c2 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -41,7 +41,7 @@ pub mod sprints; pub mod store; pub mod timeparse; -pub use assistant::FsAssistantContextStore; +pub use assistant::{FsAssistantContextStore, TicketAssistantEnvironmentPreparer}; pub use background_task::{ bounded_tail, start_background_ready_inbox_bridge, tail_cap_bytes, BackgroundCompletionSink, BackgroundCompletionSinkError, BackgroundCompletionSinkOutcome, diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index a0ee572..87197e4 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -482,7 +482,15 @@ impl McpServer { ), )); } - if matches!(name, "idea_ticket_update" | "idea_ticket_update_carnet") { + if matches!( + name, + "idea_ticket_update" + | "idea_ticket_update_status" + | "idea_ticket_update_priority" + | "idea_ticket_update_carnet" + | "idea_ticket_link" + | "idea_ticket_unlink" + ) { let raw_ref = arguments .get("ref") .and_then(Value::as_str) diff --git a/crates/infrastructure/tests/assistant_context_store.rs b/crates/infrastructure/tests/assistant_context_store.rs index abd96c1..448178b 100644 --- a/crates/infrastructure/tests/assistant_context_store.rs +++ b/crates/infrastructure/tests/assistant_context_store.rs @@ -1,11 +1,19 @@ use std::path::PathBuf; use std::sync::Arc; +use application::McpRuntime; +use async_trait::async_trait; +use domain::ports::{SessionPlan, StructuredSessionEnvironmentPreparer}; +use domain::profile::{McpCapability, McpConfigStrategy, McpTransport}; use domain::{ - AssistantContextProvider, FileSystem, Issue, IssueActor, IssueId, IssueNumber, IssuePriority, - IssueStatus, MarkdownDoc, Project, ProjectId, ProjectPath, + AgentProfile, AgentRuntime, AssistantContextProvider, ContextInjection, ContextInjectionPlan, + FileSystem, Issue, IssueActor, IssueId, IssueNumber, IssuePriority, IssueStatus, MarkdownDoc, + PreparedContext, ProfileId, Project, ProjectId, ProjectPath, RemotePath, RuntimeError, + SpawnSpec, +}; +use infrastructure::{ + FsAssistantContextStore, LocalFileSystem, TicketAssistantEnvironmentPreparer, }; -use infrastructure::{FsAssistantContextStore, LocalFileSystem}; use uuid::Uuid; struct TempDir(PathBuf); @@ -65,6 +73,55 @@ fn store(app_data_dir: String) -> FsAssistantContextStore { FsAssistantContextStore::new(fs, app_data_dir) } +struct FakeRuntime; + +#[async_trait] +impl AgentRuntime for FakeRuntime { + async fn detect(&self, _profile: &AgentProfile) -> Result { + Ok(true) + } + + fn prepare_invocation( + &self, + _profile: &AgentProfile, + _ctx: &PreparedContext, + cwd: &ProjectPath, + _session: &SessionPlan, + ) -> Result { + Ok(SpawnSpec { + command: "assistant-cli".to_owned(), + args: Vec::new(), + cwd: cwd.clone(), + env: Vec::new(), + context_plan: Some(ContextInjectionPlan::File { + // Deliberately looks like the real ticket carnet path. The preparer + // must still materialise it under the isolated assistant cwd, never + // under the project root. + target: ".ideai/tickets/7/carnet.md".to_owned(), + }), + sandbox: None, + }) + } +} + +fn mcp_profile() -> AgentProfile { + AgentProfile::new( + ProfileId::from_uuid(Uuid::from_u128(9)), + "Claude", + "claude", + Vec::new(), + ContextInjection::stdin(), + None, + "{projectRoot}", + None, + ) + .unwrap() + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").unwrap(), + McpTransport::Stdio, + )) +} + #[tokio::test] async fn default_context_is_embedded_and_injects_the_ticket() { let tmp = TempDir::new(); @@ -108,3 +165,83 @@ async fn app_data_override_replaces_the_embedded_default() { assert!(!body.contains("You are IdeA's ticket editing assistant")); assert!(body.contains("- Ref: #8")); } + +#[tokio::test] +async fn environment_preparer_materialises_context_and_mcp_under_isolated_app_data() { + let tmp = TempDir::new(); + let fs: Arc = Arc::new(LocalFileSystem::new()); + let app_data_dir = tmp.app_data_dir(); + let project = project(tmp.project_root()); + let project_carnet = PathBuf::from(project.root.as_str()) + .join(".ideai") + .join("tickets") + .join("7") + .join("carnet.md"); + let prepared = PreparedContext { + content: MarkdownDoc::new("assistant-only context"), + relative_path: "ticket-assistant.md".to_owned(), + project_root: project.root.as_str().to_owned(), + }; + let requester = "ticket-assistant:00000000000000000000000000000001:7".to_owned(); + let preparer = TicketAssistantEnvironmentPreparer::new( + fs.clone(), + app_data_dir.clone(), + Arc::new(FakeRuntime), + Arc::new(move |_, _| { + Some(McpRuntime { + exe: "/opt/idea/idea".to_owned(), + endpoint: "127.0.0.1:4567".to_owned(), + project_id: "00000000000000000000000000000001".to_owned(), + requester: requester.clone(), + }) + }), + ); + + let env = preparer + .prepare_ticket_assistant( + &project, + issue(7).reference(), + &mcp_profile(), + &prepared, + "ticket-assistant:00000000000000000000000000000001:7", + ) + .await + .unwrap(); + + let run_dir = PathBuf::from(&app_data_dir) + .join("assistant") + .join("tickets") + .join("00000000000000000000000000000001") + .join("7"); + assert_eq!(env.cwd.as_str(), run_dir.to_string_lossy()); + + let isolated_carnet = run_dir + .join(".ideai") + .join("tickets") + .join("7") + .join("carnet.md"); + let isolated_mcp = run_dir.join(".mcp.json"); + assert_eq!( + fs.read(&RemotePath::new( + isolated_carnet.to_string_lossy().into_owned() + )) + .await + .unwrap(), + b"assistant-only context" + ); + let mcp = String::from_utf8( + fs.read(&RemotePath::new( + isolated_mcp.to_string_lossy().into_owned(), + )) + .await + .unwrap(), + ) + .unwrap(); + assert!(mcp.contains(r#""idea""#)); + assert!(mcp.contains(r#""--requester""#)); + assert!(mcp.contains("ticket-assistant:00000000000000000000000000000001:7")); + assert!( + !project_carnet.exists(), + "assistant context must not be written into the real project ticket carnet" + ); +} diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 704ace1..fd8b6b3 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -830,6 +830,129 @@ async fn ticket_update_on_other_issue_is_rejected_before_ticket_provider() { assert_eq!(ticket_tools.mutation_attempts(), 0); } +#[tokio::test] +async fn bounded_ticket_mutation_tools_reject_other_issue_before_ticket_provider() { + for (id, tool, arguments) in [ + ( + 21, + "idea_ticket_update_status", + json!({ "ref": "#8", "expectedVersion": 1, "status": "closed" }), + ), + ( + 22, + "idea_ticket_update_priority", + json!({ "ref": "#8", "expectedVersion": 1, "priority": "high" }), + ), + ( + 23, + "idea_ticket_link", + json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }), + ), + ( + 24, + "idea_ticket_unlink", + json!({ "ref": "#8", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }), + ), + ] { + let (service, _s) = build_service(FakeContexts::new()); + let registry = Arc::new(ToolPolicyRegistry::new()); + registry.set( + "assistant-req", + AgentToolPolicy::new( + vec![tool.to_owned()], + Some(IssueRef::from_str("#7").unwrap()), + true, + ), + ); + let ticket_tools = Arc::new(FakeTicketTools::default()); + let server = server(service) + .with_ticket_tools(ticket_tools.clone()) + .with_tool_policies(registry) + .for_requester("assistant-req"); + + let raw = tools_call(id, tool, arguments); + let response = server.handle_raw(&raw).await.expect("reply owed"); + let error = response.error.expect("policy rejection expected"); + assert_eq!(error.code, error_codes::INVALID_PARAMS, "tool {tool}"); + assert!( + error.message.contains("#8"), + "message should name the rejected issue for {tool}; got {}", + error.message + ); + assert!( + ticket_tools.calls().is_empty(), + "policy rejection for {tool} must happen before ticket provider dispatch" + ); + assert_eq!( + ticket_tools.mutation_attempts(), + 0, + "no mutation attempt expected for rejected {tool}" + ); + } +} + +#[tokio::test] +async fn bounded_ticket_mutation_tools_allow_bound_issue_to_reach_ticket_provider() { + for (id, tool, arguments) in [ + ( + 31, + "idea_ticket_update_status", + json!({ "ref": "#7", "expectedVersion": 1, "status": "closed" }), + ), + ( + 32, + "idea_ticket_update_priority", + json!({ "ref": "#7", "expectedVersion": 1, "priority": "high" }), + ), + ( + 33, + "idea_ticket_link", + json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }), + ), + ( + 34, + "idea_ticket_unlink", + json!({ "ref": "#7", "targetRef": "#9", "kind": "blocks", "expectedVersion": 1 }), + ), + ] { + let (service, _s) = build_service(FakeContexts::new()); + let registry = Arc::new(ToolPolicyRegistry::new()); + registry.set( + "assistant-req", + AgentToolPolicy::new( + vec![tool.to_owned()], + Some(IssueRef::from_str("#7").unwrap()), + true, + ), + ); + let ticket_tools = Arc::new(FakeTicketTools::default()); + let server = server(service) + .with_ticket_tools(ticket_tools.clone()) + .with_tool_policies(registry) + .for_requester("assistant-req"); + + let raw = tools_call(id, tool, arguments); + let response = server.handle_raw(&raw).await.expect("reply owed"); + assert!( + response.error.is_none(), + "bound issue should pass policy for {tool}, got {:?}", + response.error + ); + let result = response.result.expect("tool result"); + assert_eq!( + result["isError"], + json!(true), + "fake provider intentionally returns a tool error after policy passes" + ); + assert_eq!(ticket_tools.calls(), vec![tool.to_owned()]); + assert_eq!( + ticket_tools.mutation_attempts(), + 1, + "bound {tool} should reach the ticket provider exactly once" + ); + } +} + // --------------------------------------------------------------------------- // 3. Inter-agent delegation is exposed through MCP; reply protocol is not // ---------------------------------------------------------------------------