From a8c031b0d9b46b21c7f940f3a9592bccd11e0ddf Mon Sep 17 00:00:00 2001 From: 1nderboi <201919958+1nderboi@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:45:53 -0400 Subject: [PATCH] fix(desktop): resolve claude config panel paths from agent's CLAUDE_CONFIG_DIR The agent config panel read Claude Code's MCP server list and file attribution from ~/.claude/settings.json and ~/.claude.json unconditionally, ignoring an agent's own CLAUDE_CONFIG_DIR. An isolated agent's runtime was correctly scoped, but its config panel still showed the operator's personal MCP servers, making the isolation look broken when it wasn't. claude::read_config_file and reader::mcp_config_file_path_for_runtime now resolve settings.json/.claude.json against the agent's effective CLAUDE_CONFIG_DIR (record.env_vars) when set, matching Claude Code's own resolution. Fixes #3493 Signed-off-by: 1nderboi <201919958+1nderboi@users.noreply.github.com> --- .../managed_agents/config_bridge/claude.rs | 74 +++++++++++++++++-- .../managed_agents/config_bridge/reader.rs | 25 ++++++- .../config_bridge/reader_tests.rs | 38 ++++++++++ 3 files changed, 127 insertions(+), 10 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs index 449197a3b3..881606f548 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs @@ -1,10 +1,13 @@ +use std::path::{Path, PathBuf}; + use super::types::{ExtensionEntry, RuntimeFileConfig}; -/// Read Claude Code config from `~/.claude/settings.json` and `~/.claude.json`. -pub(super) fn read_config_file() -> Option { - let home = dirs::home_dir()?; - let settings_path = home.join(".claude").join("settings.json"); - let mcp_path = home.join(".claude.json"); +/// Read Claude Code config from `settings.json` and `.claude.json`, resolved +/// against `config_dir` (the agent's effective `CLAUDE_CONFIG_DIR`, if set) or +/// `~/.claude`/`~` otherwise. Passing the correct `config_dir` for an isolated +/// agent is what keeps this from reading the operator's personal config. +pub(super) fn read_config_file(config_dir: Option<&Path>) -> Option { + let (settings_path, mcp_path) = claude_config_paths(config_dir)?; let settings = read_json_file(&settings_path); let mcp_config = read_json_file(&mcp_path); @@ -26,7 +29,7 @@ pub(super) fn read_config_file() -> Option { cfg.extra = super::schema_walker::extract_config_fields(s, skip); } - // MCP servers from ~/.claude.json + // MCP servers from mcp_path (~/.claude.json, or /.claude.json) let mut extensions = Vec::new(); if let Some(ref mc) = mcp_config { if let Some(servers) = mc.get("mcpServers").and_then(|v| v.as_object()) { @@ -44,6 +47,24 @@ pub(super) fn read_config_file() -> Option { Some(cfg) } +/// Resolve `(settings.json, .claude.json)` for `config_dir`. When +/// `config_dir` is `Some` (an explicit `CLAUDE_CONFIG_DIR`), both files live +/// directly under it — matching Claude Code's own resolution, which replaces +/// `~/.claude` *and* moves the top-level `.claude.json` inside it. Otherwise +/// falls back to the default `~/.claude/settings.json` + `~/.claude.json`. +pub(super) fn claude_config_paths(config_dir: Option<&Path>) -> Option<(PathBuf, PathBuf)> { + match config_dir { + Some(dir) => Some((dir.join("settings.json"), dir.join(".claude.json"))), + None => { + let home = dirs::home_dir()?; + Some(( + home.join(".claude").join("settings.json"), + home.join(".claude.json"), + )) + } + } +} + fn read_json_file(path: &std::path::Path) -> Option { let raw = std::fs::read_to_string(path).ok()?; serde_json::from_str(&raw).ok() @@ -61,6 +82,47 @@ fn json_string(val: &serde_json::Value, key: &str) -> Option { mod tests { use super::*; + #[test] + fn config_dir_override_reads_settings_and_mcp_from_isolated_dir() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("settings.json"), + r#"{"model": "isolated-model"}"#, + ) + .unwrap(); + std::fs::write( + dir.path().join(".claude.json"), + r#"{"mcpServers": {"isolated-server": {"command": "foo"}}}"#, + ) + .unwrap(); + + let cfg = read_config_file(Some(dir.path())).expect("config read from isolated dir"); + assert_eq!(cfg.model.as_deref(), Some("isolated-model")); + assert_eq!(cfg.extensions.len(), 1); + assert_eq!(cfg.extensions[0].name, "isolated-server"); + } + + #[test] + fn config_dir_override_does_not_leak_operator_home_config() { + // An isolated agent's CLAUDE_CONFIG_DIR pointing at an empty directory + // must never fall back to reading the operator's ~/.claude.json, even + // when the isolated dir has no files of its own yet. + let dir = tempfile::tempdir().unwrap(); + let cfg = read_config_file(Some(dir.path())); + assert!( + cfg.is_none(), + "empty isolated dir must not surface any config" + ); + } + + #[test] + fn claude_config_paths_nests_both_files_under_explicit_config_dir() { + let dir = tempfile::tempdir().unwrap(); + let (settings_path, mcp_path) = claude_config_paths(Some(dir.path())).unwrap(); + assert_eq!(settings_path, dir.path().join("settings.json")); + assert_eq!(mcp_path, dir.path().join(".claude.json")); + } + /// Parse a settings JSON string into a RuntimeFileConfig using the same /// logic as read_config_file but without touching the filesystem. fn parse_settings(json: &str) -> RuntimeFileConfig { diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 372d2cfde1..8ecc849e49 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -20,7 +20,13 @@ pub(crate) fn read_config_surface( .map(|m| m.id) .and_then(|id| match id { "goose" => super::goose::read_config_file().map(|c| (c, true)), - "claude" => super::claude::read_config_file().map(|c| (c, true)), + "claude" => super::claude::read_config_file( + record + .env_vars + .get("CLAUDE_CONFIG_DIR") + .map(std::path::Path::new), + ) + .map(|c| (c, true)), "codex" => super::codex::read_config_file().map(|c| (c, true)), "buzz-agent" => super::buzz_agent::read_config_file().map(|c| (c, true)), _ => None, @@ -167,7 +173,8 @@ pub(crate) fn read_config_surface( let config_file_path = runtime_meta .and_then(|m| m.config_file_path) .map(resolve_tilde); - let mcp_config_file_path = runtime_meta.and_then(mcp_config_file_path_for_runtime); + let mcp_config_file_path = + runtime_meta.and_then(|m| mcp_config_file_path_for_runtime(m, record)); let extensions = file_config.extensions.clone(); let sources = ConfigSourceReport { @@ -213,12 +220,22 @@ pub(crate) fn read_config_surface( } } -fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option { +fn mcp_config_file_path_for_runtime( + runtime: &KnownAcpRuntime, + record: &ManagedAgentRecord, +) -> Option { match runtime.id { "goose" => { super::goose::goose_config_path().map(|path| path.to_string_lossy().into_owned()) } - "claude" => Some(resolve_tilde("~/.claude.json")), + "claude" => { + let config_dir = record + .env_vars + .get("CLAUDE_CONFIG_DIR") + .map(std::path::Path::new); + super::claude::claude_config_paths(config_dir) + .map(|(_, mcp_path)| mcp_path.to_string_lossy().into_owned()) + } "codex" => { super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned()) } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 4ee4ec79c3..62a7f3628f 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -197,6 +197,44 @@ fn claude_surface_uses_mcp_config_path_not_settings_path() { .is_some_and(|path| path.ends_with(".claude.json"))); } +#[test] +fn claude_surface_honors_per_agent_config_dir_override() { + // An agent isolated via CLAUDE_CONFIG_DIR must read its own settings/MCP + // config from that directory, not the operator's ~/.claude — both for the + // extensions list and for the "From config file (…)" attribution path. + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("settings.json"), + r#"{"model": "isolated-model"}"#, + ) + .unwrap(); + std::fs::write( + dir.path().join(".claude.json"), + r#"{"mcpServers": {"isolated-server": {"command": "foo"}}}"#, + ) + .unwrap(); + + let mut record = test_record(); + record.env_vars.insert( + "CLAUDE_CONFIG_DIR".to_string(), + dir.path().display().to_string(), + ); + let runtime = &KnownAcpRuntime { + id: "claude", + config_file_path: Some("~/.claude/settings.json"), + ..*test_runtime() + }; + + let surface = read_config_surface(&record, Some(runtime), None, None); + + assert_eq!(surface.extensions.len(), 1); + assert_eq!(surface.extensions[0].name, "isolated-server"); + assert_eq!( + surface.sources.mcp_config_file_path.as_deref(), + Some(dir.path().join(".claude.json").to_string_lossy().as_ref()) + ); +} + #[test] fn record_model_overrides_file_model() { let mut record = test_record();