diff --git a/src-tauri/src/acp/family_isolator.rs b/src-tauri/src/acp/family_isolator.rs new file mode 100644 index 000000000..3d78e4b35 --- /dev/null +++ b/src-tauri/src/acp/family_isolator.rs @@ -0,0 +1,364 @@ +//! Isolated extra-account homes for built-in agent families. +//! +//! Codeg has one built-in agent per family (`claude_code`, `codex`, …) whose +//! MCP/auth files live in the default home (`~/.claude`, `~/.codex`, …). Extra +//! subscriptions are registered as custom ACP agents whose launch `spec.env` +//! sets that family's official isolator (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, …). +//! +//! Settings → MCP still targets the family row. This module is how writers +//! discover the extra homes so they stay in lock-step without extra checkboxes +//! and without copying `auth.json` / `.credentials.json`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::acp::custom_registry::{CustomAgentDef, CustomAgentSpec}; +use crate::acp::registry::AgentDistribution; +use crate::models::agent::AgentType; + +/// Families whose official CLI honors a home-override env var. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum IsolatorFamily { + Claude, + Codex, + Grok, + Gemini, + OpenCode, +} + +impl IsolatorFamily { + pub fn isolator_key(self) -> &'static str { + match self { + Self::Claude => "CLAUDE_CONFIG_DIR", + Self::Codex => "CODEX_HOME", + Self::Grok => "GROK_HOME", + Self::Gemini => "GEMINI_CONFIG_DIR", + Self::OpenCode => "OPENCODE_CONFIG_DIR", + } + } + + /// Official login argv for this family. Used by extra-slot Sign in. + pub fn login_args(self) -> &'static [&'static str] { + match self { + Self::Claude => &["claude", "login"], + Self::Codex => &["codex", "login"], + Self::Grok => &["grok", "login"], + Self::Gemini => &["gemini", "auth"], + Self::OpenCode => &["opencode", "auth", "login"], + } + } + + pub fn default_home(self) -> PathBuf { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + match self { + Self::Claude => home.join(".claude"), + Self::Codex => home.join(".codex"), + Self::Grok => home.join(".grok"), + Self::Gemini => home.join(".gemini"), + Self::OpenCode => home.join(".config").join("opencode"), + } + } +} + +/// Merge every channel env map on a custom-agent spec. Extra slots created +/// through the ACP save path put isolators on `npx.env` (or uvx/binary). +pub fn spec_env_map(spec: &CustomAgentSpec) -> BTreeMap { + let mut out = BTreeMap::new(); + if let Some(npx) = &spec.npx { + out.extend(npx.env.clone()); + } + if let Some(uvx) = &spec.uvx { + out.extend(uvx.env.clone()); + } + for bin in spec.binary.values() { + out.extend(bin.env.clone()); + } + out +} + +/// Detect the isolated family home from a launch env map. +/// +/// Gemini accepts two official keys: `GEMINI_CONFIG_DIR` is the `.gemini` +/// directory itself; `GEMINI_CLI_HOME` is the parent (we join `.gemini`). +/// Blank values are ignored. Auth-file paths are never returned. +pub fn isolator_from_env(env: &BTreeMap) -> Option<(IsolatorFamily, PathBuf)> { + isolator_from_env_filtered(env, None) +} + +fn isolator_from_env_filtered( + env: &BTreeMap, + only: Option, +) -> Option<(IsolatorFamily, PathBuf)> { + // Prefer the explicit config-dir keys. `GEMINI_CLI_HOME` is the parent of + // the settings directory, so it is consulted after `GEMINI_CONFIG_DIR`. + let candidates: &[(IsolatorFamily, &str, bool)] = &[ + (IsolatorFamily::Claude, "CLAUDE_CONFIG_DIR", false), + (IsolatorFamily::Codex, "CODEX_HOME", false), + (IsolatorFamily::Grok, "GROK_HOME", false), + (IsolatorFamily::Gemini, "GEMINI_CONFIG_DIR", false), + (IsolatorFamily::Gemini, "GEMINI_CLI_HOME", true), + (IsolatorFamily::OpenCode, "OPENCODE_CONFIG_DIR", false), + ]; + for (family, key, join_gemini) in candidates { + if let Some(only) = only { + if only != *family { + continue; + } + } + let Some(raw) = env.get(*key).map(|s| s.trim()).filter(|s| !s.is_empty()) else { + continue; + }; + let mut path = PathBuf::from(raw); + if *join_gemini { + path.push(".gemini"); + } + return Some((*family, path)); + } + None +} + +fn paths_equivalent(left: &Path, right: &Path) -> bool { + if left == right { + return true; + } + match (left.canonicalize(), right.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +fn is_default_home(family: IsolatorFamily, home: &Path) -> bool { + paths_equivalent(home, &family.default_home()) +} + +/// Extra homes for one family, from already-loaded custom-agent defs. No DB. +pub fn extra_homes_for_family( + family: IsolatorFamily, + defs: &[CustomAgentDef], +) -> Vec { + let mut homes = Vec::new(); + for def in defs { + let env = spec_env_map(&def.spec); + if let Some((_, home)) = isolator_from_env_filtered(&env, Some(family)) { + if !is_default_home(family, &home) { + homes.push(home); + } + } + } + homes.sort(); + homes.dedup(); + homes +} + +fn distribution_env(dist: &AgentDistribution) -> BTreeMap { + let pairs = match dist { + AgentDistribution::Npx { env, .. } + | AgentDistribution::Binary { env, .. } + | AgentDistribution::Uvx { env, .. } => *env, + }; + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() +} + +/// Extra homes published in the in-memory custom-agent registry (hydrated +/// from `custom_agent` at boot and after every save). Sync, no DB. +pub fn extra_homes_from_live_registry(family: IsolatorFamily) -> Vec { + let mut homes = Vec::new(); + for agent in crate::acp::custom_registry::all() { + let AgentType::Custom(id) = agent else { + continue; + }; + let Some(meta) = crate::acp::custom_registry::get(id) else { + continue; + }; + let env = distribution_env(&meta.distribution); + if let Some((_, home)) = isolator_from_env_filtered(&env, Some(family)) { + if !is_default_home(family, &home) { + homes.push(home); + } + } + } + homes.sort(); + homes.dedup(); + homes +} + +/// Official login plan for an extra slot. Never copies tokens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtraSlotLogin { + pub family: IsolatorFamily, + pub isolator_key: &'static str, + pub home: PathBuf, + pub args: &'static [&'static str], +} + +pub fn login_plan_from_env(env: &BTreeMap) -> Option { + let (family, home) = isolator_from_env(env)?; + Some(ExtraSlotLogin { + family, + isolator_key: family.isolator_key(), + home, + args: family.login_args(), + }) +} + +pub fn login_plan_from_def(def: &CustomAgentDef) -> Option { + login_plan_from_env(&spec_env_map(&def.spec)) +} + +/// Build the command line `open_external_terminal_impl` will run. +/// Rejects newlines in the home path (same rule as the terminal opener). +pub fn shell_export_and_login(plan: &ExtraSlotLogin) -> Result { + let home = plan.home.to_string_lossy(); + if home.contains(['\n', '\r']) || plan.isolator_key.contains(['\n', '\r']) { + return Err("isolator home must not contain newlines".into()); + } + let command = plan.args.join(" "); + if cfg!(windows) { + Ok(format!( + "set \"{}={}\"&& {}", + plan.isolator_key, home, command + )) + } else { + Ok(format!( + "export {}={} && {}", + plan.isolator_key, + shell_single_quote(&home), + command + )) + } +} + +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::custom_registry::{CustomDistributionKind, NpxSpec}; + + fn def_with_env(id: &str, env: BTreeMap) -> CustomAgentDef { + CustomAgentDef { + registry_id: id.to_string(), + name: id.to_string(), + description: String::new(), + version: "1.0.0".into(), + distribution_kind: CustomDistributionKind::Npx, + spec: CustomAgentSpec { + npx: Some(NpxSpec { + package: "example@1.0.0".into(), + args: Vec::new(), + env, + cmd: None, + node_required: None, + }), + ..Default::default() + }, + icon_url: None, + skills_shared_store: false, + skills_dir: None, + source: Default::default(), + version_probe: None, + supports_mcp: true, + } + } + + #[test] + fn isolator_from_env_reads_family_keys() { + let mut env = BTreeMap::new(); + env.insert("CLAUDE_CONFIG_DIR".into(), "/tmp/a".into()); + let (family, home) = isolator_from_env(&env).expect("claude"); + assert_eq!(family, IsolatorFamily::Claude); + assert_eq!(home, PathBuf::from("/tmp/a")); + + let mut env = BTreeMap::new(); + env.insert("GEMINI_CLI_HOME".into(), "/tmp/h".into()); + let (family, home) = isolator_from_env(&env).expect("gemini parent"); + assert_eq!(family, IsolatorFamily::Gemini); + assert_eq!(home, PathBuf::from("/tmp/h").join(".gemini")); + + let mut env = BTreeMap::new(); + env.insert("GEMINI_CONFIG_DIR".into(), "/tmp/g".into()); + env.insert("GEMINI_CLI_HOME".into(), "/tmp/h".into()); + let (family, home) = isolator_from_env(&env).expect("config dir wins"); + assert_eq!(family, IsolatorFamily::Gemini); + assert_eq!(home, PathBuf::from("/tmp/g")); + + let mut env = BTreeMap::new(); + env.insert("CLAUDE_CONFIG_DIR".into(), " ".into()); + assert!(isolator_from_env(&env).is_none()); + assert!(isolator_from_env(&BTreeMap::new()).is_none()); + } + + #[test] + fn extra_homes_for_family_filters_and_skips_default() { + let defs = vec![ + def_with_env( + "codex-2", + BTreeMap::from([("CODEX_HOME".into(), "/p/codex-2".into())]), + ), + def_with_env( + "grok-2", + BTreeMap::from([("GROK_HOME".into(), "/p/grok-2".into())]), + ), + def_with_env( + "codex-default", + BTreeMap::from([( + "CODEX_HOME".into(), + IsolatorFamily::Codex.default_home().to_string_lossy().into(), + )]), + ), + ]; + let homes = extra_homes_for_family(IsolatorFamily::Codex, &defs); + assert_eq!(homes, vec![PathBuf::from("/p/codex-2")]); + let grok = extra_homes_for_family(IsolatorFamily::Grok, &defs); + assert_eq!(grok, vec![PathBuf::from("/p/grok-2")]); + } + + #[test] + fn login_plan_sets_isolator_and_official_args() { + let def = def_with_env( + "codex-2", + BTreeMap::from([("CODEX_HOME".into(), "/tmp/c2".into())]), + ); + let plan = login_plan_from_def(&def).expect("plan"); + assert_eq!(plan.family, IsolatorFamily::Codex); + assert_eq!(plan.isolator_key, "CODEX_HOME"); + assert_eq!(plan.home, PathBuf::from("/tmp/c2")); + assert_eq!(plan.args, &["codex", "login"]); + let cmd = shell_export_and_login(&plan).expect("cmd"); + assert!(cmd.contains("CODEX_HOME")); + assert!(cmd.contains("codex login")); + assert!(!cmd.contains('\n')); + } + + #[test] + fn shell_export_rejects_newlines_in_home() { + let plan = ExtraSlotLogin { + family: IsolatorFamily::Claude, + isolator_key: "CLAUDE_CONFIG_DIR", + home: PathBuf::from("/tmp/bad\nhome"), + args: IsolatorFamily::Claude.login_args(), + }; + assert!(shell_export_and_login(&plan).is_err()); + } + + #[test] + fn isolator_never_returns_an_auth_file_path() { + let mut env = BTreeMap::new(); + env.insert( + "CLAUDE_CONFIG_DIR".into(), + "/profiles/claude-2".into(), + ); + env.insert( + "ANTHROPIC_AUTH_TOKEN".into(), + "/profiles/other/auth.json".into(), + ); + let (_, home) = isolator_from_env(&env).expect("home"); + assert_eq!(home, PathBuf::from("/profiles/claude-2")); + assert!(!home.ends_with("auth.json")); + } +} diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index 878d2d005..5ea2ac2af 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -9,6 +9,7 @@ pub mod custom_registry; pub mod delegation; pub mod error; pub mod event_stream; +pub mod family_isolator; pub mod feedback; pub mod file_system_runtime; pub mod fork; diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 60eee3a0f..fb3cb8cbe 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -747,12 +747,15 @@ fn write_json_file(path: &Path, value: &Value) -> Result<(), AppCommandError> { } fn read_codex_root_toml() -> Result { - let path = codex_config_toml_path(); + read_codex_root_toml_at(&codex_config_toml_path()) +} + +fn read_codex_root_toml_at(path: &Path) -> Result { if !path.exists() { return Ok(toml::Value::Table(toml::map::Map::new())); } - let raw = fs::read_to_string(&path).map_err(AppCommandError::io)?; + let raw = fs::read_to_string(path).map_err(AppCommandError::io)?; let parsed = raw.parse::().map_err(|e| { mcp_configuration_invalid(format!("invalid TOML at {}: {e}", path.display())) })?; @@ -768,7 +771,10 @@ fn read_codex_root_toml() -> Result { } fn write_codex_root_toml(root: &toml::Value) -> Result<(), AppCommandError> { - let path = codex_config_toml_path(); + write_codex_root_toml_at(&codex_config_toml_path(), root) +} + +fn write_codex_root_toml_at(path: &Path, root: &toml::Value) -> Result<(), AppCommandError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(AppCommandError::io)?; } @@ -779,7 +785,7 @@ fn write_codex_root_toml(root: &toml::Value) -> Result<(), AppCommandError> { path.display() )) })?; - fs::write(&path, format!("{serialized}\n")).map_err(AppCommandError::io) + fs::write(path, format!("{serialized}\n")).map_err(AppCommandError::io) } fn obj_as_string_map(value: Option<&Value>) -> Option> { @@ -1637,8 +1643,11 @@ fn remove_claude_server(id: &str) -> Result { /// will not load until it appears in this list). Existing fields in the /// settings file (env, model, other plugin entries) are preserved. fn enable_claude_local_plugin(id: &str) -> Result<(), AppCommandError> { - let path = claude_settings_path(); - let mut root = read_json_file(&path)?; + enable_claude_local_plugin_at(&claude_settings_path(), id) +} + +fn enable_claude_local_plugin_at(path: &Path, id: &str) -> Result<(), AppCommandError> { + let mut root = read_json_file(path)?; if !root.is_object() { root = json!({}); } @@ -1673,11 +1682,14 @@ fn enable_claude_local_plugin(id: &str) -> Result<(), AppCommandError> { /// present. Other entries (including any `@` that /// the user manages manually) are intentionally left untouched. fn disable_claude_local_plugin(id: &str) -> Result<(), AppCommandError> { - let path = claude_settings_path(); + disable_claude_local_plugin_at(&claude_settings_path(), id) +} + +fn disable_claude_local_plugin_at(path: &Path, id: &str) -> Result<(), AppCommandError> { if !path.exists() { return Ok(()); } - let mut root = read_json_file(&path)?; + let mut root = read_json_file(path)?; let Some(obj) = root.as_object_mut() else { return Ok(()); }; @@ -1881,7 +1893,11 @@ fn read_codex_servers() -> Result, AppCommandError> { } fn upsert_codex_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { - let mut root = read_codex_root_toml()?; + upsert_codex_server_at(&codex_config_toml_path(), id, spec) +} + +fn upsert_codex_server_at(path: &Path, id: &str, spec: &Value) -> Result<(), AppCommandError> { + let mut root = read_codex_root_toml_at(path)?; let table = root .as_table_mut() .ok_or_else(|| mcp_configuration_invalid("Codex root TOML must be a table"))?; @@ -1920,16 +1936,19 @@ fn upsert_codex_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { } } - write_codex_root_toml(&root) + write_codex_root_toml_at(path, &root) } fn remove_codex_server(id: &str) -> Result { - let path = codex_config_toml_path(); + remove_codex_server_at(&codex_config_toml_path(), id) +} + +fn remove_codex_server_at(path: &Path, id: &str) -> Result { if !path.exists() { return Ok(false); } - let mut root = read_codex_root_toml()?; + let mut root = read_codex_root_toml_at(path)?; let Some(table) = root.as_table_mut() else { return Ok(false); }; @@ -1962,7 +1981,7 @@ fn remove_codex_server(id: &str) -> Result { } if removed { - write_codex_root_toml(&root)?; + write_codex_root_toml_at(path, &root)?; } Ok(removed) @@ -2007,8 +2026,11 @@ fn read_opencode_servers() -> Result, AppCommandError> { } fn upsert_opencode_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { - let path = opencode_config_path(); - let mut root = read_json_file(&path)?; + upsert_opencode_server_at(&opencode_config_path(), id, spec) +} + +fn upsert_opencode_server_at(path: &Path, id: &str, spec: &Value) -> Result<(), AppCommandError> { + let mut root = read_json_file(path)?; if !root.is_object() { root = json!({}); } @@ -2044,12 +2066,15 @@ fn upsert_opencode_server(id: &str, spec: &Value) -> Result<(), AppCommandError> } fn remove_opencode_server(id: &str) -> Result { - let path = opencode_config_path(); + remove_opencode_server_at(&opencode_config_path(), id) +} + +fn remove_opencode_server_at(path: &Path, id: &str) -> Result { if !path.exists() { return Ok(false); } - let mut root = read_json_file(&path)?; + let mut root = read_json_file(path)?; let Some(obj) = root.as_object_mut() else { return Ok(false); }; @@ -2099,8 +2124,11 @@ fn read_gemini_servers() -> Result, AppCommandError> { } fn upsert_gemini_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { - let path = gemini_config_path(); - let mut root = read_json_file(&path)?; + upsert_gemini_server_at(&gemini_config_path(), id, spec) +} + +fn upsert_gemini_server_at(path: &Path, id: &str, spec: &Value) -> Result<(), AppCommandError> { + let mut root = read_json_file(path)?; if !root.is_object() { root = json!({}); } @@ -2126,12 +2154,15 @@ fn upsert_gemini_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { } fn remove_gemini_server(id: &str) -> Result { - let path = gemini_config_path(); + remove_gemini_server_at(&gemini_config_path(), id) +} + +fn remove_gemini_server_at(path: &Path, id: &str) -> Result { if !path.exists() { return Ok(false); } - let mut root = read_json_file(&path)?; + let mut root = read_json_file(path)?; let Some(obj) = root.as_object_mut() else { return Ok(false); }; @@ -2623,6 +2654,90 @@ fn find_local_server(server_id: &str) -> Result, AppComma Ok(servers.into_iter().find(|item| item.id == server_id)) } +fn isolator_family_for_app( + app: McpAppType, +) -> Option { + use crate::acp::family_isolator::IsolatorFamily; + match app { + McpAppType::ClaudeCode => Some(IsolatorFamily::Claude), + McpAppType::Codex => Some(IsolatorFamily::Codex), + McpAppType::Grok => Some(IsolatorFamily::Grok), + McpAppType::Gemini => Some(IsolatorFamily::Gemini), + McpAppType::OpenCode => Some(IsolatorFamily::OpenCode), + _ => None, + } +} + +/// After a default-home write/remove, keep extra isolated family homes in +/// lock-step. Auth files are never touched. `spec = None` means remove. +fn fanout_server_for_extra_homes( + app: McpAppType, + id: &str, + spec: Option<&Value>, + extra_homes: &[PathBuf], +) -> Result<(), AppCommandError> { + let Some(family) = isolator_family_for_app(app) else { + return Ok(()); + }; + for home in extra_homes { + match family { + crate::acp::family_isolator::IsolatorFamily::Claude => { + let settings = home.join("settings.json"); + if spec.is_some() { + enable_claude_local_plugin_at(&settings, id)?; + } else { + disable_claude_local_plugin_at(&settings, id)?; + } + } + crate::acp::family_isolator::IsolatorFamily::Codex => { + let path = home.join("config.toml"); + if let Some(spec) = spec { + upsert_codex_server_at(&path, id, spec)?; + } else { + let _ = remove_codex_server_at(&path, id)?; + } + } + crate::acp::family_isolator::IsolatorFamily::Grok => { + let path = home.join("config.toml"); + if let Some(spec) = spec { + upsert_grok_server_at(&path, id, spec)?; + } else { + let _ = remove_grok_server_at(&path, id)?; + } + } + crate::acp::family_isolator::IsolatorFamily::Gemini => { + let path = home.join("settings.json"); + if let Some(spec) = spec { + upsert_gemini_server_at(&path, id, spec)?; + } else { + let _ = remove_gemini_server_at(&path, id)?; + } + } + crate::acp::family_isolator::IsolatorFamily::OpenCode => { + let path = home.join("opencode.json"); + if let Some(spec) = spec { + upsert_opencode_server_at(&path, id, spec)?; + } else { + let _ = remove_opencode_server_at(&path, id)?; + } + } + } + } + Ok(()) +} + +fn fanout_live_extra_homes( + app: McpAppType, + id: &str, + spec: Option<&Value>, +) -> Result<(), AppCommandError> { + let Some(family) = isolator_family_for_app(app) else { + return Ok(()); + }; + let homes = crate::acp::family_isolator::extra_homes_from_live_registry(family); + fanout_server_for_extra_homes(app, id, spec, &homes) +} + fn upsert_server_for_app(app: McpAppType, id: &str, spec: &Value) -> Result<(), AppCommandError> { match app { McpAppType::ClaudeCode => upsert_claude_server(id, spec), @@ -2637,7 +2752,8 @@ fn upsert_server_for_app(app: McpAppType, id: &str, spec: &Value) -> Result<(), McpAppType::Grok => upsert_grok_server(id, spec), McpAppType::Cursor => upsert_cursor_server(id, spec), McpAppType::DeepSeek => upsert_deepseek_server(id, spec), - } + }?; + fanout_live_extra_homes(app, id, Some(spec)) } pub fn read_servers_for_agent_type( @@ -3573,7 +3689,7 @@ fn remove_hermes_server(id: &str) -> Result { } fn remove_server_for_app(app: McpAppType, id: &str) -> Result { - match app { + let removed = match app { McpAppType::ClaudeCode => remove_claude_server(id), McpAppType::Codex => remove_codex_server(id), McpAppType::OpenCode => remove_opencode_server(id), @@ -3586,7 +3702,11 @@ fn remove_server_for_app(app: McpAppType, id: &str) -> Result remove_grok_server(id), McpAppType::Cursor => remove_cursor_server(id), McpAppType::DeepSeek => remove_deepseek_server(id), - } + }?; + // Always fan out the remove so a stale extra-home entry cannot outlive + // the family-row uncheck, even if the default home had nothing to drop. + fanout_live_extra_homes(app, id, None)?; + Ok(removed) } #[derive(Debug, Deserialize)] @@ -6331,4 +6451,165 @@ mod tests { assert_eq!(back, canonical, "round-trip mismatch for {spec}"); } } + + #[test] + fn extra_home_fanout_writes_and_removes_without_touching_auth() { + let dir = tempfile::tempdir().expect("tempdir"); + let claude_home = dir.path().join("claude-2"); + let codex_home = dir.path().join("codex-2"); + let gemini_home = dir.path().join("gemini-2"); + let opencode_home = dir.path().join("opencode-2"); + std::fs::create_dir_all(&claude_home).expect("claude home"); + std::fs::create_dir_all(&codex_home).expect("codex home"); + std::fs::create_dir_all(&gemini_home).expect("gemini home"); + std::fs::create_dir_all(&opencode_home).expect("opencode home"); + + let claude_settings = claude_home.join("settings.json"); + std::fs::write( + &claude_settings, + "{\n \"model\": \"keep-me\",\n \"enabledPlugins\": {\"other@local\": true}\n}\n", + ) + .expect("seed claude settings"); + let claude_auth = claude_home.join("auth.json"); + let auth_bytes = b"{\"token\":\"do-not-copy\"}\n"; + std::fs::write(&claude_auth, auth_bytes).expect("seed auth"); + + std::fs::write( + codex_home.join("config.toml"), + "[cli]\nauto_update = true\n", + ) + .expect("seed codex"); + std::fs::write( + gemini_home.join("settings.json"), + "{\n \"theme\": \"dark\"\n}\n", + ) + .expect("seed gemini"); + std::fs::write( + opencode_home.join("opencode.json"), + "{\n \"model\": \"keep\"\n}\n", + ) + .expect("seed opencode"); + + let spec = json!({ + "type": "stdio", + "command": "npx", + "args": ["-y", "ctx7-mcp"], + }); + + fanout_server_for_extra_homes( + McpAppType::ClaudeCode, + "ctx7", + Some(&spec), + &[claude_home.clone()], + ) + .expect("claude fanout"); + fanout_server_for_extra_homes( + McpAppType::Codex, + "ctx7", + Some(&spec), + &[codex_home.clone()], + ) + .expect("codex fanout"); + fanout_server_for_extra_homes( + McpAppType::Gemini, + "ctx7", + Some(&spec), + &[gemini_home.clone()], + ) + .expect("gemini fanout"); + fanout_server_for_extra_homes( + McpAppType::OpenCode, + "ctx7", + Some(&spec), + &[opencode_home.clone()], + ) + .expect("opencode fanout"); + + let claude_root: Value = + serde_json::from_str(&std::fs::read_to_string(&claude_settings).unwrap()).unwrap(); + assert_eq!( + claude_root.pointer("/enabledPlugins/ctx7@local"), + Some(&json!(true)) + ); + assert_eq!( + claude_root.pointer("/enabledPlugins/other@local"), + Some(&json!(true)) + ); + assert_eq!(claude_root.get("model").and_then(Value::as_str), Some("keep-me")); + assert_eq!(std::fs::read(&claude_auth).unwrap(), auth_bytes); + assert!( + !claude_root.as_object().unwrap().contains_key("mcpServers"), + "Claude extra homes get enabledPlugins only; defs stay in ~/.claude.json" + ); + + let codex_raw = std::fs::read_to_string(codex_home.join("config.toml")).unwrap(); + let codex_root: toml::Value = codex_raw.parse().unwrap(); + assert!(codex_root + .get("mcp_servers") + .and_then(toml::Value::as_table) + .map(|t| t.contains_key("ctx7")) + .unwrap_or(false)); + assert!(codex_root.get("cli").is_some(), "unrelated Codex keys survive"); + + let gemini_root: Value = serde_json::from_str( + &std::fs::read_to_string(gemini_home.join("settings.json")).unwrap(), + ) + .unwrap(); + assert!(gemini_root + .pointer("/mcpServers/ctx7") + .is_some()); + assert_eq!(gemini_root.get("theme").and_then(Value::as_str), Some("dark")); + + let opencode_root: Value = serde_json::from_str( + &std::fs::read_to_string(opencode_home.join("opencode.json")).unwrap(), + ) + .unwrap(); + assert!( + opencode_root.pointer("/mcp/ctx7").is_some() + || opencode_root.pointer("/mcpServers/ctx7").is_some() + ); + assert_eq!( + opencode_root.get("model").and_then(Value::as_str), + Some("keep") + ); + + fanout_server_for_extra_homes( + McpAppType::ClaudeCode, + "ctx7", + None, + &[claude_home.clone()], + ) + .expect("claude remove"); + fanout_server_for_extra_homes(McpAppType::Codex, "ctx7", None, &[codex_home.clone()]) + .expect("codex remove"); + fanout_server_for_extra_homes(McpAppType::Gemini, "ctx7", None, &[gemini_home.clone()]) + .expect("gemini remove"); + fanout_server_for_extra_homes( + McpAppType::OpenCode, + "ctx7", + None, + &[opencode_home.clone()], + ) + .expect("opencode remove"); + + let claude_root: Value = + serde_json::from_str(&std::fs::read_to_string(&claude_settings).unwrap()).unwrap(); + assert!(claude_root.pointer("/enabledPlugins/ctx7@local").is_none()); + assert_eq!( + claude_root.pointer("/enabledPlugins/other@local"), + Some(&json!(true)) + ); + assert_eq!(std::fs::read(&claude_auth).unwrap(), auth_bytes); + + let codex_raw = std::fs::read_to_string(codex_home.join("config.toml")).unwrap(); + let codex_root: toml::Value = codex_raw.parse().unwrap(); + assert!( + !codex_root + .get("mcp_servers") + .and_then(toml::Value::as_table) + .map(|t| t.contains_key("ctx7")) + .unwrap_or(false) + ); + assert!(codex_root.get("cli").is_some()); + } } diff --git a/src/components/settings/mcp-settings.tsx b/src/components/settings/mcp-settings.tsx index 92554bda7..8989dd26d 100644 --- a/src/components/settings/mcp-settings.tsx +++ b/src/components/settings/mcp-settings.tsx @@ -83,6 +83,10 @@ type McpTranslator = ( values?: Record ) => string +// Extra isolated family homes (custom agents with CLAUDE_CONFIG_DIR / +// CODEX_HOME / GROK_HOME / GEMINI_CONFIG_DIR / OPENCODE_CONFIG_DIR) stay +// covered by these family rows. Do not add custom:* checkboxes here: MCP +// writers fan the same server into those homes automatically. const APP_OPTIONS: { value: McpAppType; label: string }[] = [ { value: "claude_code", label: "Claude Code" }, { value: "codex", label: "Codex CLI" },