From 3d4fb4119b1e35e1cb34aeae74218b6c5a89b941 Mon Sep 17 00:00:00 2001 From: Randolf Jung Date: Tue, 10 Mar 2026 21:29:27 -0700 Subject: [PATCH] fix: respect env vars for client config paths and add Codex local scope support - Claude Code: check CLAUDE_CONFIG_DIR before defaulting to ~/.claude.json - VS Code: check XDG_CONFIG_HOME on Linux before defaulting to ~/.config - Codex: enable local config (SupportsLocal=true) with trust-level validation; reads projects..trust_level from global config.toml and returns an informative error if the project is not trusted Closes #1, #2 Co-Authored-By: Claude Sonnet 4.6 --- clients/claude.go | 4 + clients/clients_test.go | 196 +++++++++++++++++++++++++++++++++++++++- clients/codex.go | 89 ++++++++++++++++-- clients/vscode.go | 6 +- 4 files changed, 283 insertions(+), 12 deletions(-) diff --git a/clients/claude.go b/clients/claude.go index 8618cda..d861037 100644 --- a/clients/claude.go +++ b/clients/claude.go @@ -60,6 +60,10 @@ func getClaudeDesktopConfigPathImpl() (string, error) { } func getClaudeCodeConfigPathImpl() (string, error) { + if claudeConfigDir := os.Getenv("CLAUDE_CONFIG_DIR"); claudeConfigDir != "" { + return filepath.Join(claudeConfigDir, "claude.json"), nil + } + home, err := os.UserHomeDir() if err != nil { return "", err diff --git a/clients/clients_test.go b/clients/clients_test.go index 085edbb..95b3e4d 100644 --- a/clients/clients_test.go +++ b/clients/clients_test.go @@ -2,6 +2,7 @@ package clients import ( "encoding/json" + "fmt" "os" "path/filepath" "runtime" @@ -629,7 +630,7 @@ func TestClientSupportsLocal(t *testing.T) { {"cline", false}, {"vscode", true}, {"continue", false}, - {"codex", false}, + {"codex", true}, {"gemini", true}, {"kilo-code", true}, {"antigravity", true}, @@ -1625,6 +1626,199 @@ func TestSyncToOpenCode_PreservesOtherSettings(t *testing.T) { } } +func TestClaudeCodeConfigPath_WithEnvVar(t *testing.T) { + customDir := t.TempDir() + t.Setenv("CLAUDE_CONFIG_DIR", customDir) + + path, err := getClaudeCodeConfigPathImpl() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := filepath.Join(customDir, "claude.json") + if path != expected { + t.Errorf("expected path %q, got %q", expected, path) + } +} + +func TestClaudeCodeConfigPath_WithoutEnvVar(t *testing.T) { + t.Setenv("CLAUDE_CONFIG_DIR", "") + + home, _ := os.UserHomeDir() + expected := filepath.Join(home, ".claude.json") + + path, err := getClaudeCodeConfigPathImpl() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if path != expected { + t.Errorf("expected path %q, got %q", expected, path) + } +} + +func TestVSCodeConfigPath_WithXDGEnvVar(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("XDG_CONFIG_HOME only applies on Linux") + } + + customDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", customDir) + + path, err := getVSCodeConfigPathImpl() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := filepath.Join(customDir, "Code", "User", "settings.json") + if path != expected { + t.Errorf("expected path %q, got %q", expected, path) + } +} + +func TestVSCodeConfigPath_WithoutXDGEnvVar(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("XDG_CONFIG_HOME only applies on Linux") + } + + t.Setenv("XDG_CONFIG_HOME", "") + + home, _ := os.UserHomeDir() + expected := filepath.Join(home, ".config", "Code", "User", "settings.json") + + path, err := getVSCodeConfigPathImpl() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if path != expected { + t.Errorf("expected path %q, got %q", expected, path) + } +} + +func TestReadCodexProjectTrustLevel_Trusted(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.toml") + + projectPath := "/home/user/myproject" + content := fmt.Sprintf("[projects.%q]\ntrust_level = \"trusted\"\n", projectPath) + os.WriteFile(configPath, []byte(content), 0o644) + + level, err := readCodexProjectTrustLevel(configPath, projectPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if level != "trusted" { + t.Errorf("expected trust level %q, got %q", "trusted", level) + } +} + +func TestReadCodexProjectTrustLevel_Untrusted(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.toml") + + projectPath := "/home/user/myproject" + content := fmt.Sprintf("[projects.%q]\ntrust_level = \"untrusted\"\n", projectPath) + os.WriteFile(configPath, []byte(content), 0o644) + + level, err := readCodexProjectTrustLevel(configPath, projectPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if level != "untrusted" { + t.Errorf("expected trust level %q, got %q", "untrusted", level) + } +} + +func TestReadCodexProjectTrustLevel_MissingProject(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.toml") + + content := "[projects.\"/other/project\"]\ntrust_level = \"trusted\"\n" + os.WriteFile(configPath, []byte(content), 0o644) + + level, err := readCodexProjectTrustLevel(configPath, "/home/user/myproject") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if level != "" { + t.Errorf("expected empty trust level, got %q", level) + } +} + +func TestReadCodexProjectTrustLevel_NoConfigFile(t *testing.T) { + level, err := readCodexProjectTrustLevel("/nonexistent/path/config.toml", "/home/user/project") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if level != "" { + t.Errorf("expected empty trust level for missing config, got %q", level) + } +} + +func TestReadCodexProjectTrustLevel_MultipleProjects(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.toml") + + projectPath := "/home/user/myproject" + content := fmt.Sprintf( + "[projects.\"/other/project\"]\ntrust_level = \"trusted\"\n\n[projects.%q]\ntrust_level = \"trusted\"\n", + projectPath, + ) + os.WriteFile(configPath, []byte(content), 0o644) + + level, err := readCodexProjectTrustLevel(configPath, projectPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if level != "trusted" { + t.Errorf("expected trust level %q, got %q", "trusted", level) + } +} + +func TestCodexLocalPath_Trusted(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CODEX_HOME", tempDir) + + cwd, _ := os.Getwd() + content := fmt.Sprintf("[projects.%q]\ntrust_level = \"trusted\"\n", cwd) + os.WriteFile(filepath.Join(tempDir, "config.toml"), []byte(content), 0o644) + + path, err := getCodexLocalPathImpl() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := filepath.Join(cwd, ".codex", "config.toml") + if path != expected { + t.Errorf("expected path %q, got %q", expected, path) + } +} + +func TestCodexLocalPath_Untrusted(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CODEX_HOME", tempDir) + + // Write config without trust for the current directory + os.WriteFile(filepath.Join(tempDir, "config.toml"), []byte(""), 0o644) + + _, err := getCodexLocalPathImpl() + if err == nil { + t.Error("expected error for untrusted project, got nil") + } +} + +func TestCodexLocalPath_NoConfigFile(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CODEX_HOME", tempDir) + // No config.toml written — trust level is absent + + _, err := getCodexLocalPathImpl() + if err == nil { + t.Error("expected error when no Codex config exists (project not trusted), got nil") + } +} + func TestSyncIdempotency_OpenCode(t *testing.T) { tempDir, err := os.MkdirTemp("", "mcpr-test-*") if err != nil { diff --git a/clients/codex.go b/clients/codex.go index a44bc30..e87fabf 100644 --- a/clients/codex.go +++ b/clients/codex.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "sort" + "strings" "github.com/mathematic-inc/mcpr/config" ) @@ -12,6 +13,7 @@ import ( // Path functions as variables for testing var ( getCodexConfigPath = getCodexConfigPathImpl + getCodexLocalPath = getCodexLocalPathImpl ) func init() { @@ -19,23 +21,90 @@ func init() { Name: "codex", DisplayName: "Codex (OpenAI)", GlobalPath: func() (string, error) { return getCodexConfigPath() }, - LocalPath: nil, - SupportsLocal: false, + LocalPath: func() (string, error) { return getCodexLocalPath() }, + SupportsLocal: true, SyncFunc: syncToCodex, }) } func getCodexConfigPathImpl() (string, error) { - // Check CODEX_HOME env var first - codexHome := os.Getenv("CODEX_HOME") - if codexHome == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", err + codexHome := codexHomeDir() + return filepath.Join(codexHome, "config.toml"), nil +} + +func getCodexLocalPathImpl() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + if err := validateCodexProjectTrust(cwd); err != nil { + return "", err + } + return filepath.Join(cwd, ".codex", "config.toml"), nil +} + +func codexHomeDir() string { + if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" { + return codexHome + } + home, err := os.UserHomeDir() + if err != nil { + return ".codex" + } + return filepath.Join(home, ".codex") +} + +func validateCodexProjectTrust(projectPath string) error { + globalConfigPath := filepath.Join(codexHomeDir(), "config.toml") + trustLevel, err := readCodexProjectTrustLevel(globalConfigPath, projectPath) + if err != nil { + return err + } + if trustLevel != "trusted" { + return fmt.Errorf( + "project %q is not trusted in Codex\n\nTo enable local configuration, add the following to %s:\n\n[projects.%q]\ntrust_level = \"trusted\"", + projectPath, globalConfigPath, projectPath, + ) + } + return nil +} + +// readCodexProjectTrustLevel parses configPath for the trust_level of the given project. +// Returns "" if the project section or key is not found. +func readCodexProjectTrustLevel(configPath, projectPath string) (string, error) { + data, err := os.ReadFile(configPath) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("failed to read Codex config: %w", err) + } + + // Build the expected section header, e.g. [projects."/home/user/project"] + sectionHeader := fmt.Sprintf(`[projects.%q]`, projectPath) + + inSection := false + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == sectionHeader { + inSection = true + continue + } + if inSection { + if strings.HasPrefix(trimmed, "[") { + break + } + if strings.HasPrefix(trimmed, "trust_level") { + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) == 2 { + val := strings.TrimSpace(parts[1]) + val = strings.Trim(val, `"'`) + return val, nil + } + } } - codexHome = filepath.Join(home, ".codex") } - return filepath.Join(codexHome, "config.toml"), nil + return "", nil } func syncToCodex(servers []config.MCPServer, path string) error { diff --git a/clients/vscode.go b/clients/vscode.go index 920d3b5..81b508b 100644 --- a/clients/vscode.go +++ b/clients/vscode.go @@ -42,7 +42,11 @@ func getVSCodeConfigPathImpl() (string, error) { } return filepath.Join(appData, "Code", "User", "settings.json"), nil case "linux": - return filepath.Join(home, ".config", "Code", "User", "settings.json"), nil + configDir := os.Getenv("XDG_CONFIG_HOME") + if configDir == "" { + configDir = filepath.Join(home, ".config") + } + return filepath.Join(configDir, "Code", "User", "settings.json"), nil default: return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS) }