diff --git a/bench/journeys.go b/bench/journeys.go index 7323c74457..570d1a164a 100644 --- a/bench/journeys.go +++ b/bench/journeys.go @@ -815,6 +815,8 @@ func Journeys() []Journey { journeys = append(journeys, issue2906Journeys()...) journeys = append(journeys, issue2138Journeys()...) journeys = append(journeys, issue3043Journeys()...) + journeys = append(journeys, issue3557Journeys()...) + journeys = append(journeys, issue3561Journeys()...) journeys = append(journeys, repositoryContextJourneys()...) journeys = append(journeys, providerCaptureRetryJourneys()...) journeys = append(journeys, capturedProviderValidatorJourneys()...) diff --git a/bench/journeys_id_collision_test.go b/bench/journeys_id_collision_test.go index 040c6143e2..b7104b2879 100644 --- a/bench/journeys_id_collision_test.go +++ b/bench/journeys_id_collision_test.go @@ -57,6 +57,8 @@ func journeySources() []journeySource { {"journeys_issue2906.go", issue2906Journeys()}, {"journeys_issue_2138.go", issue2138Journeys()}, {"journeys_issue_3043.go", issue3043Journeys()}, + {"journeys_issue_3557.go", issue3557Journeys()}, + {"journeys_issue_3561.go", issue3561Journeys()}, {"journeys_repository_context.go", repositoryContextJourneys()}, {"journeys_provider_capture.go", providerCaptureRetryJourneys()}, {"journeys_captured_provider_validator.go", capturedProviderValidatorJourneys()}, diff --git a/bench/journeys_issue_3557.go b/bench/journeys_issue_3557.go new file mode 100644 index 0000000000..1e9b5de874 --- /dev/null +++ b/bench/journeys_issue_3557.go @@ -0,0 +1,120 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "time" +) + +const windowsErrorPrivilegeNotHeld = syscall.Errno(1314) + +func issue3557DanglingSymlinkFixture(sandbox *Sandbox) error { + if err := baseRepo(sandbox); err != nil { + return err + } + + configPath := filepath.Join(sandbox.Home, ".config", "opencode") + targetPath := filepath.Join(sandbox.Root, "missing-external-opencode-config") + if err := os.Symlink(targetPath, configPath); err != nil { + if !errors.Is(err, windowsErrorPrivilegeNotHeld) { + return err + } + sandbox.Scratch["issue-3557-symlink-unavailable"] = err.Error() + return nil + } + if err := os.MkdirAll(filepath.Join(sandbox.Home, ".claude"), 0o755); err != nil { + return err + } + statePath := filepath.Join(sandbox.Home, ".gentle-ai", "state.json") + state := fmt.Sprintf(`{"installed_agents":["opencode","claude-code"],"last_update_check":%q}`, time.Now().UTC().Format(time.RFC3339Nano)) + if err := sandbox.write(statePath, state); err != nil { + return err + } + claudeConfig := `{"mcpServers":{"engram":{"command":"engram-not-installed-for-issue-3557","args":[]}}}` + if err := sandbox.write(filepath.Join(sandbox.Home, ".claude.json"), claudeConfig); err != nil { + return err + } + + sandbox.Scratch["issue-3557-config"] = configPath + sandbox.Scratch["issue-3557-target"] = targetPath + sandbox.Scratch["issue-3557-state"] = statePath + sandbox.Scratch["issue-3557-state-content"] = state + sandbox.Scratch["issue-3557-link-target"] = targetPath + return nil +} + +func issue3557DoctorArgs(*Sandbox) ([]string, error) { return []string{"doctor"}, nil } + +func issue3557SymlinkSkip(sandbox *Sandbox) string { + if reason := sandbox.Scratch["issue-3557-symlink-unavailable"]; reason != "" { + return "managed config symlink unavailable: " + reason + } + return "" +} + +func issue3557VerifyDoctor(sandbox *Sandbox, observation Observation) error { + if observation.ExitCode != 0 { + return fmt.Errorf("gentle-ai doctor exited %d: %s", observation.ExitCode, firstLine(observation.Stderr)) + } + if strings.Contains(observation.Stdout, "gentle-ai sync") { + return fmt.Errorf("doctor recommended the unrunnable sync recovery: %s", observation.Stdout) + } + for _, want := range []string{sandbox.Scratch["issue-3557-config"], "inspect", "gentle-ai doctor"} { + if !strings.Contains(observation.Stdout, want) { + return fmt.Errorf("doctor output missing %q: %s", want, observation.Stdout) + } + } + + configPath := sandbox.Scratch["issue-3557-config"] + info, err := os.Lstat(configPath) + if err != nil { + return fmt.Errorf("lstat managed config symlink after doctor: %w", err) + } + if info.Mode()&os.ModeSymlink == 0 { + return fmt.Errorf("doctor changed managed config path into %s", info.Mode()) + } + linkTarget, err := os.Readlink(configPath) + if err != nil { + return fmt.Errorf("read managed config symlink after doctor: %w", err) + } + if linkTarget != sandbox.Scratch["issue-3557-link-target"] { + return fmt.Errorf("doctor changed symlink target from %q to %q", sandbox.Scratch["issue-3557-link-target"], linkTarget) + } + targetPath := sandbox.Scratch["issue-3557-target"] + if _, err := os.Lstat(targetPath); err == nil { + return fmt.Errorf("doctor unexpectedly created %q", targetPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("doctor changed %q: %w", targetPath, err) + } + state, err := os.ReadFile(sandbox.Scratch["issue-3557-state"]) + if err != nil { + return fmt.Errorf("read state after doctor: %w", err) + } + if string(state) != sandbox.Scratch["issue-3557-state-content"] { + return fmt.Errorf("doctor changed state.json: got %q, want %q", string(state), sandbox.Scratch["issue-3557-state-content"]) + } + backupPath := filepath.Join(sandbox.Home, ".gentle-ai", "backups") + if _, err := os.Lstat(backupPath); err == nil { + return fmt.Errorf("doctor unexpectedly created %q", backupPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("doctor could not inspect %q: %w", backupPath, err) + } + return nil +} + +func issue3557Journeys() []Journey { + return []Journey{{ + ID: "j117-doctor-dangling-managed-config", + Review: reviewUntouched, + Title: "Doctor identifies a dangling managed config symlink without recommending sync", + Source: "https://github.com/Gentleman-Programming/gentle-ai/issues/3557", + Steps: []Step{ + {Name: "fixture: dangling OpenCode managed config symlink", Fixture: issue3557DanglingSymlinkFixture}, + {Name: "doctor reports manual repair and preserves the filesystem", Skip: issue3557SymlinkSkip, Args: issue3557DoctorArgs, After: issue3557VerifyDoctor}, + }, + }} +} diff --git a/bench/journeys_issue_3561.go b/bench/journeys_issue_3561.go new file mode 100644 index 0000000000..b03e233470 --- /dev/null +++ b/bench/journeys_issue_3561.go @@ -0,0 +1,134 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +func issue3561DanglingAncestorFixture(sandbox *Sandbox) error { + if err := baseRepo(sandbox); err != nil { + return err + } + + // The sandbox pre-creates ~/.config as a real directory; replace it with + // a symlink whose target does not exist so the managed opencode path + // (~/.config/opencode) sits below a dangling ANCESTOR while not being a + // symlink itself. + ancestorPath := filepath.Join(sandbox.Home, ".config") + targetPath := filepath.Join(sandbox.Root, "missing-external-config-root") + if err := os.Remove(ancestorPath); err != nil { + return err + } + if err := os.Symlink(targetPath, ancestorPath); err != nil { + if !errors.Is(err, windowsErrorPrivilegeNotHeld) { + return err + } + if mkdirErr := os.MkdirAll(ancestorPath, 0o755); mkdirErr != nil { + return mkdirErr + } + sandbox.Scratch["issue-3561-symlink-unavailable"] = err.Error() + return nil + } + if err := os.MkdirAll(filepath.Join(sandbox.Home, ".claude"), 0o755); err != nil { + return err + } + statePath := filepath.Join(sandbox.Home, ".gentle-ai", "state.json") + state := fmt.Sprintf(`{"installed_agents":["opencode","claude-code"],"last_update_check":%q}`, time.Now().UTC().Format(time.RFC3339Nano)) + if err := sandbox.write(statePath, state); err != nil { + return err + } + claudeConfig := `{"mcpServers":{"engram":{"command":"engram-not-installed-for-issue-3561","args":[]}}}` + if err := sandbox.write(filepath.Join(sandbox.Home, ".claude.json"), claudeConfig); err != nil { + return err + } + + sandbox.Scratch["issue-3561-ancestor"] = ancestorPath + sandbox.Scratch["issue-3561-config"] = filepath.Join(ancestorPath, "opencode") + sandbox.Scratch["issue-3561-target"] = targetPath + sandbox.Scratch["issue-3561-state"] = statePath + sandbox.Scratch["issue-3561-state-content"] = state + sandbox.Scratch["issue-3561-link-target"] = targetPath + return nil +} + +func issue3561DoctorArgs(*Sandbox) ([]string, error) { return []string{"doctor"}, nil } + +func issue3561SymlinkSkip(sandbox *Sandbox) string { + if reason := sandbox.Scratch["issue-3561-symlink-unavailable"]; reason != "" { + return "managed config ancestor symlink unavailable: " + reason + } + return "" +} + +func issue3561VerifyDoctor(sandbox *Sandbox, observation Observation) error { + if observation.ExitCode != 0 { + return fmt.Errorf("gentle-ai doctor exited %d: %s", observation.ExitCode, firstLine(observation.Stderr)) + } + if strings.Contains(observation.Stdout, "gentle-ai sync") { + return fmt.Errorf("doctor recommended the unrunnable sync recovery: %s", observation.Stdout) + } + ancestorPath := sandbox.Scratch["issue-3561-ancestor"] + for _, want := range []string{sandbox.Scratch["issue-3561-config"], "dangling ancestor symlink " + ancestorPath, "inspect", "gentle-ai doctor"} { + if !strings.Contains(observation.Stdout, want) { + return fmt.Errorf("doctor output missing %q: %s", want, observation.Stdout) + } + } + + info, err := os.Lstat(ancestorPath) + if err != nil { + return fmt.Errorf("lstat config ancestor symlink after doctor: %w", err) + } + if info.Mode()&os.ModeSymlink == 0 { + return fmt.Errorf("doctor changed config ancestor into %s", info.Mode()) + } + linkTarget, err := os.Readlink(ancestorPath) + if err != nil { + return fmt.Errorf("read config ancestor symlink after doctor: %w", err) + } + if linkTarget != sandbox.Scratch["issue-3561-link-target"] { + return fmt.Errorf("doctor changed ancestor symlink target from %q to %q", sandbox.Scratch["issue-3561-link-target"], linkTarget) + } + targetPath := sandbox.Scratch["issue-3561-target"] + if _, err := os.Lstat(targetPath); err == nil { + return fmt.Errorf("doctor unexpectedly created %q", targetPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("doctor changed %q: %w", targetPath, err) + } + configPath := sandbox.Scratch["issue-3561-config"] + if _, err := os.Lstat(configPath); err == nil { + return fmt.Errorf("doctor unexpectedly created %q", configPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("doctor changed %q: %w", configPath, err) + } + state, err := os.ReadFile(sandbox.Scratch["issue-3561-state"]) + if err != nil { + return fmt.Errorf("read state after doctor: %w", err) + } + if string(state) != sandbox.Scratch["issue-3561-state-content"] { + return fmt.Errorf("doctor changed state.json: got %q, want %q", string(state), sandbox.Scratch["issue-3561-state-content"]) + } + backupPath := filepath.Join(sandbox.Home, ".gentle-ai", "backups") + if _, err := os.Lstat(backupPath); err == nil { + return fmt.Errorf("doctor unexpectedly created %q", backupPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("doctor could not inspect %q: %w", backupPath, err) + } + return nil +} + +func issue3561Journeys() []Journey { + return []Journey{{ + ID: "j118-doctor-dangling-config-ancestor", + Review: reviewUntouched, + Title: "Doctor identifies a dangling ancestor of a managed config path without recommending sync", + Source: "https://github.com/Gentleman-Programming/gentle-ai/pull/3561", + Steps: []Step{ + {Name: "fixture: dangling ~/.config ancestor over the OpenCode managed path", Fixture: issue3561DanglingAncestorFixture}, + {Name: "doctor reports manual repair and preserves the filesystem", Skip: issue3561SymlinkSkip, Args: issue3561DoctorArgs, After: issue3561VerifyDoctor}, + }, + }} +} diff --git a/bench/review_declarations.go b/bench/review_declarations.go index ae2ec751ca..31e58f36a8 100644 --- a/bench/review_declarations.go +++ b/bench/review_declarations.go @@ -50,6 +50,8 @@ var coreJourneyReviewModes = map[string]ReviewPrecondition{ "j29-cherry-pick-in-progress": reviewOptedIn, "j30-kill-switch-flipped-mid-review": reviewOptedIn, "j3043-opencode-managed-background-activation": reviewUntouched, + "j117-doctor-dangling-managed-config": reviewUntouched, + "j118-doctor-dangling-config-ancestor": reviewUntouched, "j31-nonsense-mode-value": reviewUntouched, "j32-recovery-of-a-recovery": reviewOptedIn, "j33-escalate-then-recover": reviewOptedIn, diff --git a/bench/testdata/journeys.manifest b/bench/testdata/journeys.manifest index 30a7c4541a..c04405d1c7 100644 --- a/bench/testdata/journeys.manifest +++ b/bench/testdata/journeys.manifest @@ -13,6 +13,8 @@ j113-correction-removes-candidate-only-path j114-last-reviewer-capture-closes-and-burns j115-recovery-selector-is-collected-before-authorization j116-codex-committed-correction-runs-returned-status-continuation +j117-doctor-dangling-managed-config +j118-doctor-dangling-config-ancestor j12-rejected-capture-then-recapture j13-next-transition-runs-verbatim j14-abandon-needs-a-hand-built-token diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 0d8ec4465a..94b7c70107 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -406,12 +406,53 @@ func checkStateJSON(homeDir string) CheckResult { } var missing []string + var dangling []string for _, agentID := range s.InstalledAgents { if dir := agentConfigDir(homeDir, agentID); dir != "" { - if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { + info, lstatErr := os.Lstat(dir) + if os.IsNotExist(lstatErr) { + // A missing final path is only genuinely missing when its + // ancestors resolve: a dangling ancestor symlink (e.g. + // ~/.config pointing at a removed target) also yields ENOENT + // here, and sync cannot restore a path behind a broken link. + ancestor, ancestorErr := danglingAncestor(homeDir, dir) + if ancestorErr != nil { + return CheckResult{ + Name: id, + Status: CheckStatusWarn, + Detail: fmt.Sprintf("managed config path %s could not be inspected: %v; inspect or repair it manually, then re-run 'gentle-ai doctor'", dir, ancestorErr), + } + } + if ancestor != "" { + dangling = append(dangling, fmt.Sprintf("%s (dangling ancestor symlink %s)", dir, ancestor)) + continue + } missing = append(missing, agentID) + continue + } + if lstatErr != nil { + return CheckResult{ + Name: id, + Status: CheckStatusWarn, + Detail: fmt.Sprintf("managed config path %s could not be inspected: %v; inspect or repair it manually, then re-run 'gentle-ai doctor'", dir, lstatErr), + } } + if info.Mode()&os.ModeSymlink != 0 { + if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { + dangling = append(dangling, dir) + } else if statErr != nil { + return CheckResult{Name: id, Status: CheckStatusWarn, Detail: fmt.Sprintf("managed config symlink target %s could not be inspected: %v; inspect or repair it manually, then re-run 'gentle-ai doctor'", dir, statErr)} + } + } + } + } + + if len(dangling) > 0 { + detail := fmt.Sprintf("state lists %d agent(s) whose managed config paths are dangling symlinks: %s; inspect or repair these paths manually, then re-run 'gentle-ai doctor'", len(dangling), strings.Join(dangling, ", ")) + if len(missing) > 0 { + detail += "; genuinely absent config dirs: " + strings.Join(missing, ", ") } + return CheckResult{Name: id, Status: CheckStatusWarn, Detail: detail} } if len(missing) > 0 { @@ -430,6 +471,37 @@ func checkStateJSON(homeDir string) CheckResult { } } +// danglingAncestor reports the nearest existing ancestor of path — walking +// upward but staying strictly below homeDir — that is a symlink whose target +// is missing. It returns "" when every existing ancestor resolves (the path is +// then genuinely missing) and an error when an ancestor exists but cannot be +// inspected, mirroring the unreadable treatment of the final path. Ancestors +// at or above homeDir are never inspected: they are not managed by gentle-ai, +// and a broken home directory would have failed the state read already. +func danglingAncestor(homeDir, path string) (string, error) { + home := filepath.Clean(homeDir) + boundary := home + string(filepath.Separator) + for ancestor := filepath.Dir(filepath.Clean(path)); ancestor != home && strings.HasPrefix(ancestor, boundary); ancestor = filepath.Dir(ancestor) { + info, err := os.Lstat(ancestor) + if os.IsNotExist(err) { + continue // ancestor missing too; keep walking up + } + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink == 0 { + return "", nil // nearest existing ancestor is real; path is genuinely missing + } + if _, err := os.Stat(ancestor); os.IsNotExist(err) { + return ancestor, nil + } else if err != nil { + return "", err + } + return "", nil // symlink resolves; path is genuinely missing below it + } + return "", nil +} + // agentConfigDir returns the expected config directory for a known agent ID. func agentConfigDir(homeDir, agentID string) string { cfgBase := filepath.Join(homeDir, ".config") diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index 9b3c02cf5b..b14e8022b9 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -347,6 +347,223 @@ func TestCheckStateJSON_AgentConfigDirMissing(t *testing.T) { if !strings.Contains(got.Detail, "config dirs are missing") { t.Errorf("unexpected detail: %s", got.Detail) } + if got.Remedy == nil || got.Remedy.ID != doctor.RemedySync { + t.Fatalf("absent config dir must retain sync remedy, got %+v", got.Remedy) + } +} + +func TestCheckStateJSON_ManagedConfigPathUnreadable(t *testing.T) { + homeDir := t.TempDir() + stateDir := filepath.Join(homeDir, ".gentle-ai") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + configRoot := filepath.Join(homeDir, ".config") + if err := os.WriteFile(configRoot, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stateDir, "state.json"), []byte(`{"installed_agents":["opencode"]}`), 0o644); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(configRoot, "opencode") + _, statErr := os.Lstat(configPath) + if statErr == nil { + t.Fatal("unreadable managed config path unexpectedly exists") + } + + got := checkStateJSON(homeDir) + + if got.Status != CheckStatusWarn { + t.Fatalf("expected warn for unreadable managed config path, got %s: %s", got.Status, got.Detail) + } + for _, want := range []string{configPath, statErr.Error(), "inspect or repair", "gentle-ai doctor"} { + if !strings.Contains(got.Detail, want) { + t.Fatalf("unreadable path result missing %q: %s", want, got.Detail) + } + } + if got.Remedy != nil && got.Remedy.ID == doctor.RemedySync { + t.Fatalf("unreadable managed config path must not recommend sync: %+v", got.Remedy) + } +} + +func setupDanglingOpenCodeFixture(t *testing.T, statePayload, symlinkTarget string) (homeDir, configDir, targetPath string) { + t.Helper() + homeDir = t.TempDir() + configDir = filepath.Join(homeDir, ".config", "opencode") + if err := os.MkdirAll(filepath.Dir(configDir), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(homeDir, ".gentle-ai"), 0o755); err != nil { + t.Fatal(err) + } + if symlinkTarget == "" { + symlinkTarget = filepath.Join(t.TempDir(), "missing-opencode-config") + } + targetPath = symlinkTarget + if err := os.Symlink(targetPath, configDir); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + if err := os.WriteFile(filepath.Join(homeDir, ".gentle-ai", "state.json"), []byte(statePayload), 0o644); err != nil { + t.Fatal(err) + } + return homeDir, configDir, targetPath +} + +func TestCheckStateJSON_AgentConfigDirDanglingSymlink(t *testing.T) { + homeDir, configDir, _ := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`, "") + + got := checkStateJSON(homeDir) + + if got.Status != CheckStatusWarn { + t.Fatalf("expected warn for dangling config symlink, got %s: %s", got.Status, got.Detail) + } + if got.Remedy != nil && got.Remedy.ID == doctor.RemedySync { + t.Fatalf("dangling config symlink must not recommend sync: %+v", got.Remedy) + } + if !strings.Contains(got.Detail, configDir) { + t.Fatalf("detail must identify dangling managed path %q, got %q", configDir, got.Detail) + } + if !strings.Contains(got.Detail, "inspect") || !strings.Contains(got.Detail, "gentle-ai doctor") { + t.Fatalf("detail must provide manual inspection and doctor rerun guidance, got %q", got.Detail) + } + if strings.Contains(got.Detail, "gentle-ai sync") { + t.Fatalf("dangling config symlink must not recommend sync, got %q", got.Detail) + } + info, err := os.Lstat(configDir) + if err != nil { + t.Fatalf("Lstat dangling config symlink: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("doctor changed dangling config path into non-symlink: mode %v", info.Mode()) + } +} + +func TestCheckStateJSON_AgentConfigDirSymlinkLoop(t *testing.T) { + homeDir, _, _ := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`, "opencode") + if got := checkStateJSON(homeDir); got.Status != CheckStatusWarn || !strings.Contains(got.Detail, "could not be inspected") || strings.Contains(got.Detail, "dangling symlinks") { + t.Fatalf("expected inspection warning for config symlink loop, got %s: %s", got.Status, got.Detail) + } +} + +func TestCheckStateJSON_DanglingAndAbsentConfigDirsSuppressSync(t *testing.T) { + homeDir, configDir, _ := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode","claude-code"]}`, "") + + got := checkStateJSON(homeDir) + + if got.Status != CheckStatusWarn { + t.Fatalf("expected warn for mixed dangling/absent config dirs, got %s: %s", got.Status, got.Detail) + } + if got.Remedy != nil { + t.Fatalf("mixed dangling/absent config dirs must suppress sync, got %+v", got.Remedy) + } + for _, want := range []string{configDir, "genuinely absent config dirs: claude-code", "inspect or repair", "gentle-ai doctor"} { + if !strings.Contains(got.Detail, want) { + t.Fatalf("mixed result missing %q: %s", want, got.Detail) + } + } + if strings.Contains(got.Detail, "gentle-ai sync") { + t.Fatalf("mixed dangling/absent result must not recommend sync: %s", got.Detail) + } +} + +func TestCheckStateJSON_DanglingAncestorSymlinkSuppressesSync(t *testing.T) { + homeDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(homeDir, ".gentle-ai"), 0o755); err != nil { + t.Fatal(err) + } + ancestor := filepath.Join(homeDir, ".config") + missingTarget := filepath.Join(t.TempDir(), "missing-config-root") + if err := os.Symlink(missingTarget, ancestor); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + if err := os.WriteFile(filepath.Join(homeDir, ".gentle-ai", "state.json"), []byte(`{"installed_agents":["opencode"]}`), 0o644); err != nil { + t.Fatal(err) + } + configDir := filepath.Join(ancestor, "opencode") + if _, err := os.Lstat(configDir); !os.IsNotExist(err) { + t.Fatalf("managed path under dangling ancestor: lstat err = %v, want not-exist", err) + } + + got := checkStateJSON(homeDir) + + if got.Status != CheckStatusWarn { + t.Fatalf("expected warn for dangling ancestor symlink, got %s: %s", got.Status, got.Detail) + } + if got.Remedy != nil && got.Remedy.ID == doctor.RemedySync { + t.Fatalf("dangling ancestor symlink must not recommend sync: %+v", got.Remedy) + } + for _, want := range []string{configDir, "dangling ancestor symlink " + ancestor, "inspect", "gentle-ai doctor"} { + if !strings.Contains(got.Detail, want) { + t.Fatalf("dangling ancestor result missing %q: %s", want, got.Detail) + } + } + if strings.Contains(got.Detail, "gentle-ai sync") || strings.Contains(got.Detail, "config dirs are missing") { + t.Fatalf("dangling ancestor must not be classified as missing: %s", got.Detail) + } + info, err := os.Lstat(ancestor) + if err != nil { + t.Fatalf("Lstat dangling ancestor symlink after doctor: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("doctor changed dangling ancestor into non-symlink: mode %v", info.Mode()) + } + if _, err := os.Lstat(missingTarget); !os.IsNotExist(err) { + t.Fatalf("doctor touched the missing ancestor target %q: err = %v", missingTarget, err) + } +} + +func TestCheckStateJSON_AbsentAncestorChainKeepsSyncRemedy(t *testing.T) { + homeDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(homeDir, ".gentle-ai"), 0o755); err != nil { + t.Fatal(err) + } + // windsurf lives two levels below home (~/.codeium/windsurf); neither + // level exists, so the path is genuinely missing and sync must stay. + if err := os.WriteFile(filepath.Join(homeDir, ".gentle-ai", "state.json"), []byte(`{"installed_agents":["windsurf"]}`), 0o644); err != nil { + t.Fatal(err) + } + + got := checkStateJSON(homeDir) + + if got.Status != CheckStatusWarn { + t.Fatalf("expected warn for absent config dir, got %s: %s", got.Status, got.Detail) + } + if !strings.Contains(got.Detail, "config dirs are missing") { + t.Fatalf("absent ancestor chain must classify as missing, got %s", got.Detail) + } + if got.Remedy == nil || got.Remedy.ID != doctor.RemedySync { + t.Fatalf("absent ancestor chain must retain sync remedy, got %+v", got.Remedy) + } +} + +func TestCheckStateJSON_HealthySymlinkAncestorKeepsSyncRemedy(t *testing.T) { + homeDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(homeDir, ".gentle-ai"), 0o755); err != nil { + t.Fatal(err) + } + realConfigRoot := filepath.Join(t.TempDir(), "real-config-root") + if err := os.MkdirAll(realConfigRoot, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(realConfigRoot, filepath.Join(homeDir, ".config")); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + // ~/.config resolves fine; only the opencode dir underneath is absent. + if err := os.WriteFile(filepath.Join(homeDir, ".gentle-ai", "state.json"), []byte(`{"installed_agents":["opencode"]}`), 0o644); err != nil { + t.Fatal(err) + } + + got := checkStateJSON(homeDir) + + if got.Status != CheckStatusWarn { + t.Fatalf("expected warn for absent config dir, got %s: %s", got.Status, got.Detail) + } + if !strings.Contains(got.Detail, "config dirs are missing") { + t.Fatalf("healthy symlink ancestor must classify as missing, got %s", got.Detail) + } + if got.Remedy == nil || got.Remedy.ID != doctor.RemedySync { + t.Fatalf("healthy symlink ancestor must retain sync remedy, got %+v", got.Remedy) + } } func TestCheckStateJSON_OK(t *testing.T) { @@ -370,6 +587,9 @@ func TestCheckStateJSON_OK(t *testing.T) { if got.Status != CheckStatusPass { t.Errorf("expected pass, got %s: %s", got.Status, got.Detail) } + if got.Remedy != nil { + t.Fatalf("real config directory must not have a remedy, got %+v", got.Remedy) + } } func TestCheckInstalledAssetVersion_MatchingPass(t *testing.T) { @@ -794,6 +1014,135 @@ Status: healthy } } +func TestRunDoctor_DanglingConfigSymlinkIsReadOnly(t *testing.T) { + origLookPath := lookPathFn + origAvail := availableBytesFn + origHTTP := httpGetFn + origPathDirs := pathDirsFn + origHomeDir := osUserHomeDirDoctor + origExecutable := osExecutableDoctor + defer func() { + lookPathFn = origLookPath + availableBytesFn = origAvail + httpGetFn = origHTTP + pathDirsFn = origPathDirs + osUserHomeDirDoctor = origHomeDir + osExecutableDoctor = origExecutable + }() + + homeDir, configDir, missingTarget := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`, "") + statePath := filepath.Join(homeDir, ".gentle-ai", "state.json") + stateDir := filepath.Dir(statePath) + stateBefore, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + targetBefore, err := os.Readlink(configDir) + if err != nil { + t.Fatal(err) + } + + lookPathFn = func(name string) (string, error) { return "/usr/local/bin/" + name, nil } + availableBytesFn = func(string) (int64, error) { return 1024 * 1024 * 1024, nil } + httpGetFn = func(string, time.Duration) (int, error) { return 200, nil } + pathDirsFn = func() []string { return []string{"/usr/local/bin"} } + osUserHomeDirDoctor = func() (string, error) { return homeDir, nil } + osExecutableDoctor = func() (string, error) { return "/usr/local/bin/gentle-ai", nil } + t.Setenv(engramHealthEnvVar, "https://engram.example.test") + + var output bytes.Buffer + if err := RunDoctor(context.Background(), &output); err != nil { + t.Fatalf("RunDoctor returned error: %v", err) + } + if strings.Contains(output.String(), "gentle-ai sync") { + t.Fatalf("Doctor must not recommend sync for a dangling config symlink, got:\n%s", output.String()) + } + for _, want := range []string{configDir, "inspect", "gentle-ai doctor"} { + if !strings.Contains(output.String(), want) { + t.Fatalf("Doctor output missing %q:\n%s", want, output.String()) + } + } + + stateAfter, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(stateAfter, stateBefore) { + t.Fatalf("Doctor changed state.json") + } + targetAfter, err := os.Readlink(configDir) + if err != nil { + t.Fatalf("read dangling config symlink after Doctor: %v", err) + } + if targetAfter != targetBefore { + t.Fatalf("Doctor changed dangling symlink target from %q to %q", targetBefore, targetAfter) + } + if _, err := os.Lstat(missingTarget); !os.IsNotExist(err) { + t.Fatalf("Doctor changed external target %q: err=%v", missingTarget, err) + } + if _, err := os.Lstat(filepath.Join(stateDir, "backups")); !os.IsNotExist(err) { + t.Fatalf("Doctor created backup metadata: err=%v", err) + } +} + +func TestDoctorSyncDisagreement_RealCLIBoundary(t *testing.T) { + homeDir, configDir, missingTarget := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`, "") + statePath := filepath.Join(homeDir, ".gentle-ai", "state.json") + stateBefore, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + linkTargetBefore, err := os.Readlink(configDir) + if err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(configDir); !os.IsNotExist(err) { + t.Fatalf("dangling managed path stat error = %v, want not-exist", err) + } + doctorResult := checkStateJSON(homeDir) + if doctorResult.Status != CheckStatusWarn { + t.Fatalf("Doctor result = %s, want warn: %s", doctorResult.Status, doctorResult.Detail) + } + if doctorResult.Remedy != nil { + t.Fatalf("fixed Doctor must suppress sync for the dangling path, got %+v", doctorResult.Remedy) + } + + setSyncTestHome(t, homeDir) + syncResult, syncErr := RunSync([]string{"--agents", "opencode"}) + if syncErr == nil { + t.Fatalf("current sync unexpectedly accepted the dangling managed path: result=%+v", syncResult) + } + if syncResult.FilesChanged != 0 || len(syncResult.ChangedFiles) != 0 { + t.Fatalf("rejected sync reported managed changes: FilesChanged=%d ChangedFiles=%v", syncResult.FilesChanged, syncResult.ChangedFiles) + } + + stateAfter, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(stateAfter, stateBefore) { + t.Fatal("sync changed state.json after rejecting the dangling managed path") + } + info, err := os.Lstat(configDir) + if err != nil { + t.Fatalf("Lstat managed path after sync: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("sync replaced managed symlink with %s", info.Mode()) + } + linkTargetAfter, err := os.Readlink(configDir) + if err != nil { + t.Fatalf("Readlink managed path after sync: %v", err) + } + if linkTargetAfter != linkTargetBefore { + t.Fatalf("sync changed symlink target from %q to %q", linkTargetBefore, linkTargetAfter) + } + if _, err := os.Lstat(missingTarget); !os.IsNotExist(err) { + t.Fatalf("sync changed missing symlink target: %v", err) + } +} + func TestRunDoctor_HomeDirError(t *testing.T) { orig := osUserHomeDirDoctor defer func() { osUserHomeDirDoctor = orig }()