From ca0ee6e93e55a4ffb6d97bcbcf0c7acc7607f968 Mon Sep 17 00:00:00 2001 From: Sivan Date: Wed, 17 Jun 2026 12:22:52 +0800 Subject: [PATCH 1/2] fix(config): prefer global provider credentials --- desktop/app_test.go | 88 ++++++++++++++++++++++++++++++++ desktop/settings_app.go | 53 +++++++++++++++++++ internal/config/config.go | 30 ++++++++--- internal/config/credentials.go | 70 +++++++++++++++++++++++-- internal/config/dotenv.go | 14 +++++ internal/config/dotenv_test.go | 44 ++++++++++++++++ internal/config/edit_test.go | 51 ++++++++++++++++++ internal/config/loadedit_test.go | 35 +++++++++++++ 8 files changed, 373 insertions(+), 12 deletions(-) diff --git a/desktop/app_test.go b/desktop/app_test.go index 43883e801..20f41c408 100644 --- a/desktop/app_test.go +++ b/desktop/app_test.go @@ -535,6 +535,51 @@ status_bar_items = ["cost", "balance"] } } +func TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) { + isolateDesktopUserDirs(t) + + project := robustTempDir(t) + launch := robustTempDir(t) + if err := os.WriteFile(filepath.Join(project, ".env"), []byte("WORKSPACE_ONLY_KEY=from-project\n"), 0o600); err != nil { + t.Fatalf("write project env: %v", err) + } + userCfg := config.LoadForEdit(config.UserConfigPath()) + if err := userCfg.UpsertProvider(config.ProviderEntry{ + Name: "workspace-provider", + Kind: "openai", + BaseURL: "https://workspace.example/v1", + Model: "workspace-model", + APIKeyEnv: "WORKSPACE_ONLY_KEY", + }); err != nil { + t.Fatalf("upsert provider: %v", err) + } + userCfg.Desktop.ProviderAccess = []string{"workspace-provider"} + if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { + t.Fatalf("save user config: %v", err) + } + t.Setenv("WORKSPACE_ONLY_KEY", "") + os.Unsetenv("WORKSPACE_ONLY_KEY") + orig, _ := os.Getwd() + defer func() { _ = os.Chdir(orig) }() + if err := os.Chdir(launch); err != nil { + t.Fatalf("chdir launch: %v", err) + } + + app := NewApp() + app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}} + app.activeTabID = "project" + got := app.Settings() + for _, p := range got.Providers { + if p.Name == "workspace-provider" { + if !p.KeySet { + t.Fatalf("workspace provider keySet = false, want true from active workspace .env: %+v", p) + } + return + } + } + t.Fatalf("workspace provider missing from settings: %+v", got.Providers) +} + func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) { isolateDesktopUserDirs(t) @@ -866,6 +911,49 @@ api_key_env = "DEEPSEEK_API_KEY" } } +func TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) { + isolateDesktopUserDirs(t) + t.Setenv("DEEPSEEK_API_KEY", "") + os.Unsetenv("DEEPSEEK_API_KEY") + if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + if err := os.WriteFile(config.UserConfigPath(), []byte(` +default_model = "deepseek/deepseek-v4-flash" + +[desktop] +provider_access = [] + +[[providers]] +name = "deepseek" +kind = "openai" +base_url = "https://api.deepseek.com" +models = ["deepseek-v4-flash", "deepseek-v4-pro"] +default = "deepseek-v4-flash" +api_key_env = "DEEPSEEK_API_KEY" +`), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + if _, err := NewApp().SetProviderKey("DEEPSEEK_API_KEY", "sk-test"); err != nil { + t.Fatalf("SetProviderKey: %v", err) + } + cfg := config.LoadForEdit(config.UserConfigPath()) + if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] { + t.Fatalf("provider_access = %+v, want deepseek restored", cfg.Desktop.ProviderAccess) + } + got := NewApp().Settings() + for _, p := range got.Providers { + if p.Name == "deepseek" { + if !p.Added || !p.KeySet { + t.Fatalf("deepseek settings = %+v, want added and key-set", p) + } + return + } + } + t.Fatalf("settings providers missing deepseek: %+v", got.Providers) +} + func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) { isolateDesktopUserDirs(t) if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { diff --git a/desktop/settings_app.go b/desktop/settings_app.go index 0c6dad599..8f50e0e23 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -553,6 +553,8 @@ func (a *App) loadDesktopUserConfigForEdit() (*config.Config, string, error) { if userPath == "" { return nil, "", fmt.Errorf("cannot resolve user config directory") } + config.LoadGlobalCredentials() + config.LoadCredentialsForRoot(a.activeWorkspaceRoot()) if _, err := os.Stat(userPath); err == nil { cfg := config.LoadForEdit(userPath) normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath) @@ -1364,12 +1366,63 @@ func (a *App) SetProviderKey(apiKeyEnv, value string) (string, error) { if err != nil { return "", err } + if err := a.ensureProviderAccessForKey(apiKeyEnv); err != nil { + return "", err + } if err := a.rebuild(); err != nil { return "", err } return warning, nil } +func (a *App) ensureProviderAccessForKey(apiKeyEnv string) error { + apiKeyEnv = strings.TrimSpace(apiKeyEnv) + if apiKeyEnv == "" { + return nil + } + cfg, path, err := a.loadDesktopUserConfigForEdit() + if err != nil { + return err + } + access := providerAccessSet(cfg.Desktop.ProviderAccess) + changed := false + addAccess := func(name string) { + name = config.CanonicalDesktopOfficialProviderName(name) + if name == "" || access[name] { + return + } + addProviderAccess(cfg, name) + access[name] = true + changed = true + } + for i := range cfg.Providers { + p := cfg.Providers[i] + if strings.TrimSpace(p.APIKeyEnv) != apiKeyEnv { + continue + } + if len(p.ModelList()) == 0 { + continue + } + addAccess(p.Name) + } + if !changed && apiKeyEnv == "DEEPSEEK_API_KEY" { + entries, _, err := officialProviderTemplate("deepseek", cfg.DeepSeekOfficialPricingLanguage()) + if err != nil { + return err + } + for _, e := range entries { + if err := cfg.UpsertProvider(e); err != nil { + return err + } + addAccess(e.Name) + } + } + if !changed { + return nil + } + return cfg.SaveTo(path) +} + // ClearProviderKey removes a provider secret from the global credential store // and rebuilds so the provider immediately becomes unauthenticated. func (a *App) ClearProviderKey(apiKeyEnv string) error { diff --git a/internal/config/config.go b/internal/config/config.go index 03e238fda..841a5caed 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1640,10 +1640,11 @@ func mergeTOMLPlugins(paths []string) ([]PluginEntry, error) { return merged, nil } -// mergeTOMLProviders merges [[providers]] across TOML sources by provider name -// (later source wins). Keep official legacy aliases distinct here: they can carry -// different default models and effort capabilities, and the later desktop -// normalization layer handles canonical Settings access. +// mergeTOMLProviders merges [[providers]] across TOML sources by provider name. +// User-global providers win over same-named project providers; project providers +// only fill names the global config does not define. Keep official legacy aliases +// distinct here: they can carry different default models and effort capabilities, +// and the later desktop normalization layer handles canonical Settings access. func mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, bool, error) { var merged []ProviderEntry index := map[string]int{} @@ -1666,12 +1667,16 @@ func mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSou normalizeProviderEffortFields(&p) key := providerMergeKey(p) if i, ok := index[key]; ok { - merged[i] = p + if sources[key] == providerSourceProject && source == providerSourceUser { + merged[i] = p + sources[key] = source + } + continue } else { index[key] = len(merged) merged = append(merged, p) + sources[key] = source } - sources[key] = source } } return merged, sources, saw, nil @@ -1731,14 +1736,14 @@ func LoadForEdit(path string) *Config { return cfg } slog.Warn("config: load for edit failed, using defaults", "path", path, "err", err) - loadDotEnv() + loadDotEnvForEditPath(path) cfg = Default() normalizeConfigForEdit(cfg) return cfg } func loadForEditStrict(path string) (*Config, error) { - loadDotEnv() + loadDotEnvForEditPath(path) cfg := Default() if _, err := os.Stat(path); err == nil { if err := migrateLegacyMCPTiersFile(path); err != nil { @@ -1763,6 +1768,15 @@ func normalizeConfigForEdit(cfg *Config) { normalizeEffortConfig(cfg) } +func loadDotEnvForEditPath(path string) { + path = strings.TrimSpace(path) + if path == "" || isUserConfigPath(path) { + loadDotEnv() + return + } + loadDotEnvForRoot(filepath.Dir(path)) +} + // mergeFile decodes a TOML file onto cfg if it exists. An absent file is not an error. func mergeFile(cfg *Config, path string) error { if _, err := os.Stat(path); err != nil { diff --git a/internal/config/credentials.go b/internal/config/credentials.go index df75515f1..65bdaae77 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -100,6 +100,16 @@ func credentialEnvNamesForRoot(root string) []string { cfg.Providers = providers } + return credentialEnvNamesFromConfig(cfg) +} + +func credentialEnvNamesForConfigPath(path string) []string { + cfg := Default() + _ = mergeFile(cfg, path) + return credentialEnvNamesFromConfig(cfg) +} + +func credentialEnvNamesFromConfig(cfg *Config) []string { seen := map[string]bool{} var out []string add := func(name string) { @@ -125,14 +135,21 @@ func credentialEnvNamesForRoot(root string) []string { } func loadCredentialStoreForRoot(root string) { - names := credentialEnvNamesForRoot(root) + loadCredentialStoreForNames(credentialEnvNamesForRoot(root), false) +} + +func loadGlobalCredentialStore() { + loadCredentialStoreForNames(credentialEnvNamesForConfigPath(userConfigLoadPath()), true) +} + +func loadCredentialStoreForNames(names []string, overrideLocalDotEnv bool) { if len(names) == 0 { return } mode := credentialsStoreMode() if mode == CredentialsStoreAuto || mode == CredentialsStoreKeyring { for _, name := range names { - if _, exists := os.LookupEnv(name); exists { + if !credentialStoreShouldLoad(name, overrideLocalDotEnv) { recordExistingCredentialSource(name) continue } @@ -145,10 +162,55 @@ func loadCredentialStoreForRoot(root string) { } if mode == CredentialsStoreAuto || mode == CredentialsStoreFile { if p := UserCredentialsPath(); p != "" { - loadDotEnvFileAs(p, CredentialSource{Kind: CredentialSourceCredentials, Path: p, Label: "Reasonix credentials"}) + if overrideLocalDotEnv { + loadCredentialFileForNames(p, names, true, CredentialSource{Kind: CredentialSourceCredentials, Path: p, Label: "Reasonix credentials"}) + } else { + loadDotEnvFileAs(p, CredentialSource{Kind: CredentialSourceCredentials, Path: p, Label: "Reasonix credentials"}) + } } for _, p := range legacyCredentialsPaths() { - loadDotEnvFileAs(p, CredentialSource{Kind: CredentialSourceLegacy, Path: p, Label: "legacy Reasonix credentials"}) + if overrideLocalDotEnv { + loadCredentialFileForNames(p, names, true, CredentialSource{Kind: CredentialSourceLegacy, Path: p, Label: "legacy Reasonix credentials"}) + } else { + loadDotEnvFileAs(p, CredentialSource{Kind: CredentialSourceLegacy, Path: p, Label: "legacy Reasonix credentials"}) + } + } + } +} + +func credentialStoreShouldLoad(name string, overrideLocalDotEnv bool) bool { + value, exists := os.LookupEnv(name) + if !exists { + return true + } + if !overrideLocalDotEnv { + return false + } + source, ok := trackedCredential(name, value) + if !ok { + return false + } + switch source.Kind { + case CredentialSourceProjectEnv, CredentialSourceHomeEnv, CredentialSourceLegacy: + return true + default: + return false + } +} + +func loadCredentialFileForNames(path string, names []string, overrideLocalDotEnv bool, source CredentialSource) { + for _, name := range names { + if !credentialStoreShouldLoad(name, overrideLocalDotEnv) { + recordExistingCredentialSource(name) + continue + } + value, ok := envFileValue(path, name) + if !ok || value == "" { + continue + } + if err := os.Setenv(name, value); err == nil { + source.Path = path + recordCredentialSource(name, value, source) } } } diff --git a/internal/config/dotenv.go b/internal/config/dotenv.go index f235edfe8..78cfef74b 100644 --- a/internal/config/dotenv.go +++ b/internal/config/dotenv.go @@ -33,6 +33,20 @@ func loadDotEnvForRoot(root string) { } } +// LoadCredentialsForRoot loads the credential environment for a workspace root +// without decoding or mutating the full runtime config. Existing environment +// variables keep the same first-wins precedence as LoadForRoot. +func LoadCredentialsForRoot(root string) { + loadDotEnvForRoot(root) +} + +// LoadGlobalCredentials loads credentials referenced by the user-global config +// from the system credential store or ~/.reasonix/credentials, without consulting +// a project .env first. +func LoadGlobalCredentials() { + loadGlobalCredentialStore() +} + func legacyCredentialsPaths() []string { current := UserCredentialsPath() seen := map[string]bool{} diff --git a/internal/config/dotenv_test.go b/internal/config/dotenv_test.go index d8f1d7fbf..228c8cbfa 100644 --- a/internal/config/dotenv_test.go +++ b/internal/config/dotenv_test.go @@ -90,6 +90,50 @@ func TestLoadDotEnvReadsGlobalCredentials(t *testing.T) { } } +func TestLoadGlobalCredentialsOverridesProjectDotEnvSource(t *testing.T) { + project := t.TempDir() + home := t.TempDir() + + t.Setenv("HOME", home) + t.Setenv("REASONIX_CREDENTIALS_STORE", "file") + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("AppData", filepath.Join(home, "AppData")) + + key := "KEY_GLOBAL_PRIORITY" + if err := os.MkdirAll(filepath.Dir(UserConfigPath()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(UserConfigPath(), []byte(` +[[providers]] +name = "global-priority" +kind = "openai" +base_url = "https://example.invalid/v1" +model = "m" +api_key_env = "KEY_GLOBAL_PRIORITY" +`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(UserCredentialsPath(), []byte(key+"=from_credentials\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(project, ".env"), []byte(key+"=from_project\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(key, "") + os.Unsetenv(key) + + loadDotEnvForRoot(project) + if got := os.Getenv(key); got != "from_project" { + t.Fatalf("precondition: project .env should load first, got %q", got) + } + LoadGlobalCredentials() + got := ResolveCredentialForRoot(project, key) + if got.Value != "from_credentials" || got.Source.Kind != CredentialSourceCredentials { + t.Fatalf("credential = %+v, want global credentials to override project .env for settings reads", got) + } +} + func TestResolveCredentialSourceShowsProjectEnvShadowingCredentials(t *testing.T) { cwd := t.TempDir() cfgHome := t.TempDir() diff --git a/internal/config/edit_test.go b/internal/config/edit_test.go index 450c7f07a..347d9c36a 100644 --- a/internal/config/edit_test.go +++ b/internal/config/edit_test.go @@ -1005,6 +1005,57 @@ effort = "max" } } +func TestLoadForRootKeepsUserProviderOverSameNamedProjectProvider(t *testing.T) { + isolateUserConfigHome(t) + root := t.TempDir() + userPath := UserConfigPath() + if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(userPath, []byte(` +[[providers]] +name = "shared" +kind = "openai" +base_url = "https://global.example/v1" +model = "global-model" +api_key_env = "GLOBAL_SHARED_KEY" +`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(` +[[providers]] +name = "shared" +kind = "openai" +base_url = "https://project.example/v1" +model = "project-model" +api_key_env = "PROJECT_SHARED_KEY" + +[[providers]] +name = "project-only" +kind = "openai" +base_url = "https://project.example/v1" +model = "project-only-model" +api_key_env = "PROJECT_ONLY_KEY" +`), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadForRoot(root) + if err != nil { + t.Fatalf("LoadForRoot: %v", err) + } + shared, ok := cfg.Provider("shared") + if !ok { + t.Fatalf("shared provider missing: %+v", cfg.Providers) + } + if shared.BaseURL != "https://global.example/v1" || shared.APIKeyEnv != "GLOBAL_SHARED_KEY" || shared.Model != "global-model" { + t.Fatalf("shared provider = %+v, want global provider to win over project provider", shared) + } + if _, ok := cfg.Provider("project-only"); !ok { + t.Fatalf("project-only provider missing: %+v", cfg.Providers) + } +} + func TestSaveForRootDoesNotWriteUserProvidersIntoProjectConfig(t *testing.T) { isolateUserConfigHome(t) root := t.TempDir() diff --git a/internal/config/loadedit_test.go b/internal/config/loadedit_test.go index 2d3d392c5..4e5e69f95 100644 --- a/internal/config/loadedit_test.go +++ b/internal/config/loadedit_test.go @@ -72,3 +72,38 @@ model = "m" t.Fatalf("migration should preserve ordinary config:\n%s", updated) } } + +func TestLoadForEditLoadsDotEnvNextToEditedProjectConfig(t *testing.T) { + project := t.TempDir() + launch := t.TempDir() + path := filepath.Join(project, "reasonix.toml") + body := `default_model = "custom/m" +[[providers]] +name = "custom" +kind = "openai" +base_url = "https://example.invalid/v1" +model = "m" +api_key_env = "PROJECT_ONLY_KEY" +` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(project, ".env"), []byte("PROJECT_ONLY_KEY=from-project\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Chdir(launch) + t.Setenv("PROJECT_ONLY_KEY", "") + os.Unsetenv("PROJECT_ONLY_KEY") + + cfg := LoadForEdit(path) + provider, ok := cfg.Provider("custom") + if !ok { + t.Fatalf("provider missing from edited config: %+v", cfg.Providers) + } + if !provider.Configured() { + t.Fatalf("provider should resolve api_key_env from project .env next to edited config") + } + if got := ResolveCredentialForRoot(project, "PROJECT_ONLY_KEY"); !got.Set || got.Value != "from-project" { + t.Fatalf("credential = %+v, want project .env value", got) + } +} From e35ec315572c7085cdae9dc3ccf832415a565928 Mon Sep 17 00:00:00 2001 From: Sivan Date: Wed, 17 Jun 2026 13:06:15 +0800 Subject: [PATCH 2/2] fix(config): address global provider review gaps --- desktop/app_test.go | 85 +++++++++++++++++++ desktop/settings_app.go | 45 +++++++++-- internal/config/backfill_test.go | 21 +++++ internal/config/config.go | 75 ++++++++++++++--- internal/config/credentials.go | 135 ++++++++++++++++--------------- internal/config/dotenv.go | 28 ++----- internal/config/dotenv_test.go | 59 ++++++-------- internal/config/edit_test.go | 49 +++++++++++ internal/config/migrate.go | 2 +- internal/config/render.go | 3 +- 10 files changed, 365 insertions(+), 137 deletions(-) diff --git a/desktop/app_test.go b/desktop/app_test.go index 20f41c408..5c0f8720d 100644 --- a/desktop/app_test.go +++ b/desktop/app_test.go @@ -580,6 +580,57 @@ func TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) { t.Fatalf("workspace provider missing from settings: %+v", got.Providers) } +func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) { + isolateDesktopUserDirs(t) + + project := robustTempDir(t) + launch := robustTempDir(t) + if err := os.WriteFile(filepath.Join(project, ".env"), []byte("SHARED_SETTINGS_KEY=from-project\n"), 0o600); err != nil { + t.Fatalf("write project env: %v", err) + } + if _, err := config.SetCredential("SHARED_SETTINGS_KEY", "from-credentials"); err != nil { + t.Fatalf("SetCredential: %v", err) + } + userCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath()) + if err := userCfg.UpsertProvider(config.ProviderEntry{ + Name: "settings-provider", + Kind: "openai", + BaseURL: "https://settings.example/v1", + Model: "settings-model", + APIKeyEnv: "SHARED_SETTINGS_KEY", + }); err != nil { + t.Fatalf("upsert provider: %v", err) + } + userCfg.Desktop.ProviderAccess = []string{"settings-provider"} + if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { + t.Fatalf("save user config: %v", err) + } + t.Setenv("SHARED_SETTINGS_KEY", "from-project") + orig, _ := os.Getwd() + defer func() { _ = os.Chdir(orig) }() + if err := os.Chdir(launch); err != nil { + t.Fatalf("chdir launch: %v", err) + } + + app := NewApp() + app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}} + app.activeTabID = "project" + got := app.Settings() + for _, p := range got.Providers { + if p.Name != "settings-provider" { + continue + } + if !p.KeySet || p.KeySource != "Reasonix credentials" { + t.Fatalf("settings-provider key = set:%v source:%q, want Reasonix credentials: %+v", p.KeySet, p.KeySource, p) + } + if env := os.Getenv("SHARED_SETTINGS_KEY"); env != "from-project" { + t.Fatalf("Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env", env) + } + return + } + t.Fatalf("settings provider missing from settings: %+v", got.Providers) +} + func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) { isolateDesktopUserDirs(t) @@ -954,6 +1005,40 @@ api_key_env = "DEEPSEEK_API_KEY" t.Fatalf("settings providers missing deepseek: %+v", got.Providers) } +func TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) { + isolateDesktopUserDirs(t) + t.Setenv("PROXY_DEEPSEEK_KEY", "") + os.Unsetenv("PROXY_DEEPSEEK_KEY") + if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + if err := os.WriteFile(config.UserConfigPath(), []byte(` +[desktop] +provider_access = [] + +[[providers]] +name = "deepseek-flash" +kind = "openai" +base_url = "https://proxy.example/v1" +model = "deepseek-v4-flash" +api_key_env = "PROXY_DEEPSEEK_KEY" +`), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + if _, err := NewApp().SetProviderKey("PROXY_DEEPSEEK_KEY", "sk-test"); err != nil { + t.Fatalf("SetProviderKey: %v", err) + } + cfg := config.LoadForEditWithoutCredentials(config.UserConfigPath()) + access := providerAccessSet(cfg.Desktop.ProviderAccess) + if !access["deepseek-flash"] { + t.Fatalf("provider_access = %+v, want custom alias deepseek-flash", cfg.Desktop.ProviderAccess) + } + if access["deepseek"] { + t.Fatalf("provider_access = %+v, should not canonicalize custom proxy to deepseek", cfg.Desktop.ProviderAccess) + } +} + func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) { isolateDesktopUserDirs(t) if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil { diff --git a/desktop/settings_app.go b/desktop/settings_app.go index 8f50e0e23..375924952 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -284,7 +284,7 @@ func providerViewFromEntryForRoot(p config.ProviderEntry, builtIn, added bool, r if p.Vision { visionModels = models } - key := config.ResolveCredentialForRoot(root, p.APIKeyEnv) + key := config.ResolveCredentialForRootGlobalFirst(root, p.APIKeyEnv) return ProviderView{ Name: p.Name, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL, Models: nonNil(models), VisionModels: nonNil(providerVisionModels(models, visionModels)), VisionModelsSet: visionModelsSet, ModelsURL: p.ModelsURL, Default: p.DefaultModel(), @@ -338,7 +338,7 @@ func officialProviderAddedSet(cfg *config.Config) map[string]bool { // Settings returns the current configuration for the Settings panel. func (a *App) Settings() SettingsView { - cfg, cfgPath, err := a.loadDesktopUserConfigForEdit() + cfg, cfgPath, err := a.loadDesktopUserConfigForView() if err != nil { return SettingsView{ Providers: []ProviderView{}, @@ -553,8 +553,6 @@ func (a *App) loadDesktopUserConfigForEdit() (*config.Config, string, error) { if userPath == "" { return nil, "", fmt.Errorf("cannot resolve user config directory") } - config.LoadGlobalCredentials() - config.LoadCredentialsForRoot(a.activeWorkspaceRoot()) if _, err := os.Stat(userPath); err == nil { cfg := config.LoadForEdit(userPath) normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath) @@ -578,6 +576,38 @@ func (a *App) loadDesktopUserConfigForEdit() (*config.Config, string, error) { return legacyCfg, userPath, nil } +func (a *App) loadDesktopUserConfigForView() (*config.Config, string, error) { + userPath := config.UserConfigPath() + if userPath == "" { + return nil, "", fmt.Errorf("cannot resolve user config directory") + } + if _, err := os.Stat(userPath); err == nil { + cfg := config.LoadForEditWithoutCredentials(userPath) + normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath) + legacyPath := config.SourcePathForRoot(a.activeWorkspaceRoot()) + if legacyPath != "" && !sameConfigPath(legacyPath, userPath) { + legacyCfg := config.LoadForEditWithoutCredentials(legacyPath) + if err := migrateLegacyBotConfigToUser(cfg, legacyCfg, userPath); err != nil { + return nil, "", err + } + } + return cfg, userPath, nil + } + cfg := config.LoadForEditWithoutCredentials(userPath) + legacyPath := config.SourcePathForRoot(a.activeWorkspaceRoot()) + if legacyPath == "" || sameConfigPath(legacyPath, userPath) { + normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath) + return cfg, userPath, nil + } + legacyCfg := config.LoadForEditWithoutCredentials(legacyPath) + normalizeLegacyDesktopProviderAccessForSettings(legacyCfg, legacyPath) + legacyCfg.ConfigVersion = config.Default().ConfigVersion + if err := migrateLegacyBotConfigToUser(cfg, legacyCfg, userPath); err != nil { + return nil, "", err + } + return legacyCfg, userPath, nil +} + func (a *App) migrateLegacyBotConfigToUser(userCfg *config.Config, userPath string) error { if userCfg == nil { return nil @@ -1387,7 +1417,6 @@ func (a *App) ensureProviderAccessForKey(apiKeyEnv string) error { access := providerAccessSet(cfg.Desktop.ProviderAccess) changed := false addAccess := func(name string) { - name = config.CanonicalDesktopOfficialProviderName(name) if name == "" || access[name] { return } @@ -1403,7 +1432,11 @@ func (a *App) ensureProviderAccessForKey(apiKeyEnv string) error { if len(p.ModelList()) == 0 { continue } - addAccess(p.Name) + if isOfficialBuiltInProvider(p) { + addAccess(config.CanonicalDesktopOfficialProviderName(p.Name)) + } else { + addAccess(strings.TrimSpace(p.Name)) + } } if !changed && apiKeyEnv == "DEEPSEEK_API_KEY" { entries, _, err := officialProviderTemplate("deepseek", cfg.DeepSeekOfficialPricingLanguage()) diff --git a/internal/config/backfill_test.go b/internal/config/backfill_test.go index 4896da24b..c84a28710 100644 --- a/internal/config/backfill_test.go +++ b/internal/config/backfill_test.go @@ -135,6 +135,27 @@ func TestNormalizeDesktopOfficialProviderAccessCanonicalizesLegacyIDs(t *testing } } +func TestNormalizeDesktopOfficialProviderAccessKeepsCustomAlias(t *testing.T) { + c := &Config{ + Desktop: DesktopConfig{ProviderAccess: []string{"deepseek-flash"}}, + Providers: []ProviderEntry{{ + Name: "deepseek-flash", + Kind: "openai", + BaseURL: "https://proxy.example/v1", + Model: "deepseek-v4-flash", + }}, + } + + normalizeDesktopOfficialProviderAccess(c) + + if len(c.Desktop.ProviderAccess) != 1 || c.Desktop.ProviderAccess[0] != "deepseek-flash" { + t.Fatalf("provider_access = %+v, want custom alias preserved", c.Desktop.ProviderAccess) + } + if _, ok := c.Provider("deepseek"); ok { + t.Fatal("custom deepseek-flash proxy should not create canonical deepseek provider") + } +} + func TestNormalizeOfficialDeepSeekModelsRepairsCanonicalProvider(t *testing.T) { c := &Config{ DefaultModel: "deepseek-flash/deepseek-v4-flash", diff --git a/internal/config/config.go b/internal/config/config.go index 841a5caed..f1ef8798f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,7 +58,8 @@ type Config struct { LSP LSPConfig `toml:"lsp"` Bot BotConfig `toml:"bot"` - providerSources map[string]providerSourceScope + providerSources map[string]providerSourceScope + shadowedProjectProviders []ProviderEntry } type providerSourceScope string @@ -1473,11 +1474,12 @@ func LoadForRoot(root string) (*Config, error) { return nil, err } cfg.Plugins = plugins - if providers, providerSources, ok, err := mergeTOMLProviders(tomlSources); err != nil { + if providers, providerSources, shadowedProjectProviders, ok, err := mergeTOMLProviders(tomlSources); err != nil { return nil, err } else if ok { cfg.Providers = providers cfg.providerSources = providerSources + cfg.shadowedProjectProviders = shadowedProjectProviders } if access, ok, err := mergeTOMLProviderAccess(tomlSources); err != nil { return nil, err @@ -1645,8 +1647,9 @@ func mergeTOMLPlugins(paths []string) ([]PluginEntry, error) { // only fill names the global config does not define. Keep official legacy aliases // distinct here: they can carry different default models and effort capabilities, // and the later desktop normalization layer handles canonical Settings access. -func mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, bool, error) { +func mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, []ProviderEntry, bool, error) { var merged []ProviderEntry + var shadowedProject []ProviderEntry index := map[string]int{} sources := map[string]providerSourceScope{} saw := false @@ -1656,7 +1659,7 @@ func mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSou } var f Config if _, err := toml.DecodeFile(path, &f); err != nil { - return nil, nil, false, fmt.Errorf("config %s: %w", path, err) + return nil, nil, nil, false, fmt.Errorf("config %s: %w", path, err) } if len(f.Providers) == 0 { continue @@ -1668,8 +1671,11 @@ func mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSou key := providerMergeKey(p) if i, ok := index[key]; ok { if sources[key] == providerSourceProject && source == providerSourceUser { + shadowedProject = append(shadowedProject, merged[i]) merged[i] = p sources[key] = source + } else if sources[key] == providerSourceUser && source == providerSourceProject { + shadowedProject = append(shadowedProject, p) } continue } else { @@ -1679,7 +1685,7 @@ func mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSou } } } - return merged, sources, saw, nil + return merged, sources, shadowedProject, saw, nil } func providerSourceForPath(path string) providerSourceScope { @@ -1731,7 +1737,7 @@ func mergeTOMLProviderAccess(paths []string) ([]string, bool, error) { // of resetting to defaults. .env is loaded so api_key_env resolution works while // the wizard decides which keys are still missing. func LoadForEdit(path string) *Config { - cfg, err := loadForEditStrict(path) + cfg, err := loadForEditStrict(path, true) if err == nil { return cfg } @@ -1742,8 +1748,21 @@ func LoadForEdit(path string) *Config { return cfg } -func loadForEditStrict(path string) (*Config, error) { - loadDotEnvForEditPath(path) +func LoadForEditWithoutCredentials(path string) *Config { + cfg, err := loadForEditStrict(path, false) + if err == nil { + return cfg + } + slog.Warn("config: load for edit failed, using defaults", "path", path, "err", err) + cfg = Default() + normalizeConfigForEdit(cfg) + return cfg +} + +func loadForEditStrict(path string, loadCredentials bool) (*Config, error) { + if loadCredentials { + loadDotEnvForEditPath(path) + } cfg := Default() if _, err := os.Stat(path); err == nil { if err := migrateLegacyMCPTiersFile(path); err != nil { @@ -1956,7 +1975,7 @@ func normalizeDesktopOfficialProviderAccess(c *Config) { if strings.TrimSpace(name) == "mimo-flash" { includeMimoFlash = true } - name = canonicalDesktopOfficialProviderName(name) + name = desktopProviderAccessNameForConfig(c, name) if name == "" || seen[name] { continue } @@ -1987,7 +2006,7 @@ func NormalizeLegacyDesktopProviderAccess(c *Config) { seen := desktopProviderAccessMap(nil) var access []string add := func(name string) { - name = canonicalDesktopOfficialProviderName(name) + name = desktopProviderAccessNameForConfig(c, name) if name == "" || seen[name] { return } @@ -2035,6 +2054,40 @@ func canonicalDesktopOfficialProviderName(name string) string { } } +func desktopProviderAccessNameForConfig(c *Config, name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + canonical := canonicalDesktopOfficialProviderName(name) + if canonical == name { + return name + } + if c == nil { + return canonical + } + if p, ok := c.Provider(name); ok && !providerEntryMatchesCanonicalOfficialAccess(p, canonical) { + return name + } + return canonical +} + +func providerEntryMatchesCanonicalOfficialAccess(p *ProviderEntry, canonical string) bool { + if p == nil { + return false + } + switch canonical { + case "deepseek": + return officialProviderKind(p) == "deepseek" + case "mimo-api": + return isOfficialMimoAPIProvider(p) + case "mimo-token-plan": + return isOfficialMimoTokenPlanProvider(p) + default: + return false + } +} + // CanonicalDesktopOfficialProviderName returns the Settings Center provider ID // for built-in official provider aliases. func CanonicalDesktopOfficialProviderName(name string) string { @@ -2044,7 +2097,7 @@ func CanonicalDesktopOfficialProviderName(name string) string { func desktopProviderAccessMap(names []string) map[string]bool { out := map[string]bool{} for _, name := range names { - name = canonicalDesktopOfficialProviderName(name) + name = strings.TrimSpace(name) if name != "" { out[name] = true } diff --git a/internal/config/credentials.go b/internal/config/credentials.go index 65bdaae77..57cde2b5c 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -96,19 +96,13 @@ func credentialEnvNamesForRoot(root string) []string { tomlSources = append(tomlSources, uc) } tomlSources = append(tomlSources, projectTOML) - if providers, _, ok, err := mergeTOMLProviders(tomlSources); err == nil && ok { + if providers, _, _, ok, err := mergeTOMLProviders(tomlSources); err == nil && ok { cfg.Providers = providers } return credentialEnvNamesFromConfig(cfg) } -func credentialEnvNamesForConfigPath(path string) []string { - cfg := Default() - _ = mergeFile(cfg, path) - return credentialEnvNamesFromConfig(cfg) -} - func credentialEnvNamesFromConfig(cfg *Config) []string { seen := map[string]bool{} var out []string @@ -135,21 +129,14 @@ func credentialEnvNamesFromConfig(cfg *Config) []string { } func loadCredentialStoreForRoot(root string) { - loadCredentialStoreForNames(credentialEnvNamesForRoot(root), false) -} - -func loadGlobalCredentialStore() { - loadCredentialStoreForNames(credentialEnvNamesForConfigPath(userConfigLoadPath()), true) -} - -func loadCredentialStoreForNames(names []string, overrideLocalDotEnv bool) { + names := credentialEnvNamesForRoot(root) if len(names) == 0 { return } mode := credentialsStoreMode() if mode == CredentialsStoreAuto || mode == CredentialsStoreKeyring { for _, name := range names { - if !credentialStoreShouldLoad(name, overrideLocalDotEnv) { + if _, exists := os.LookupEnv(name); exists { recordExistingCredentialSource(name) continue } @@ -162,55 +149,10 @@ func loadCredentialStoreForNames(names []string, overrideLocalDotEnv bool) { } if mode == CredentialsStoreAuto || mode == CredentialsStoreFile { if p := UserCredentialsPath(); p != "" { - if overrideLocalDotEnv { - loadCredentialFileForNames(p, names, true, CredentialSource{Kind: CredentialSourceCredentials, Path: p, Label: "Reasonix credentials"}) - } else { - loadDotEnvFileAs(p, CredentialSource{Kind: CredentialSourceCredentials, Path: p, Label: "Reasonix credentials"}) - } + loadDotEnvFileAs(p, CredentialSource{Kind: CredentialSourceCredentials, Path: p, Label: "Reasonix credentials"}) } for _, p := range legacyCredentialsPaths() { - if overrideLocalDotEnv { - loadCredentialFileForNames(p, names, true, CredentialSource{Kind: CredentialSourceLegacy, Path: p, Label: "legacy Reasonix credentials"}) - } else { - loadDotEnvFileAs(p, CredentialSource{Kind: CredentialSourceLegacy, Path: p, Label: "legacy Reasonix credentials"}) - } - } - } -} - -func credentialStoreShouldLoad(name string, overrideLocalDotEnv bool) bool { - value, exists := os.LookupEnv(name) - if !exists { - return true - } - if !overrideLocalDotEnv { - return false - } - source, ok := trackedCredential(name, value) - if !ok { - return false - } - switch source.Kind { - case CredentialSourceProjectEnv, CredentialSourceHomeEnv, CredentialSourceLegacy: - return true - default: - return false - } -} - -func loadCredentialFileForNames(path string, names []string, overrideLocalDotEnv bool, source CredentialSource) { - for _, name := range names { - if !credentialStoreShouldLoad(name, overrideLocalDotEnv) { - recordExistingCredentialSource(name) - continue - } - value, ok := envFileValue(path, name) - if !ok || value == "" { - continue - } - if err := os.Setenv(name, value); err == nil { - source.Path = path - recordCredentialSource(name, value, source) + loadDotEnvFileAs(p, CredentialSource{Kind: CredentialSourceLegacy, Path: p, Label: "legacy Reasonix credentials"}) } } } @@ -445,6 +387,73 @@ func ResolveCredentialForRoot(root, key string) CredentialResolution { return res } +func ResolveCredentialForRootGlobalFirst(root, key string) CredentialResolution { + key = strings.TrimSpace(key) + res := CredentialResolution{Name: key} + if key == "" { + return res + } + if value, source, ok := storedCredentialValue(key); ok { + res.Set = true + res.Value = value + res.Source = source + res.Source.Label = credentialSourceLabel(res.Source) + res.Shadowed = shadowedCredentialSources(root, key, value, res.Source) + return res + } + for _, source := range credentialSourceCandidates(root) { + switch source.Kind { + case CredentialSourceProjectEnv, CredentialSourceHomeEnv, CredentialSourceLegacy: + default: + continue + } + if value, ok := envFileValue(source.Path, key); ok && value != "" { + res.Set = true + res.Value = value + source.Label = credentialSourceLabel(source) + res.Source = source + res.Shadowed = shadowedCredentialSources(root, key, value, source) + return res + } + } + value := os.Getenv(key) + if value == "" { + return res + } + res.Set = true + res.Value = value + if source, ok := trackedCredential(key, value); ok { + res.Source = source + } else { + res.Source = CredentialSource{Kind: CredentialSourceEnvironment} + } + res.Source.Label = credentialSourceLabel(res.Source) + res.Shadowed = shadowedCredentialSources(root, key, value, res.Source) + return res +} + +func storedCredentialValue(key string) (string, CredentialSource, bool) { + mode := credentialsStoreMode() + if mode == CredentialsStoreAuto || mode == CredentialsStoreKeyring { + if value, err := keyring.Get(credentialsKeyringService, key); err == nil && value != "" { + return value, CredentialSource{Kind: CredentialSourceCredentials, Label: "system credential store"}, true + } + } + if mode == CredentialsStoreAuto || mode == CredentialsStoreFile { + if p := UserCredentialsPath(); p != "" { + if value, ok := envFileValue(p, key); ok && value != "" { + return value, CredentialSource{Kind: CredentialSourceCredentials, Path: p, Label: "Reasonix credentials"}, true + } + } + for _, p := range legacyCredentialsPaths() { + if value, ok := envFileValue(p, key); ok && value != "" { + return value, CredentialSource{Kind: CredentialSourceLegacy, Path: p, Label: "legacy Reasonix credentials"}, true + } + } + } + return "", CredentialSource{}, false +} + func inferCredentialSource(root, key, value string) (CredentialSource, bool) { for _, candidate := range credentialSourceCandidates(root) { if v, ok := envFileValue(candidate.Path, key); ok && v == value { diff --git a/internal/config/dotenv.go b/internal/config/dotenv.go index 78cfef74b..975c721d8 100644 --- a/internal/config/dotenv.go +++ b/internal/config/dotenv.go @@ -9,44 +9,30 @@ import ( // loadDotEnv loads KEY=value files into the process environment without // overriding variables that are already set (first file to set a key wins). -// Order: a project ./.env (read-only back-compat, so a manual project override -// takes precedence), then the configured Reasonix credential store (where -// `reasonix setup` writes keys, so they resolve from any directory without ever -// touching a project's own .env), then ~/.env as a legacy fallback. Existing +// Order: configured Reasonix credential store (where `reasonix setup` writes +// keys, so they resolve from any directory), then a project ./.env as a +// read-only back-compat fallback, then ~/.env as a legacy fallback. Existing // environment variables always win over all credential sources. func loadDotEnv() { loadDotEnvForRoot(".") } -// loadDotEnvForRoot loads a root's .env file (if present) before the home .env -// fallback. When root is "." it behaves like loadDotEnv(). +// loadDotEnvForRoot loads Reasonix global credentials before a root's .env file +// (if present) and the home .env fallback. When root is "." it behaves like +// loadDotEnv(). func loadDotEnvForRoot(root string) { dotEnvPath := ".env" if root != "" && root != "." { dotEnvPath = filepath.Join(root, ".env") } - loadDotEnvFileAs(dotEnvPath, CredentialSource{Kind: CredentialSourceProjectEnv, Path: dotEnvPath}) loadCredentialStoreForRoot(root) + loadDotEnvFileAs(dotEnvPath, CredentialSource{Kind: CredentialSourceProjectEnv, Path: dotEnvPath}) if home, err := os.UserHomeDir(); err == nil { homeEnv := filepath.Join(home, ".env") loadDotEnvFileAs(homeEnv, CredentialSource{Kind: CredentialSourceHomeEnv, Path: homeEnv}) } } -// LoadCredentialsForRoot loads the credential environment for a workspace root -// without decoding or mutating the full runtime config. Existing environment -// variables keep the same first-wins precedence as LoadForRoot. -func LoadCredentialsForRoot(root string) { - loadDotEnvForRoot(root) -} - -// LoadGlobalCredentials loads credentials referenced by the user-global config -// from the system credential store or ~/.reasonix/credentials, without consulting -// a project .env first. -func LoadGlobalCredentials() { - loadGlobalCredentialStore() -} - func legacyCredentialsPaths() []string { current := UserCredentialsPath() seen := map[string]bool{} diff --git a/internal/config/dotenv_test.go b/internal/config/dotenv_test.go index 228c8cbfa..5e0acde3f 100644 --- a/internal/config/dotenv_test.go +++ b/internal/config/dotenv_test.go @@ -7,9 +7,8 @@ import ( ) // TestLoadDotEnvFallsBackToHome proves the unified-key behaviour: the working -// directory's .env wins, but a key only present in ~/.env is still picked up — -// so a key set once in the home .env (the desktop app writes there) reaches the -// CLI run from any project directory. Existing env vars beat both files. +// directory's .env is still read as a fallback, and a key only present in ~/.env +// is picked up too. Existing env vars beat file-backed credential sources. func TestLoadDotEnvFallsBackToHome(t *testing.T) { cwd := t.TempDir() home := t.TempDir() @@ -49,7 +48,7 @@ func TestLoadDotEnvFallsBackToHome(t *testing.T) { // TestLoadDotEnvReadsGlobalCredentials proves `reasonix setup`'s target — the // reasonix-owned credentials file under Reasonix home — is loaded from any -// working directory, while a project ./.env still wins on a shared key. +// working directory and wins over a project ./.env on a shared key. func TestLoadDotEnvReadsGlobalCredentials(t *testing.T) { cwd := t.TempDir() cfgHome := t.TempDir() @@ -68,7 +67,7 @@ func TestLoadDotEnvReadsGlobalCredentials(t *testing.T) { if err := os.MkdirAll(filepath.Dir(cred), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(cred, []byte("KEY_GLOBAL=from_credentials\nKEY_SHARED=global_loses\n"), 0o600); err != nil { + if err := os.WriteFile(cred, []byte("KEY_GLOBAL=from_credentials\nKEY_SHARED=global_wins\n"), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(cwd, ".env"), []byte("KEY_SHARED=cwd_wins\n"), 0o600); err != nil { @@ -85,12 +84,12 @@ func TestLoadDotEnvReadsGlobalCredentials(t *testing.T) { if got := os.Getenv("KEY_GLOBAL"); got != "from_credentials" { t.Errorf("global credentials not loaded: KEY_GLOBAL=%q want from_credentials", got) } - if got := os.Getenv("KEY_SHARED"); got != "cwd_wins" { - t.Errorf("project .env should win over global credentials: KEY_SHARED=%q want cwd_wins", got) + if got := os.Getenv("KEY_SHARED"); got != "global_wins" { + t.Errorf("global credentials should win over project .env: KEY_SHARED=%q want global_wins", got) } } -func TestLoadGlobalCredentialsOverridesProjectDotEnvSource(t *testing.T) { +func TestResolveCredentialGlobalFirstDoesNotMutateProjectEnv(t *testing.T) { project := t.TempDir() home := t.TempDir() @@ -101,17 +100,7 @@ func TestLoadGlobalCredentialsOverridesProjectDotEnvSource(t *testing.T) { t.Setenv("AppData", filepath.Join(home, "AppData")) key := "KEY_GLOBAL_PRIORITY" - if err := os.MkdirAll(filepath.Dir(UserConfigPath()), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(UserConfigPath(), []byte(` -[[providers]] -name = "global-priority" -kind = "openai" -base_url = "https://example.invalid/v1" -model = "m" -api_key_env = "KEY_GLOBAL_PRIORITY" -`), 0o644); err != nil { + if err := os.MkdirAll(filepath.Dir(UserCredentialsPath()), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(UserCredentialsPath(), []byte(key+"=from_credentials\n"), 0o600); err != nil { @@ -122,19 +111,21 @@ api_key_env = "KEY_GLOBAL_PRIORITY" } t.Setenv(key, "") os.Unsetenv(key) + t.Setenv(key, "from_project") - loadDotEnvForRoot(project) if got := os.Getenv(key); got != "from_project" { - t.Fatalf("precondition: project .env should load first, got %q", got) + t.Fatalf("precondition: existing env should be project value, got %q", got) } - LoadGlobalCredentials() - got := ResolveCredentialForRoot(project, key) + got := ResolveCredentialForRootGlobalFirst(project, key) if got.Value != "from_credentials" || got.Source.Kind != CredentialSourceCredentials { - t.Fatalf("credential = %+v, want global credentials to override project .env for settings reads", got) + t.Fatalf("credential = %+v, want global credentials for settings display", got) + } + if env := os.Getenv(key); env != "from_project" { + t.Fatalf("global-first resolution mutated process env: %q", env) } } -func TestResolveCredentialSourceShowsProjectEnvShadowingCredentials(t *testing.T) { +func TestResolveCredentialSourceShowsCredentialsShadowingProjectEnv(t *testing.T) { cwd := t.TempDir() cfgHome := t.TempDir() @@ -165,21 +156,21 @@ func TestResolveCredentialSourceShowsProjectEnvShadowingCredentials(t *testing.T loadDotEnv() got := ResolveCredentialForRoot(cwd, key) - if !got.Set || got.Source.Kind != CredentialSourceProjectEnv { - t.Fatalf("source = %+v set=%v, want project .env", got.Source, got.Set) + if !got.Set || got.Source.Kind != CredentialSourceCredentials { + t.Fatalf("source = %+v set=%v, want Reasonix credentials", got.Source, got.Set) } - foundCredentialsShadow := false + foundProjectShadow := false for _, source := range got.Shadowed { - if source.Kind == CredentialSourceCredentials { - foundCredentialsShadow = true + if source.Kind == CredentialSourceProjectEnv { + foundProjectShadow = true } } - if !foundCredentialsShadow { - t.Fatalf("shadowed = %+v, want credentials shadowed by project .env", got.Shadowed) + if !foundProjectShadow { + t.Fatalf("shadowed = %+v, want project .env shadowed by credentials", got.Shadowed) } } -func TestResolveCredentialSourceShowsEmptyProjectEnvShadowingCredentials(t *testing.T) { +func TestResolveCredentialSourceShowsCredentialsShadowingEmptyProjectEnv(t *testing.T) { cwd := t.TempDir() cfgHome := t.TempDir() @@ -219,7 +210,7 @@ func TestResolveCredentialSourceShowsEmptyProjectEnvShadowingCredentials(t *test } } if !foundProjectShadow { - t.Fatalf("shadowed = %+v, want empty project .env shadowing credentials", got.Shadowed) + t.Fatalf("shadowed = %+v, want empty project .env shadowed by credentials", got.Shadowed) } } diff --git a/internal/config/edit_test.go b/internal/config/edit_test.go index 347d9c36a..e06c4bed8 100644 --- a/internal/config/edit_test.go +++ b/internal/config/edit_test.go @@ -1056,6 +1056,55 @@ api_key_env = "PROJECT_ONLY_KEY" } } +func TestSaveForRootPreservesShadowedProjectProvider(t *testing.T) { + isolateUserConfigHome(t) + root := t.TempDir() + userPath := UserConfigPath() + if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(userPath, []byte(` +[[providers]] +name = "shared" +kind = "openai" +base_url = "https://global.example/v1" +model = "global-model" +api_key_env = "GLOBAL_SHARED_KEY" +`), 0o644); err != nil { + t.Fatal(err) + } + projectPath := filepath.Join(root, "reasonix.toml") + if err := os.WriteFile(projectPath, []byte(` +[[providers]] +name = "shared" +kind = "openai" +base_url = "https://project.example/v1" +model = "project-model" +api_key_env = "PROJECT_SHARED_KEY" +`), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadForRoot(root) + if err != nil { + t.Fatalf("LoadForRoot: %v", err) + } + if err := cfg.SaveForRoot(root); err != nil { + t.Fatalf("SaveForRoot: %v", err) + } + var saved Config + if _, err := toml.DecodeFile(projectPath, &saved); err != nil { + t.Fatalf("saved project config does not parse: %v", err) + } + shared, ok := saved.Provider("shared") + if !ok { + t.Fatalf("saved project provider missing: %+v", saved.Providers) + } + if shared.BaseURL != "https://project.example/v1" || shared.APIKeyEnv != "PROJECT_SHARED_KEY" { + t.Fatalf("saved provider = %+v, want original project provider", shared) + } +} + func TestSaveForRootDoesNotWriteUserProvidersIntoProjectConfig(t *testing.T) { isolateUserConfigHome(t) root := t.TempDir() diff --git a/internal/config/migrate.go b/internal/config/migrate.go index 36721406a..7bf9665a4 100644 --- a/internal/config/migrate.go +++ b/internal/config/migrate.go @@ -199,7 +199,7 @@ func migrateMCPToUserConfig(projectRoots []string) (*MCPGlobalMigrationResult, e if dest == "" { return nil, nil } - userCfg, err := loadForEditStrict(dest) + userCfg, err := loadForEditStrict(dest, true) if err != nil { return nil, err } diff --git a/internal/config/render.go b/internal/config/render.go index adabb795a..b506b0686 100644 --- a/internal/config/render.go +++ b/internal/config/render.go @@ -583,13 +583,14 @@ func projectScopedConfigForRender(c *Config) *Config { return c } cp := *c - cp.Providers = make([]ProviderEntry, 0, len(c.Providers)) + cp.Providers = make([]ProviderEntry, 0, len(c.Providers)+len(c.shadowedProjectProviders)) for _, p := range c.Providers { if c.providerSources[providerMergeKey(p)] == providerSourceUser { continue } cp.Providers = append(cp.Providers, p) } + cp.Providers = append(cp.Providers, c.shadowedProjectProviders...) return &cp }