Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 68 additions & 6 deletions desktop/src-tauri/src/managed_agents/config_bridge/claude.rs
Original file line number Diff line number Diff line change
@@ -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<RuntimeFileConfig> {
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<RuntimeFileConfig> {
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);
Expand All @@ -26,7 +29,7 @@ pub(super) fn read_config_file() -> Option<RuntimeFileConfig> {
cfg.extra = super::schema_walker::extract_config_fields(s, skip);
}

// MCP servers from ~/.claude.json
// MCP servers from mcp_path (~/.claude.json, or <CLAUDE_CONFIG_DIR>/.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()) {
Expand All @@ -44,6 +47,24 @@ pub(super) fn read_config_file() -> Option<RuntimeFileConfig> {
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<serde_json::Value> {
let raw = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&raw).ok()
Expand All @@ -61,6 +82,47 @@ fn json_string(val: &serde_json::Value, key: &str) -> Option<String> {
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 {
Expand Down
25 changes: 21 additions & 4 deletions desktop/src-tauri/src/managed_agents/config_bridge/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -213,12 +220,22 @@ pub(crate) fn read_config_surface(
}
}

fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option<String> {
fn mcp_config_file_path_for_runtime(
runtime: &KnownAcpRuntime,
record: &ManagedAgentRecord,
) -> Option<String> {
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())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down