diff --git a/desktop/app_test.go b/desktop/app_test.go index 43883e801..5c0f8720d 100644 --- a/desktop/app_test.go +++ b/desktop/app_test.go @@ -535,6 +535,102 @@ 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 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) @@ -866,6 +962,83 @@ 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 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 0c6dad599..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{}, @@ -576,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 @@ -1364,12 +1396,66 @@ 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) { + 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 + } + 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()) + 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/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 03e238fda..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 @@ -1640,12 +1642,14 @@ 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. -func mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSourceScope, bool, error) { +// 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, []ProviderEntry, bool, error) { var merged []ProviderEntry + var shadowedProject []ProviderEntry index := map[string]int{} sources := map[string]providerSourceScope{} saw := false @@ -1655,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 @@ -1666,15 +1670,22 @@ 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 { + shadowedProject = append(shadowedProject, merged[i]) + merged[i] = p + sources[key] = source + } else if sources[key] == providerSourceUser && source == providerSourceProject { + shadowedProject = append(shadowedProject, p) + } + continue } else { index[key] = len(merged) merged = append(merged, p) + sources[key] = source } - sources[key] = source } } - return merged, sources, saw, nil + return merged, sources, shadowedProject, saw, nil } func providerSourceForPath(path string) providerSourceScope { @@ -1726,19 +1737,32 @@ 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 + } + slog.Warn("config: load for edit failed, using defaults", "path", path, "err", err) + loadDotEnvForEditPath(path) + cfg = Default() + normalizeConfigForEdit(cfg) + return cfg +} + +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) - loadDotEnv() cfg = Default() normalizeConfigForEdit(cfg) return cfg } -func loadForEditStrict(path string) (*Config, error) { - loadDotEnv() +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 { @@ -1763,6 +1787,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 { @@ -1942,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 } @@ -1973,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 } @@ -2021,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 { @@ -2030,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 df75515f1..57cde2b5c 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -96,10 +96,14 @@ 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 credentialEnvNamesFromConfig(cfg *Config) []string { seen := map[string]bool{} var out []string add := func(name string) { @@ -383,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 f235edfe8..975c721d8 100644 --- a/internal/config/dotenv.go +++ b/internal/config/dotenv.go @@ -9,24 +9,24 @@ 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}) diff --git a/internal/config/dotenv_test.go b/internal/config/dotenv_test.go index d8f1d7fbf..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,48 @@ 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 TestResolveCredentialSourceShowsProjectEnvShadowingCredentials(t *testing.T) { +func TestResolveCredentialGlobalFirstDoesNotMutateProjectEnv(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(UserCredentialsPath()), 0o755); 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) + t.Setenv(key, "from_project") + + if got := os.Getenv(key); got != "from_project" { + t.Fatalf("precondition: existing env should be project value, got %q", got) + } + got := ResolveCredentialForRootGlobalFirst(project, key) + if got.Value != "from_credentials" || got.Source.Kind != CredentialSourceCredentials { + 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 TestResolveCredentialSourceShowsCredentialsShadowingProjectEnv(t *testing.T) { cwd := t.TempDir() cfgHome := t.TempDir() @@ -121,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() @@ -175,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 450c7f07a..e06c4bed8 100644 --- a/internal/config/edit_test.go +++ b/internal/config/edit_test.go @@ -1005,6 +1005,106 @@ 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 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/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) + } +} 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 }