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 // ---------------------------------------------------------------------------