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) }