From e7502c57398a4edf505108e4c4b62888ddba3962 Mon Sep 17 00:00:00 2001 From: Karim Fanous Date: Sun, 5 Apr 2026 16:20:51 -0700 Subject: [PATCH] feat(configstore): support local .leash.toml override file Allow users to place a .leash.toml file in their project directory to layer secrets and personal configuration on top of the global XDG config. This enables teams to commit shared config while keeping credentials gitignored locally. Closes #55 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/configstore/config.go | 77 ++++++++ internal/configstore/loadsave.go | 26 +++ internal/configstore/loadsave_overlay_test.go | 178 ++++++++++++++++++ internal/configstore/merge_test.go | 143 ++++++++++++++ internal/configstore/path.go | 11 ++ internal/runner/mount_state.go | 2 +- internal/runner/runner.go | 2 +- 7 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 internal/configstore/loadsave_overlay_test.go create mode 100644 internal/configstore/merge_test.go diff --git a/internal/configstore/config.go b/internal/configstore/config.go index a69babb..8595554 100644 --- a/internal/configstore/config.go +++ b/internal/configstore/config.go @@ -261,6 +261,83 @@ func boolPtr(v bool) *bool { return &b } +// Merge returns a new Config that combines base with overlay. Overlay values +// take precedence: non-empty scalars replace base values, and maps are merged +// with overlay keys overriding base keys. +func Merge(base, overlay Config) Config { + out := base.Clone() + + if strings.TrimSpace(overlay.TargetImage) != "" { + out.TargetImage = overlay.TargetImage + } + + for cmd, value := range overlay.CommandVolumes { + if value != nil { + out.CommandVolumes[cmd] = boolPtr(*value) + } + } + + for host, spec := range overlay.CustomVolumes { + out.CustomVolumes[host] = spec + } + + for key, value := range overlay.EnvVars { + out.EnvVars[key] = value + } + + for key, image := range overlay.ProjectTargetImages { + out.ProjectTargetImages[key] = image + } + + for projectKey, settings := range overlay.ProjectCommandVolumes { + existing := out.ProjectCommandVolumes[projectKey] + if existing == nil { + existing = make(map[string]*bool) + } + for cmd, value := range settings { + if value != nil { + existing[cmd] = boolPtr(*value) + } + } + out.ProjectCommandVolumes[projectKey] = existing + } + + for projectKey, specs := range overlay.ProjectCustomVolumes { + existing := out.ProjectCustomVolumes[projectKey] + if existing == nil { + existing = make(map[string]string) + } + for key, value := range specs { + existing[key] = value + } + out.ProjectCustomVolumes[projectKey] = existing + } + + for projectKey, disables := range overlay.ProjectVolumeDisables { + existing := out.ProjectVolumeDisables[projectKey] + if existing == nil { + existing = make(map[string]bool) + } + for key, value := range disables { + existing[key] = value + } + out.ProjectVolumeDisables[projectKey] = existing + } + + for projectKey, envs := range overlay.ProjectEnvVars { + existing := out.ProjectEnvVars[projectKey] + if existing == nil { + existing = make(map[string]string) + } + for key, value := range envs { + existing[key] = value + } + out.ProjectEnvVars[projectKey] = existing + } + + return out +} + // SetGlobalTargetImage records the default container image for leash-managed sessions. func (c *Config) SetGlobalTargetImage(image string) { c.TargetImage = strings.TrimSpace(image) diff --git a/internal/configstore/loadsave.go b/internal/configstore/loadsave.go index 21a25e9..bf237ba 100644 --- a/internal/configstore/loadsave.go +++ b/internal/configstore/loadsave.go @@ -222,6 +222,32 @@ func decodeConfig(data []byte, path string, cfg *Config) error { return nil } +// LoadWithOverlay loads the global XDG configuration and then, if a +// .leash.toml file exists in dir, merges it on top. The local file values +// take precedence over the global config. +func LoadWithOverlay(dir string) (Config, error) { + base, err := Load() + if err != nil { + return base, err + } + if strings.TrimSpace(dir) == "" { + return base, nil + } + localPath := GetLocalConfigPath(dir) + data, err := os.ReadFile(localPath) + if errors.Is(err, os.ErrNotExist) { + return base, nil + } + if err != nil { + return base, fmt.Errorf("read local config %s: %w", localPath, err) + } + overlay := New() + if err := decodeConfig(data, localPath, &overlay); err != nil { + return base, err + } + return Merge(base, overlay), nil +} + // Save atomically writes the configuration to disk. func Save(cfg Config) error { cfg.ensureInitialized() diff --git a/internal/configstore/loadsave_overlay_test.go b/internal/configstore/loadsave_overlay_test.go new file mode 100644 index 0000000..5725e76 --- /dev/null +++ b/internal/configstore/loadsave_overlay_test.go @@ -0,0 +1,178 @@ +package configstore + +import ( + "os" + "path/filepath" + "testing" +) + +// These tests override XDG_CONFIG_HOME and HOME; run serially. + +func TestLoadWithOverlayNoLocalFile(t *testing.T) { + testSetEnv(t, "LEASH_HOME", "") + base := t.TempDir() + testSetEnv(t, "XDG_CONFIG_HOME", base) + setHome(t, filepath.Join(base, "home")) + + // Write a global config with a target image. + cfgDir := filepath.Join(base, "leash") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + globalTOML := `[leash] +target_image = "global-image" + +[leash.envvars] +GH_CONFIG_DIR = "/root/.config/gh" +` + if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte(globalTOML), 0o600); err != nil { + t.Fatal(err) + } + + // Load with a directory that has no .leash.toml. + projectDir := t.TempDir() + cfg, err := LoadWithOverlay(projectDir) + if err != nil { + t.Fatalf("LoadWithOverlay: %v", err) + } + + if cfg.TargetImage != "global-image" { + t.Fatalf("TargetImage = %q, want %q", cfg.TargetImage, "global-image") + } + if cfg.EnvVars["GH_CONFIG_DIR"] != "/root/.config/gh" { + t.Fatalf("GH_CONFIG_DIR = %q, want %q", cfg.EnvVars["GH_CONFIG_DIR"], "/root/.config/gh") + } +} + +func TestLoadWithOverlayMergesLocalFile(t *testing.T) { + testSetEnv(t, "LEASH_HOME", "") + base := t.TempDir() + testSetEnv(t, "XDG_CONFIG_HOME", base) + setHome(t, filepath.Join(base, "home")) + + // Write global config. + cfgDir := filepath.Join(base, "leash") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + globalTOML := `[leash] +target_image = "global-image" + +[leash.envvars] +GH_CONFIG_DIR = "/root/.config/gh" +SHARED_KEY = "global" +` + if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte(globalTOML), 0o600); err != nil { + t.Fatal(err) + } + + // Write local override. + projectDir := t.TempDir() + localTOML := `[leash.envvars] +ATLASSIAN_USER = "secret-user" +SHARED_KEY = "local-override" +` + if err := os.WriteFile(filepath.Join(projectDir, LocalConfigFileName), []byte(localTOML), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := LoadWithOverlay(projectDir) + if err != nil { + t.Fatalf("LoadWithOverlay: %v", err) + } + + // Global values preserved. + if cfg.TargetImage != "global-image" { + t.Fatalf("TargetImage = %q, want %q", cfg.TargetImage, "global-image") + } + if cfg.EnvVars["GH_CONFIG_DIR"] != "/root/.config/gh" { + t.Fatalf("GH_CONFIG_DIR = %q, want %q", cfg.EnvVars["GH_CONFIG_DIR"], "/root/.config/gh") + } + + // Local values added. + if cfg.EnvVars["ATLASSIAN_USER"] != "secret-user" { + t.Fatalf("ATLASSIAN_USER = %q, want %q", cfg.EnvVars["ATLASSIAN_USER"], "secret-user") + } + + // Local overrides global. + if cfg.EnvVars["SHARED_KEY"] != "local-override" { + t.Fatalf("SHARED_KEY = %q, want %q", cfg.EnvVars["SHARED_KEY"], "local-override") + } +} + +func TestLoadWithOverlayLocalOverridesTargetImage(t *testing.T) { + testSetEnv(t, "LEASH_HOME", "") + base := t.TempDir() + testSetEnv(t, "XDG_CONFIG_HOME", base) + setHome(t, filepath.Join(base, "home")) + + cfgDir := filepath.Join(base, "leash") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + globalTOML := `[leash] +target_image = "global-image" +` + if err := os.WriteFile(filepath.Join(cfgDir, configFileName), []byte(globalTOML), 0o600); err != nil { + t.Fatal(err) + } + + projectDir := t.TempDir() + localTOML := `[leash] +target_image = "local-image" +` + if err := os.WriteFile(filepath.Join(projectDir, LocalConfigFileName), []byte(localTOML), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := LoadWithOverlay(projectDir) + if err != nil { + t.Fatalf("LoadWithOverlay: %v", err) + } + + if cfg.TargetImage != "local-image" { + t.Fatalf("TargetImage = %q, want %q", cfg.TargetImage, "local-image") + } +} + +func TestLoadWithOverlayEmptyDirSkipsLocal(t *testing.T) { + testSetEnv(t, "LEASH_HOME", "") + base := t.TempDir() + testSetEnv(t, "XDG_CONFIG_HOME", base) + setHome(t, filepath.Join(base, "home")) + + cfg, err := LoadWithOverlay("") + if err != nil { + t.Fatalf("LoadWithOverlay: %v", err) + } + // Should just return defaults with no error. + if cfg.TargetImage != "" { + t.Fatalf("expected empty TargetImage, got %q", cfg.TargetImage) + } +} + +func TestLoadWithOverlayBadLocalTOMLReturnsError(t *testing.T) { + testSetEnv(t, "LEASH_HOME", "") + base := t.TempDir() + testSetEnv(t, "XDG_CONFIG_HOME", base) + setHome(t, filepath.Join(base, "home")) + + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, LocalConfigFileName), []byte("not valid {{{ toml"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := LoadWithOverlay(projectDir) + if err == nil { + t.Fatal("expected error for invalid local TOML") + } +} + +func TestGetLocalConfigPath(t *testing.T) { + t.Parallel() + got := GetLocalConfigPath("/some/project") + want := filepath.Join("/some/project", LocalConfigFileName) + if got != want { + t.Fatalf("GetLocalConfigPath = %q, want %q", got, want) + } +} diff --git a/internal/configstore/merge_test.go b/internal/configstore/merge_test.go new file mode 100644 index 0000000..6e27b1b --- /dev/null +++ b/internal/configstore/merge_test.go @@ -0,0 +1,143 @@ +package configstore + +import ( + "testing" +) + +func TestMergeEmptyOverlayReturnsBase(t *testing.T) { + t.Parallel() + base := New() + base.TargetImage = "base-image" + base.EnvVars["KEY"] = "base-val" + base.CommandVolumes["codex"] = boolPtr(true) + base.CustomVolumes["~/data"] = "/workspace/data:ro" + + result := Merge(base, New()) + + if result.TargetImage != "base-image" { + t.Fatalf("TargetImage = %q, want %q", result.TargetImage, "base-image") + } + if result.EnvVars["KEY"] != "base-val" { + t.Fatalf("EnvVars[KEY] = %q, want %q", result.EnvVars["KEY"], "base-val") + } + if result.CommandVolumes["codex"] == nil || !*result.CommandVolumes["codex"] { + t.Fatal("expected codex volume to be true") + } + if result.CustomVolumes["~/data"] != "/workspace/data:ro" { + t.Fatal("expected custom volume preserved") + } +} + +func TestMergeOverlayOverridesScalars(t *testing.T) { + t.Parallel() + base := New() + base.TargetImage = "base-image" + + overlay := New() + overlay.TargetImage = "overlay-image" + + result := Merge(base, overlay) + + if result.TargetImage != "overlay-image" { + t.Fatalf("TargetImage = %q, want %q", result.TargetImage, "overlay-image") + } +} + +func TestMergeEnvVarsMerged(t *testing.T) { + t.Parallel() + base := New() + base.EnvVars["SHARED"] = "base" + base.EnvVars["BASE_ONLY"] = "from-base" + + overlay := New() + overlay.EnvVars["SHARED"] = "overlay" + overlay.EnvVars["OVERLAY_ONLY"] = "from-overlay" + + result := Merge(base, overlay) + + if result.EnvVars["SHARED"] != "overlay" { + t.Fatalf("SHARED = %q, want %q", result.EnvVars["SHARED"], "overlay") + } + if result.EnvVars["BASE_ONLY"] != "from-base" { + t.Fatalf("BASE_ONLY = %q, want %q", result.EnvVars["BASE_ONLY"], "from-base") + } + if result.EnvVars["OVERLAY_ONLY"] != "from-overlay" { + t.Fatalf("OVERLAY_ONLY = %q, want %q", result.EnvVars["OVERLAY_ONLY"], "from-overlay") + } +} + +func TestMergeCommandVolumesOverridden(t *testing.T) { + t.Parallel() + base := New() + base.CommandVolumes["codex"] = boolPtr(true) + base.CommandVolumes["claude"] = boolPtr(true) + + overlay := New() + overlay.CommandVolumes["codex"] = boolPtr(false) + + result := Merge(base, overlay) + + if result.CommandVolumes["codex"] == nil || *result.CommandVolumes["codex"] { + t.Fatal("expected codex volume to be false from overlay") + } + if result.CommandVolumes["claude"] == nil || !*result.CommandVolumes["claude"] { + t.Fatal("expected claude volume preserved from base") + } +} + +func TestMergeProjectEnvVarsMerged(t *testing.T) { + t.Parallel() + base := New() + base.ProjectEnvVars["/proj"] = map[string]string{ + "BASE_KEY": "base-val", + "SHARED": "base", + } + + overlay := New() + overlay.ProjectEnvVars["/proj"] = map[string]string{ + "SHARED": "overlay", + "OVERLAY_KEY": "overlay-val", + } + + result := Merge(base, overlay) + + envs := result.ProjectEnvVars["/proj"] + if envs["BASE_KEY"] != "base-val" { + t.Fatalf("BASE_KEY = %q, want %q", envs["BASE_KEY"], "base-val") + } + if envs["SHARED"] != "overlay" { + t.Fatalf("SHARED = %q, want %q", envs["SHARED"], "overlay") + } + if envs["OVERLAY_KEY"] != "overlay-val" { + t.Fatalf("OVERLAY_KEY = %q, want %q", envs["OVERLAY_KEY"], "overlay-val") + } +} + +func TestMergeDoesNotMutateBase(t *testing.T) { + t.Parallel() + base := New() + base.EnvVars["KEY"] = "original" + + overlay := New() + overlay.EnvVars["KEY"] = "changed" + + _ = Merge(base, overlay) + + if base.EnvVars["KEY"] != "original" { + t.Fatalf("base was mutated: EnvVars[KEY] = %q", base.EnvVars["KEY"]) + } +} + +func TestMergeEmptyOverlayTargetImagePreservesBase(t *testing.T) { + t.Parallel() + base := New() + base.TargetImage = "keep-me" + + overlay := New() + // overlay.TargetImage is empty — should not override + + result := Merge(base, overlay) + if result.TargetImage != "keep-me" { + t.Fatalf("TargetImage = %q, want %q", result.TargetImage, "keep-me") + } +} diff --git a/internal/configstore/path.go b/internal/configstore/path.go index 0b0bdc2..4aaf028 100644 --- a/internal/configstore/path.go +++ b/internal/configstore/path.go @@ -9,6 +9,11 @@ import ( const configFileName = "config.toml" +// LocalConfigFileName is the name of the project-local override file that +// users can place in their working directory to layer additional configuration +// (e.g. secrets) on top of the global XDG config. +const LocalConfigFileName = ".leash.toml" + // GetConfigPath resolves the leash configuration directory and file path using // XDG rules with a fallback to ~/.config/leash/config.toml. func GetConfigPath() (string, string, error) { @@ -39,6 +44,12 @@ func GetConfigPath() (string, string, error) { return dir, filepath.Join(dir, configFileName), nil } +// GetLocalConfigPath returns the path to the project-local override file +// within the given directory. +func GetLocalConfigPath(dir string) string { + return filepath.Join(dir, LocalConfigFileName) +} + func buildConfigDir(base string) string { return filepath.Join(base, "leash") } diff --git a/internal/runner/mount_state.go b/internal/runner/mount_state.go index 1dafc2b..b8ac440 100644 --- a/internal/runner/mount_state.go +++ b/internal/runner/mount_state.go @@ -31,7 +31,7 @@ func (r *runner) initMountState(ctx context.Context, cwd string) error { } interactive := r.promptInteractive() - cfgData, err := configstore.Load() + cfgData, err := configstore.LoadWithOverlay(cwd) if err != nil { return fmt.Errorf("load leash config: %w", err) } diff --git a/internal/runner/runner.go b/internal/runner/runner.go index d8726e1..24c15f2 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -896,7 +896,7 @@ func loadConfig(callerDir string, opts options) (config, map[string]configstore. cfg.policyOverride = false } - cfgData, err := configstore.Load() + cfgData, err := configstore.LoadWithOverlay(callerDir) if err != nil { return config{}, nil, fmt.Errorf("load leash config: %w", err) }