diff --git a/src-tauri/src/acp/family_isolator.rs b/src-tauri/src/acp/family_isolator.rs new file mode 100644 index 0000000000..a92e8ef75d --- /dev/null +++ b/src-tauri/src/acp/family_isolator.rs @@ -0,0 +1,383 @@ +//! 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_CLI_HOME", + 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"], + 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"), + // OpenCode 2's own global directory: `$OPENCODE_CONFIG_DIR`, which + // replaces the default, else `$XDG_CONFIG_HOME/opencode`. + Self::OpenCode => crate::acp::opencode_config::config_dir() + .unwrap_or_else(|| 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 uses `GEMINI_CLI_HOME`, the parent of `.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)> { + // GEMINI_CLI_HOME is the parent of the settings directory. + let candidates: &[(IsolatorFamily, &str, bool)] = &[ + (IsolatorFamily::Claude, "CLAUDE_CONFIG_DIR", false), + (IsolatorFamily::Codex, "CODEX_HOME", false), + (IsolatorFamily::Grok, "GROK_HOME", 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::System { 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)?; + let home = if family == IsolatorFamily::Gemini { + home.parent()?.to_path_buf() + } else { + home + }; + 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::*; + + /// OpenCode 2's default directory follows `OPENCODE_CONFIG_DIR`: a slot + /// pointed there shares Default's config, while the fixed path it + /// replaced is an account of its own. + #[test] + fn opencode_default_home_follows_opencode_config_dir() { + let dir = tempfile::tempdir().unwrap(); + let own = dir.path().join("own-opencode"); + temp_env::with_var("OPENCODE_CONFIG_DIR", Some(&own), || { + assert!(is_default_home(IsolatorFamily::OpenCode, &own)); + let fixed = dirs::home_dir().unwrap().join(".config").join("opencode"); + assert!(!is_default_home(IsolatorFamily::OpenCode, &fixed)); + }); + } + 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("official home wins"); + assert_eq!(family, IsolatorFamily::Gemini); + assert_eq!(home, PathBuf::from("/tmp/h").join(".gemini")); + env.remove("GEMINI_CLI_HOME"); + assert!(isolator_from_env(&env).is_none()); + + 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 cd9fcb33b3..b02c0b36d1 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -25,6 +25,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 89ca1f0381..6d992dc2ea 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -931,8 +931,11 @@ fn write_json_file(path: &Path, value: &Value) -> Result<(), AppCommandError> { } fn read_codex_root_toml() -> Result { - let path = codex_config_toml_path(); - let Some(raw) = read_config_to_string(&path)? else { + read_codex_root_toml_at(&codex_config_toml_path()) +} + +fn read_codex_root_toml_at(path: &Path) -> Result { + let Some(raw) = read_config_to_string(path)? else { return Ok(toml::Value::Table(toml::map::Map::new())); }; let parsed = raw.parse::().map_err(|e| { @@ -949,8 +952,7 @@ fn read_codex_root_toml() -> Result { Ok(parsed) } -fn write_codex_root_toml(root: &toml::Value) -> Result<(), AppCommandError> { - let path = codex_config_toml_path(); +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)?; } @@ -961,7 +963,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> { @@ -1760,8 +1762,11 @@ fn read_claude_servers() -> Result, AppCommandError> { } fn upsert_claude_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { - let path = claude_config_path(); - let mut root = read_json_file(&path)?; + upsert_claude_server_at(&claude_config_path(), &claude_settings_path(), id, spec) +} + +fn upsert_claude_server_at(path: &Path, settings: &Path, id: &str, spec: &Value) -> Result<(), AppCommandError> { + let mut root = read_json_file(path)?; if !root.is_object() { root = json!({}); } @@ -1783,35 +1788,38 @@ fn upsert_claude_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { })?; map.insert(id.to_string(), canonical); - write_json_file(&path, &root)?; - enable_claude_local_plugin(id) + write_json_file(path, &root)?; + enable_claude_local_plugin_at(settings, id) } fn remove_claude_server(id: &str) -> Result { - let path = claude_config_path(); + remove_claude_server_at(&claude_config_path(), &claude_settings_path(), id) +} + +fn remove_claude_server_at(path: &Path, settings: &Path, id: &str) -> Result { if !path.exists() { // Even if `~/.claude.json` is missing, `enabledPlugins` could still // have a stale entry from a prior session — clean it up regardless // so the user doesn't end up with dangling activation markers. - disable_claude_local_plugin(id)?; + disable_claude_local_plugin_at(settings, id)?; 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 { - disable_claude_local_plugin(id)?; + disable_claude_local_plugin_at(settings, id)?; return Ok(false); }; let Some(servers) = obj.get_mut("mcpServers").and_then(Value::as_object_mut) else { - disable_claude_local_plugin(id)?; + disable_claude_local_plugin_at(settings, id)?; return Ok(false); }; let removed = servers.remove(id).is_some(); if removed { - write_json_file(&path, &root)?; + write_json_file(path, &root)?; } - disable_claude_local_plugin(id)?; + disable_claude_local_plugin_at(settings, id)?; Ok(removed) } @@ -1820,9 +1828,8 @@ fn remove_claude_server(id: &str) -> Result { /// servers from `~/.claude.json.mcpServers` (a server can be defined but /// 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)?; +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!({}); } @@ -1850,18 +1857,17 @@ fn enable_claude_local_plugin(id: &str) -> Result<(), AppCommandError> { return Ok(()); } plugins.insert(key, Value::Bool(true)); - write_json_file(&path, &root) + write_json_file(path, &root) } /// Remove `@local` from `~/.claude/settings.json.enabledPlugins` if /// 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(); +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(()); }; @@ -1870,7 +1876,7 @@ fn disable_claude_local_plugin(id: &str) -> Result<(), AppCommandError> { }; let key = claude_local_plugin_key(id); if plugins.remove(&key).is_some() { - write_json_file(&path, &root)?; + write_json_file(path, &root)?; } Ok(()) } @@ -2065,7 +2071,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"))?; @@ -2104,16 +2114,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); }; @@ -2146,7 +2159,7 @@ fn remove_codex_server(id: &str) -> Result { } if removed { - write_codex_root_toml(&root)?; + write_codex_root_toml_at(path, &root)?; } Ok(removed) @@ -2332,7 +2345,24 @@ fn preflight_opencode_upsert(targets: &BTreeSet) -> Result<(), AppCo if !targets.contains(&McpAppType::OpenCode) { return Ok(()); } - preflight_opencode_upsert_at(&opencode_config_path()) + let extra_homes = crate::acp::family_isolator::extra_homes_from_live_registry( + crate::acp::family_isolator::IsolatorFamily::OpenCode, + ); + preflight_opencode_upsert_homes(&opencode_config_path(), &extra_homes) +} + +/// The same save writes every extra OpenCode home after the default config +/// (see [`fanout_server_for_extra_homes`]), so each of their files has to be +/// editable before anything is written as well. +fn preflight_opencode_upsert_homes( + default: &Path, + extra_homes: &[PathBuf], +) -> Result<(), AppCommandError> { + preflight_opencode_upsert_at(default)?; + for home in extra_homes { + preflight_opencode_upsert_at(&crate::acp::opencode_config::config_file_in(home))?; + } + Ok(()) } fn preflight_opencode_upsert_at(path: &Path) -> Result<(), AppCommandError> { @@ -2572,8 +2602,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!({}); } @@ -2595,16 +2628,19 @@ fn upsert_gemini_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { })?; map.insert(id.to_string(), canonical); - write_json_file(&path, &root) + write_json_file(path, &root) } 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); }; @@ -2614,7 +2650,7 @@ fn remove_gemini_server(id: &str) -> Result { let removed = servers.remove(id).is_some(); if removed { - write_json_file(&path, &root)?; + write_json_file(path, &root)?; } Ok(removed) } @@ -3653,6 +3689,95 @@ fn require_complete_scan(scan: &LocalMcpScan) -> Result<(), AppCommandError> { ))) } +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 => { + // CLAUDE_CONFIG_DIR relocates both files. This account does + // not read server definitions from the default home. + let config = home.join(".claude.json"); + let settings = home.join("settings.json"); + if let Some(spec) = spec { + upsert_claude_server_at(&config, &settings, id, spec)?; + } else { + let _ = remove_claude_server_at(&config, &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 => { + // OPENCODE_CONFIG_DIR replaces the global directory; edit the + // file in it that OpenCode loads last. + let path = crate::acp::opencode_config::config_file_in(home); + 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> { let spec = &without_cursor_only_fields(app, spec); match app { @@ -3678,7 +3803,8 @@ fn upsert_server_for_app(app: McpAppType, id: &str, spec: &Value) -> Result<(), McpAppType::MiniMaxCode => { upsert_deepseek_server_at(&crate::acp::minimax_code::mcp_json_path(), id, spec) } - } + }?; + fanout_live_extra_homes(app, id, Some(spec)) } pub fn read_servers_for_agent_type( @@ -5697,7 +5823,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), @@ -5720,7 +5846,11 @@ fn remove_server_for_app(app: McpAppType, id: &str) -> Result { remove_deepseek_server_at(&crate::acp::minimax_code::mcp_json_path(), 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)] @@ -10651,6 +10781,262 @@ 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 claude_config = claude_home.join(".claude.json"); + std::fs::write(&claude_config, "{\"account\":\"keep\",\"mcpServers\":{\"other\":{\"command\":\"other\"}}}") + .expect("seed isolated config"); + 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), + std::slice::from_ref(&claude_home), + ) + .expect("claude fanout"); + fanout_server_for_extra_homes( + McpAppType::Codex, + "ctx7", + Some(&spec), + std::slice::from_ref(&codex_home), + ) + .expect("codex fanout"); + fanout_server_for_extra_homes( + McpAppType::Gemini, + "ctx7", + Some(&spec), + std::slice::from_ref(&gemini_home), + ) + .expect("gemini fanout"); + fanout_server_for_extra_homes( + McpAppType::OpenCode, + "ctx7", + Some(&spec), + std::slice::from_ref(&opencode_home), + ) + .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); + let config: Value = serde_json::from_str(&std::fs::read_to_string(&claude_config).unwrap()).unwrap(); + assert_eq!(config.pointer("/mcpServers/ctx7/command"), Some(&json!("npx"))); + assert_eq!(config.get("account"), Some(&json!("keep"))); + assert!(config.pointer("/mcpServers/other").is_some()); + + 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_eq!( + opencode_root.pointer("/mcp/servers/ctx7/command"), + Some(&json!(["npx", "-y", "ctx7-mcp"])) + ); + assert!(opencode_root.pointer("/mcp/ctx7").is_none()); + assert_eq!( + opencode_root.get("model").and_then(Value::as_str), + Some("keep") + ); + + fanout_server_for_extra_homes( + McpAppType::ClaudeCode, + "ctx7", + None, + std::slice::from_ref(&claude_home), + ) + .expect("claude remove"); + fanout_server_for_extra_homes( + McpAppType::Codex, + "ctx7", + None, + std::slice::from_ref(&codex_home), + ) + .expect("codex remove"); + fanout_server_for_extra_homes( + McpAppType::Gemini, + "ctx7", + None, + std::slice::from_ref(&gemini_home), + ) + .expect("gemini remove"); + fanout_server_for_extra_homes( + McpAppType::OpenCode, + "ctx7", + None, + std::slice::from_ref(&opencode_home), + ) + .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()); + let config: Value = serde_json::from_str(&std::fs::read_to_string(&claude_config).unwrap()).unwrap(); + assert!(config.pointer("/mcpServers/ctx7").is_none()); + assert!(config.pointer("/mcpServers/other").is_some()); + assert_eq!(config.get("account"), Some(&json!("keep"))); + 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()); + + 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/servers/ctx7").is_none()); + assert_eq!( + opencode_root.get("model").and_then(Value::as_str), + Some("keep") + ); + } + + /// An extra OpenCode home is an `OPENCODE_CONFIG_DIR`, so the fan-out + /// edits the file OpenCode loads last there, `opencode.jsonc` when it + /// exists, and keeps the user's comments. + #[test] + fn extra_opencode_home_fanout_edits_the_jsonc_file_that_wins() { + let dir = tempfile::tempdir().expect("tempdir"); + let home = dir.path().join("opencode-3"); + std::fs::create_dir_all(&home).expect("opencode home"); + let jsonc = home.join("opencode.jsonc"); + std::fs::write(&jsonc, "{\n // keep this note\n \"model\": \"keep\",\n}\n") + .expect("seed jsonc"); + let spec = json!({"type": "stdio", "command": "npx", "args": ["-y", "ctx7-mcp"]}); + + fanout_server_for_extra_homes( + McpAppType::OpenCode, + "ctx7", + Some(&spec), + std::slice::from_ref(&home), + ) + .expect("opencode fanout"); + let text = std::fs::read_to_string(&jsonc).unwrap(); + assert!(text.contains("// keep this note"), "{text}"); + let root = crate::acp::opencode_config::parse(&text).unwrap(); + assert_eq!( + root.pointer("/mcp/servers/ctx7/command"), + Some(&json!(["npx", "-y", "ctx7-mcp"])) + ); + assert!(!home.join("opencode.json").exists(), "no second file"); + + fanout_server_for_extra_homes( + McpAppType::OpenCode, + "ctx7", + None, + std::slice::from_ref(&home), + ) + .expect("opencode remove"); + let text = std::fs::read_to_string(&jsonc).unwrap(); + assert!(text.contains("// keep this note"), "{text}"); + let root = crate::acp::opencode_config::parse(&text).unwrap(); + assert!(root.pointer("/mcp/servers/ctx7").is_none()); + assert_eq!(root.get("model").and_then(Value::as_str), Some("keep")); + } + + /// A save that targets OpenCode also writes every extra OpenCode home, so + /// the preflight refuses one the writer would refuse before anything else + /// is written, reading the file that wins in that home. + #[test] + fn the_opencode_preflight_covers_extra_homes() { + let dir = tempfile::tempdir().expect("tempdir"); + let default = dir.path().join("default").join("opencode.json"); + let good = dir.path().join("opencode-2"); + let bad = dir.path().join("opencode-3"); + std::fs::create_dir_all(&good).unwrap(); + std::fs::create_dir_all(&bad).unwrap(); + std::fs::write(good.join("opencode.json"), r#"{ "mcp": { "servers": {} } }"#).unwrap(); + assert!(preflight_opencode_upsert_homes(&default, std::slice::from_ref(&good)).is_ok()); + + // `opencode.json` alone would pass; the `.jsonc` next to it wins. + std::fs::write(bad.join("opencode.json"), r#"{ "mcp": { "servers": {} } }"#).unwrap(); + std::fs::write(bad.join("opencode.jsonc"), r#"{ "mcp": { "servers": null } }"#).unwrap(); + assert!(preflight_opencode_upsert_homes(&default, &[good, bad.clone()]).is_err()); + assert_eq!( + std::fs::read_to_string(bad.join("opencode.jsonc")).unwrap(), + r#"{ "mcp": { "servers": null } }"# + ); + } } #[cfg(test)] diff --git a/src/components/settings/mcp-settings.tsx b/src/components/settings/mcp-settings.tsx index 2e4d9e9197..a21f967309 100644 --- a/src/components/settings/mcp-settings.tsx +++ b/src/components/settings/mcp-settings.tsx @@ -110,6 +110,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" },