//! Ticket assistant infrastructure adapters. use std::sync::Arc; use application::McpRuntime; use async_trait::async_trait; use domain::ports::{SecretStore, SessionPlan}; use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter}; use domain::{ AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider, ContextInjectionPlan, EffectivePermissions, 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 { fs: Arc, app_data_dir: String, } impl FsAssistantContextStore { /// Builds the store from an injected filesystem and app-data directory. #[must_use] pub fn new(fs: Arc, app_data_dir: impl Into) -> Self { Self { fs, app_data_dir: app_data_dir.into(), } } fn join(&self, rel: &str) -> String { let base = self.app_data_dir.trim_end_matches(['/', '\\']); format!("{base}/{rel}") } fn context_path(&self) -> RemotePath { RemotePath::new(self.join(&format!("{ASSISTANT_DIR}/{TICKET_ASSISTANT_FILE}"))) } async fn read_base_context(&self) -> Result { let path = self.context_path(); match self.fs.read(&path).await { Ok(bytes) => { String::from_utf8(bytes).map_err(|e| AssistantContextError::Store(e.to_string())) } Err(FsError::NotFound(_)) => Ok(DEFAULT_TICKET_ASSISTANT_CONTEXT.to_owned()), Err(e) => Err(AssistantContextError::Store(e.to_string())), } } } #[async_trait] impl AssistantContextProvider for FsAssistantContextStore { async fn prepare_ticket_assistant_context( &self, project: &Project, issue: &Issue, ) -> Result { let mut content = self.read_base_context().await?; content.push_str("\n\n## Bound Ticket\n\n"); content.push_str(&format!("- Ref: {}\n", issue.reference())); content.push_str(&format!("- Title: {}\n", issue.title)); content.push_str("\n### Description\n\n"); content.push_str(issue.description.as_str()); content.push_str("\n\n### Carnet\n\n"); content.push_str(issue.carnet.as_str()); content.push('\n'); Ok(PreparedContext { content: MarkdownDoc::new(content), relative_path: TICKET_ASSISTANT_FILE.to_owned(), project_root: project.root.as_str().to_owned(), }) } } /// 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, /// Resolves the literal API key of an [`OpenCodeProviderConfig`] (ticket #92, /// lot B3) just before writing `opencode.json`. A missing/undecryptable /// secret fails the ticket-assistant launch — see /// [`Self::resolve_opencode_provider_api_key`]. secret_store: Arc, } impl TicketAssistantEnvironmentPreparer { /// Builds the preparer from filesystem, app-data dir, runtime, MCP resolver /// and secret store. #[must_use] pub fn new( fs: Arc, app_data_dir: impl Into, runtime: Arc, mcp_runtime: Arc, secret_store: Arc, ) -> Self { Self { fs, app_data_dir: app_data_dir.into(), runtime, mcp_runtime, secret_store, } } /// Resolves the literal API key of an [`OpenCodeProviderConfig`] through the /// injected [`SecretStore`] (ticket #92, lot B3). Fails hard — never spawns /// the ticket assistant with a dead/absent key. /// /// # Errors /// [`RuntimeError::Invocation`] if the secret is absent (corrupted/deleted out /// from under the profile) or the store fails. async fn resolve_opencode_provider_api_key( &self, provider: &OpenCodeProviderConfig, ) -> Result { self.secret_store .get(&provider.api_key_ref) .await .map_err(|e| RuntimeError::Invocation(e.to_string()))? .ok_or_else(|| { RuntimeError::Invocation(format!( "no secret found for OpenCode provider `{}` (secret ref `{}`)", provider.provider_id, provider.api_key_ref.as_str() )) }) } 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(), profile.model.as_deref(), ); 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(()); } // No `PermissionStore` is wired for ticket-assistant sessions (no // per-agent policy exists in this path, for any CLI) — `eff` is // always `None` here, which per `opencode_permission_block`'s // contract omits the `permission` key entirely and preserves // OpenCode's native prompting, matching Claude/Codex's behaviour // on this same path. let body = if let Some(opencode) = profile.opencode.as_ref() { opencode_config_json(opencode, project.root.as_str(), runtime.as_ref(), None) .to_string() } else if let Some(provider) = profile.opencode_provider.as_ref() { let api_key = self.resolve_opencode_provider_api_key(provider).await?; opencode_provider_config_json( provider, &api_key, project.root.as_str(), runtime.as_ref(), None, ) .to_string() } 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?; } 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, structured_policy: None, }) } } 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>, eff: Option<&EffectivePermissions>, ) -> 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": application::agent::resolve_opencode_mcp_timeout_ms() } }), ); if let Some(permission) = domain::opencode_permission_block(eff) { root.insert("permission".to_owned(), permission); } root.insert( "disabled_providers".to_owned(), json!(["anthropic", "openai", "gemini", "ollama"]), ); Value::Object(root) } /// Renders the OpenCode config for a **cloud** provider profile (ticket #92, lot /// B3), mirroring [`opencode_config_json`]. See the sibling implementation in /// `application::agent::lifecycle` for the rationale (duplication flagged as /// separate debt, not tripled here). fn opencode_provider_config_json( config: &OpenCodeProviderConfig, api_key: &str, project_root: &str, runtime: Option<&McpRuntime>, eff: Option<&EffectivePermissions>, ) -> Value { 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(format!("{}/{}", config.provider_id, config.model)), ); let mut options = Map::new(); options.insert("apiKey".to_owned(), Value::String(api_key.to_owned())); let mut provider_entry = Map::new(); if let Some(custom) = config.custom.as_ref() { // Provider outside the OpenCode registry: it needs the AI SDK package // (`npm`), the endpoint (`options.baseURL`), and a `models` block — // OpenCode has no built-in knowledge of this provider otherwise. options.insert("baseURL".to_owned(), Value::String(custom.base_url.clone())); provider_entry.insert("npm".to_owned(), Value::String(custom.npm.clone())); let model_label = custom .display_name .clone() .unwrap_or_else(|| config.model.clone()); provider_entry.insert( "models".to_owned(), json!({ config.model.as_str(): { "name": model_label } }), ); } provider_entry.insert("options".to_owned(), Value::Object(options)); root.insert( "provider".to_owned(), json!({ config.provider_id.as_str(): provider_entry }), ); 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": application::agent::resolve_opencode_mcp_timeout_ms() } }), ); if let Some(permission) = domain::opencode_permission_block(eff) { root.insert("permission".to_owned(), permission); } Value::Object(root) } fn codex_config_toml( existing: Option<&str>, mcp_declaration: &str, run_dir: &str, project_root: &str, model: Option<&str>, ) -> String { let mut text = existing.unwrap_or_default().to_owned(); if let Some(model) = model { text = set_top_level_toml_value(&text, "model", model); } text = replace_toml_table_block(&text, "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 set_top_level_toml_value(input: &str, key: &str, value: &str) -> String { let line = format!("{key} = {}", toml_quoted(value)); set_top_level_toml_line(input, key, &line) } fn set_top_level_toml_line(input: &str, key: &str, replacement: &str) -> String { let needle = format!("{key} ="); let mut out = Vec::new(); let mut replaced = false; for line in input.lines() { let trimmed = line.trim_start(); if !replaced && trimmed.starts_with(&needle) { out.push(replacement.to_owned()); replaced = true; } else { out.push(line.to_owned()); } } if !replaced { if !out.is_empty() && !out.first().is_some_and(|line| line.trim().starts_with('[')) { out.push(String::new()); } out.insert(0, replacement.to_owned()); } out.join("\n") } 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(), } } #[cfg(test)] mod opencode_provider_config_json_tests { use domain::ports::SecretRef; use domain::profile::CustomProviderConfig; use super::*; fn known_provider_config() -> OpenCodeProviderConfig { OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", SecretRef::new("secret-ref")) .unwrap() } fn custom_provider_config() -> OpenCodeProviderConfig { let custom = CustomProviderConfig::new("@ai-sdk/openai-compatible", "https://my-endpoint/v1", None) .unwrap(); OpenCodeProviderConfig::new("my-custom", "my-model", SecretRef::new("secret-ref")) .unwrap() .with_custom(custom) } #[test] fn known_provider_emits_only_the_api_key_option() { let config = known_provider_config(); let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None); let provider = &body["provider"]["anthropic"]; assert_eq!(provider["options"]["apiKey"], "sk-live"); assert!(provider["options"].get("baseURL").is_none()); assert!(provider.get("npm").is_none()); assert!(provider.get("models").is_none()); } #[test] fn custom_provider_emits_npm_base_url_and_models() { let config = custom_provider_config(); let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None); let provider = &body["provider"]["my-custom"]; assert_eq!(provider["options"]["apiKey"], "sk-live"); assert_eq!(provider["options"]["baseURL"], "https://my-endpoint/v1"); assert_eq!(provider["npm"], "@ai-sdk/openai-compatible"); assert_eq!(provider["models"]["my-model"]["name"], "my-model"); } #[test] fn custom_provider_model_name_prefers_display_name() { let custom = CustomProviderConfig::new( "@ai-sdk/openai-compatible", "https://my-endpoint/v1", Some("My Model".to_owned()), ) .unwrap(); let config = OpenCodeProviderConfig::new("my-custom", "my-model", SecretRef::new("ref")) .unwrap() .with_custom(custom); let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None); assert_eq!( body["provider"]["my-custom"]["models"]["my-model"]["name"], "My Model" ); } #[test] fn zai_provider_is_rendered_as_a_cache_independent_provider() { let custom = CustomProviderConfig::new( "@ai-sdk/openai-compatible", "https://api.z.ai/api/paas/v4", Some("GLM-5.1".to_owned()), ) .unwrap(); let config = OpenCodeProviderConfig::new("zai", "glm-5.1", SecretRef::new("ref")) .unwrap() .with_custom(custom); let body = opencode_provider_config_json(&config, "secret", "/project", None, None); assert_eq!(body["model"], "zai/glm-5.1"); assert_eq!(body["provider"]["zai"]["npm"], "@ai-sdk/openai-compatible"); assert_eq!( body["provider"]["zai"]["options"]["baseURL"], "https://api.z.ai/api/paas/v4" ); assert_eq!( body["provider"]["zai"]["models"]["glm-5.1"]["name"], "GLM-5.1" ); } // ---- permission projection wiring (ticket #94) ----------------------- /// Builds an [`EffectivePermissions`] with the given fallback posture (only /// the fallback drives the blanket bash/edit verdicts checked here). fn eff(fallback: domain::permission::Posture) -> EffectivePermissions { domain::permission::resolve( Some(&domain::permission::PermissionSet::new(vec![], fallback)), None, ) .unwrap() } #[test] fn opencode_config_json_omits_permission_key_when_eff_is_none() { let config = domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None) .unwrap(); let body = opencode_config_json(&config, "/project", None, None); assert!(body.get("permission").is_none()); } #[test] fn opencode_config_json_projects_allow_and_deny() { let config = domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None) .unwrap(); let allowed = opencode_config_json( &config, "/project", None, Some(&eff(domain::permission::Posture::Allow)), ); assert_eq!(allowed["permission"]["bash"], "allow"); assert_eq!(allowed["permission"]["edit"], "allow"); let denied = opencode_config_json( &config, "/project", None, Some(&eff(domain::permission::Posture::Deny)), ); assert_eq!(denied["permission"]["bash"], "deny"); assert_eq!(denied["permission"]["edit"], "deny"); } #[test] fn provider_config_json_omits_permission_key_when_eff_is_none() { let config = known_provider_config(); let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None); assert!(body.get("permission").is_none()); } #[test] fn provider_config_json_projects_allow_and_deny() { let config = known_provider_config(); let allowed = opencode_provider_config_json( &config, "sk-live", "/project", None, Some(&eff(domain::permission::Posture::Allow)), ); assert_eq!(allowed["permission"]["bash"], "allow"); assert_eq!(allowed["permission"]["edit"], "allow"); let denied = opencode_provider_config_json( &config, "sk-live", "/project", None, Some(&eff(domain::permission::Posture::Deny)), ); assert_eq!(denied["permission"]["bash"], "deny"); assert_eq!(denied["permission"]["edit"], "deny"); } }