diff --git a/internal/setup/agents.go b/internal/setup/agents.go index 308a7599..cd09506b 100644 --- a/internal/setup/agents.go +++ b/internal/setup/agents.go @@ -249,14 +249,14 @@ func vscodeUserDir() string { home, _ := userHome() switch runtimeGOOS { case "windows": - if appData := os.Getenv("APPDATA"); appData != "" { + if appData := os.Getenv("APPDATA"); appData != "" && filepath.IsAbs(appData) { return filepath.Join(appData, "Code", "User") } return filepath.Join(home, "AppData", "Roaming", "Code", "User") case "darwin": return filepath.Join(home, "Library", "Application Support", "Code", "User") default: - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" && filepath.IsAbs(xdg) { return filepath.Join(xdg, "Code", "User") } return filepath.Join(home, ".config", "Code", "User") @@ -278,7 +278,7 @@ func vscodePromptPath() string { func kilocodeConfigDir() string { home, _ := userHome() - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" && filepath.IsAbs(xdg) { return filepath.Join(xdg, "kilo") } return filepath.Join(home, ".config", "kilo") diff --git a/internal/setup/registry.go b/internal/setup/registry.go index df3d40bf..87e60703 100644 --- a/internal/setup/registry.go +++ b/internal/setup/registry.go @@ -197,9 +197,16 @@ func upsertMarkerBlock(path, begin, end, body string) error { text := strings.ReplaceAll(string(existing), "\r\n", "\n") start := strings.Index(text, begin) - stop := strings.Index(text, end) - if start != -1 && stop != -1 && stop > start { - stop += len(end) + // Search for the end marker only AFTER the begin marker, so a stray end + // marker in user content above the managed block can't defeat idempotency + // (which would otherwise append a second block). + stop := -1 + if start != -1 { + if rel := strings.Index(text[start:], end); rel != -1 { + stop = start + rel + len(end) + } + } + if start != -1 && stop != -1 { text = text[:start] + strings.TrimRight(block, "\n") + text[stop:] } else { text = strings.TrimRight(text, "\n") + "\n\n" + block diff --git a/internal/setup/registry_test.go b/internal/setup/registry_test.go index c2bf5779..8cbe16a2 100644 --- a/internal/setup/registry_test.go +++ b/internal/setup/registry_test.go @@ -263,6 +263,41 @@ func TestUpsertMarkerBlockPreservesUserContentAndReplaces(t *testing.T) { } } +// TestUpsertMarkerBlockStrayEndMarkerStaysIdempotent guards the anchored end +// search: a stray end marker in user content ABOVE the managed block must not +// defeat idempotency (an unanchored search would find it and append a duplicate). +func TestUpsertMarkerBlockStrayEndMarkerStaysIdempotent(t *testing.T) { + resetSetupSeams(t) + home := useTestHome(t) + path := filepath.Join(home, "notes.md") + seed := "# My notes\n\n" + engramMarkerEnd + "\n\nkeep me\n" + if err := os.WriteFile(path, []byte(seed), 0644); err != nil { + t.Fatalf("seed: %v", err) + } + + if err := upsertMarkerBlock(path, engramMarkerBegin, engramMarkerEnd, "BODY ONE"); err != nil { + t.Fatalf("first upsert: %v", err) + } + if err := upsertMarkerBlock(path, engramMarkerBegin, engramMarkerEnd, "BODY TWO"); err != nil { + t.Fatalf("second upsert: %v", err) + } + + raw, _ := os.ReadFile(path) + text := string(raw) + if !strings.Contains(text, "keep me") { + t.Errorf("user content not preserved: %q", text) + } + if strings.Contains(text, "BODY ONE") { + t.Errorf("stale managed block not replaced: %q", text) + } + if !strings.Contains(text, "BODY TWO") { + t.Errorf("new managed block missing: %q", text) + } + if n := strings.Count(text, engramMarkerBegin); n != 1 { + t.Errorf("expected exactly 1 managed block despite stray end marker, got %d: %q", n, text) + } +} + func TestInstallDeclarativeAgentMCPWriteError(t *testing.T) { stubRegistryEnv(t) writeFileFn = func(string, []byte, os.FileMode) error { return errors.New("disk full") } @@ -342,3 +377,30 @@ func TestVSCodeUserDirPerPlatform(t *testing.T) { } } } + +// TestConfigDirsIgnoreRelativeConfigHome verifies a relative XDG_CONFIG_HOME / +// APPDATA is ignored (falling back to the absolute home path) instead of +// resolving config under the current working directory. +func TestConfigDirsIgnoreRelativeConfigHome(t *testing.T) { + resetSetupSeams(t) + home := useTestHome(t) + + t.Run("relative XDG_CONFIG_HOME ignored", func(t *testing.T) { + runtimeGOOS = "linux" + t.Setenv("XDG_CONFIG_HOME", "relative/xdg") + if got, want := vscodeUserDir(), filepath.Join(home, ".config", "Code", "User"); got != want { + t.Errorf("vscodeUserDir with relative XDG = %q, want %q", got, want) + } + if got, want := kilocodeConfigDir(), filepath.Join(home, ".config", "kilo"); got != want { + t.Errorf("kilocodeConfigDir with relative XDG = %q, want %q", got, want) + } + }) + + t.Run("relative APPDATA ignored", func(t *testing.T) { + runtimeGOOS = "windows" + t.Setenv("APPDATA", "relative/appdata") + if got, want := vscodeUserDir(), filepath.Join(home, "AppData", "Roaming", "Code", "User"); got != want { + t.Errorf("vscodeUserDir with relative APPDATA = %q, want %q", got, want) + } + }) +}