From 43f7f7ac1bd3cf3206ff4aa1c62e406c48310290 Mon Sep 17 00:00:00 2001 From: pushkraj-space Date: Fri, 11 Sep 2026 21:10:14 +0530 Subject: [PATCH] fix: refine doctor launcher reporting and diagnostic coverage --- README.md | 107 ++++++++++++------ cmd/moirai/doctor.go | 43 ++++---- cmd/moirai/doctor_test.go | 191 +++++++++++++++++++++++++++++++-- cmd/moirai/doctor_unix.go | 6 +- cmd/moirai/doctor_unix_test.go | 140 +++++++++++++++++++----- 5 files changed, 390 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 59327e4..029985d 100644 --- a/README.md +++ b/README.md @@ -221,46 +221,93 @@ codex Codex read,write,discover,continue warning: /home/me/.codex/sessions: store root does not exist; run Codex once, or set CODEX_HOME if its data lives elsewhere (store_missing) ``` -The `executable` line appears only for harnesses Moirai can launch and reports -whether the launcher is on `PATH`. On macOS and Windows the Claude Cowork line -therefore reflects the `open` or `cmd` launcher, not a verified installation. +The `executable` line appears only when Moirai has a launch command for the +harness. Claude Cowork and Cursor Desktop label this line `launcher`: Cowork +uses `open` on macOS, `cmd` on Windows, and `claude-desktop` on Linux; Cursor +Desktop uses `cursor`. Finding the program on `PATH` does not verify that the +desktop application is installed. Amp and Hermes have no launch command, so +their executable status is omitted. + The `store` line shows the resolved root and names the override variable that is set, or the variables that would override it. Harnesses without a local store (`simple`, `claude_chat`, `chatgpt`) show `store: none`. -The `status` line combines a read phrase with, for stores Moirai can save into, -a write phrase: +The `status` line reports `missing`, `unknown`, `wrong type`, `readable`, or +`not readable`. Directory stores Moirai can save into also report `writable`, +`not writable`, or `write access not checked` (on non-Unix platforms, including +Windows). File-backed stores and source-only stores omit the write status. +Harnesses without a store show `status: none`. + +Warnings use these codes: -| Status | Meaning | Fix | +| Code | Meaning | Fix | |---|---|---| -| `missing` | The store root does not exist. | Run the harness once so it creates its data directory, or set the named variable if the data lives elsewhere. | -| `unknown` | The root could not be inspected, usually because a parent directory denies access. | Check the ownership and permissions of the parent directories. | -| `wrong type` | A file sits where a directory is expected, a directory where a database file is expected, or the path is a pipe, socket, or device. Doctor never opens such a path. | Move the entry aside, or point the override variable at the real store. | -| `not readable` | The root exists but cannot be opened. | Fix its ownership or permissions. | -| `readable` | The root can be opened. | | -| `writable`, `not writable` | Whether the current account may create entries in the root. | Fix its ownership or permissions. | -| `write access not checked` | Write permission is not queried on this platform (Windows). | Run an import; it reports any permission error. | - -Warnings carry one of these codes: `executable_missing`, `store_missing`, -`store_stat_failed`, `store_wrong_type`, `store_unreadable`, and -`store_unwritable`. +| `executable_missing` | The launch program was not found on `PATH`. | Install it or add it to `PATH`. | +| `store_missing` | The store root does not exist. | Run the harness once, or set the named override if the data lives elsewhere. | +| `store_stat_failed` | The root could not be inspected; existence is unknown. | Check parent-directory permissions and symlink targets. | +| `store_wrong_type` | The root has the wrong kind: a file where a directory belongs, a directory where a database file belongs, or a pipe, socket, or device. | Point the store override at the correct root. | +| `store_unreadable` | Opening and closing the root failed. | Check its ownership and permissions. | +| `store_unwritable` | The directory failed the write and search permission query. | Check ownership, permissions, and whether the filesystem is read-only. | + +These are diagnostic statuses, including missing optional harnesses and stores; +they do not make the command exit with an error. `moirai doctor --json` emits an array with one object per harness holding `format`, `display_name`, `capabilities`, `executable`, `installed`, `store`, `store_overrides`, `active_override`, `exists`, `readable`, `writable`, and -`warnings`. A key is absent when its check is unknown or does not apply, so -scripts should test for presence rather than assume `false`. JSON keeps paths -and error text verbatim; the human report scrubs terminal control characters. - -Doctor is read-only. It stats and opens store roots but never lists -directories, reads transcripts, writes files, or touches the network, and it -does not print environment-variable values on their own (a resolved store path -does reveal the value of the active override). Write permission is queried with -`access(2)` rather than tested by writing, so it is advisory for the account -running doctor: it uses real credentials, always succeeds for root, and does not -cover nested destination directories, disk space, or a harness's own import. -Database-file stores are never write-checked; OpenCode saves go through -`opencode import`, and Hermes is source-only. +`warnings`. `capabilities` contains the registry's boolean capability object; +`installed`, `exists`, `readable`, and `writable` are optional booleans. A key is +absent when its check is unknown or does not apply, so scripts should test for +presence rather than assume `false`. Empty strings and lists are also omitted. +For example, a missing Codex store with a launch program on `PATH` looks like: + +```json +[ + { + "format": "codex", + "display_name": "Codex", + "capabilities": { + "read": true, "write": true, "discover": true, "save": true, + "delete": true, "continue": true, "remote": false, "source_only": false + }, + "executable": "codex", + "installed": true, + "store": "/home/me/.codex/sessions", + "store_overrides": ["CODEX_HOME"], + "exists": false, + "warnings": [{ + "path": "/home/me/.codex/sessions", + "code": "store_missing", + "message": "store root does not exist; run Codex once, or set CODEX_HOME if its data lives elsewhere" + }] + } +] +``` + +The full array follows registry order. JSON `installed` means only that the +launch program can be found on `PATH`, including desktop launchers. Each +`store_overrides` list contains environment-variable names in precedence order, +including `XDG_DATA_HOME` for Amp and OpenCode and `APPDATA` for Cowork on +Windows. `active_override`, when present, names the first nonempty variable; +empty and shadowed variables do not become active. JSON preserves paths and +error text; the human report scrubs terminal control characters. + +Doctor is read-only. It stats each root, following symlinks, and only opens and +closes roots whose type matches the store: a regular file for OpenCode and +Hermes, a directory for other stores. Pipes, sockets, and devices fail the type +check before opening. Doctor never lists directories, discovers or loads +sessions, reads transcripts, runs launchers, writes files, or touches the +network. It does not print environment-variable values separately; the resolved +store path may still reveal all or part of the active override value. + +On Unix, writability is queried with `access(2)` using `W_OK | X_OK` and the +process's **real** user/group credentials. Readability uses `os.Open` and the +process's **effective** credentials. Writability is advisory for the invoking +account: root can bypass ordinary mode bits, but a read-only filesystem can +reject even root. The check does not cover nested destination directories, +disk space, or a harness's own import. Database-file stores are never checked +for writability; OpenCode saves go through `opencode import`, and Hermes is +source-only. ## Supported formats diff --git a/cmd/moirai/doctor.go b/cmd/moirai/doctor.go index bd3bd79..2a0beed 100644 --- a/cmd/moirai/doctor.go +++ b/cmd/moirai/doctor.go @@ -28,10 +28,11 @@ type doctorRow struct { Readable *bool `json:"readable,omitempty"` Writable *bool `json:"writable,omitempty"` Warnings []moirai.Warning `json:"warnings,omitempty"` +} - // writeChecked records that a write check applies to the root, so the - // human report can tell "not checked" apart from "not applicable". - writeChecked bool +var fileBackedFormats = map[moirai.Format]bool{ + moirai.FormatOpenCode: true, + moirai.FormatHermes: true, } func (a app) doctor(args []string) error { @@ -80,14 +81,14 @@ func (r *doctorRow) checkExecutable(program string) { } // checkStore reports on the store root without opening anything whose type is -// unexpected: a named pipe or socket would block, and a directory where a -// database file belongs cannot be a store. +// unexpected. Stat follows symlinks to legitimate roots while allowing the +// kind gate to reject pipes, sockets, and devices before opening the root. func (r *doctorRow) checkStore(store moirai.Store) { root := store.Root() r.Store = root r.StoreOverrides = storeOverrides(r.Format) r.ActiveOverride = activeOverride(r.StoreOverrides) - wantFile := expectsFile(store) + wantFile := fileBackedFormats[r.Format] info, err := os.Stat(root) if errors.Is(err, fs.ErrNotExist) { r.Exists = boolPtr(false) @@ -110,16 +111,15 @@ func (r *doctorRow) checkStore(store moirai.Store) { } file, err := os.Open(root) if err == nil { - file.Close() - r.Readable = boolPtr(true) - } else { - r.Readable = boolPtr(false) - r.warn(root, "store_unreadable", fmt.Sprintf("store root cannot be opened: %v; fix its ownership or permissions", underlyingError(err))) + err = file.Close() + } + r.Readable = boolPtr(err == nil) + if err != nil { + r.warn(root, "store_unreadable", fmt.Sprintf("store root readability check failed: %v; fix its ownership or permissions", underlyingError(err))) } if wantFile || !r.Capabilities.Save { return } - r.writeChecked = true r.Writable = writableDir(root) if r.Writable != nil && !*r.Writable { r.warn(root, "store_unwritable", "store root is not writable by the current user; fix its ownership or permissions") @@ -140,11 +140,15 @@ func (r doctorRow) overrideHint() string { func (r doctorRow) print(w io.Writer) { fmt.Fprintf(w, "%s %s %s\n", r.Format, r.DisplayName, strings.Join(capabilityNames(r.Capabilities), ",")) if r.Executable != "" { + label := "executable" + if r.Format == moirai.FormatCowork || r.Format == moirai.FormatCursorDesktop { + label = "launcher" + } state := "found" if !*r.Installed { state = "not on PATH" } - fmt.Fprintf(w, " executable: %s (%s)\n", r.Executable, state) + fmt.Fprintf(w, " %s: %s (%s)\n", label, r.Executable, state) } fmt.Fprintf(w, " store: %s\n", r.storeLine()) fmt.Fprintf(w, " status: %s\n", r.statusLine()) @@ -186,7 +190,7 @@ func (r doctorRow) statusLine() string { case !*r.Readable: read = "not readable" } - if !r.writeChecked { + if r.Readable == nil || !r.Capabilities.Save || fileBackedFormats[r.Format] { return read } write := "write access not checked" @@ -244,17 +248,6 @@ func activeOverride(names []string) string { return "" } -// expectsFile reports whether the store root is a database file rather than a -// directory of sessions. -func expectsFile(store moirai.Store) bool { - switch store.(type) { - case *moirai.OpenCodeStore, *moirai.HermesStore: - return true - default: - return false - } -} - func describeMode(mode fs.FileMode) string { switch { case mode.IsRegular(): diff --git a/cmd/moirai/doctor_test.go b/cmd/moirai/doctor_test.go index 8b630e0..04f56f2 100644 --- a/cmd/moirai/doctor_test.go +++ b/cmd/moirai/doctor_test.go @@ -92,6 +92,9 @@ func runDoctor(t *testing.T) doctorResult { } result := doctorResult{rows: map[moirai.Format]doctorRow{}, raw: map[moirai.Format]map[string]json.RawMessage{}} for index, row := range rows { + if row.Format != moirai.Formats[index] { + t.Fatalf("row %d: format %s, want %s", index, row.Format, moirai.Formats[index]) + } result.rows[row.Format] = row result.raw[row.Format] = raw[index] } @@ -101,6 +104,14 @@ func runDoctor(t *testing.T) doctorResult { t.Fatal(err) } result.human = stdout.String() + previous := -1 + for _, row := range rows { + index := strings.Index(result.human, string(row.Format)+" "+row.DisplayName+" ") + if index <= previous { + t.Fatalf("human output is missing %s or out of registry order:\n%s", row.Format, result.human) + } + previous = index + } return result } @@ -161,6 +172,11 @@ func TestDoctorReportsStatuses(t *testing.T) { if !isTrue(claude.Exists) || !isTrue(claude.Readable) || len(claude.Warnings) != 0 { t.Fatalf("claude status: %+v", claude) } + if runtime.GOOS == "windows" { + if result.hasKey(moirai.FormatClaudeCode, "writable") || !strings.Contains(result.human, "status: readable, write access not checked\n") { + t.Fatalf("Windows write access must be not checked: %+v\n%s", claude, result.human) + } + } codex := result.row(t, moirai.FormatCodex) codexRoot := filepath.Join(codexHome, "sessions") if codex.Executable != "codex" || !isFalse(codex.Installed) || codex.Store != codexRoot || !isFalse(codex.Exists) { @@ -193,6 +209,11 @@ func TestDoctorReportsStatuses(t *testing.T) { func TestDoctorOmitsInapplicableKeys(t *testing.T) { home, _ := isolatedDoctorEnv(t) + ampRoot := filepath.Join(home, "amp-threads") + if err := os.Mkdir(ampRoot, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("AMP_THREADS_DIR", ampRoot) openCodeDB := filepath.Join(home, "opencode.db") hermesHome := filepath.Join(home, "hermes") if err := os.MkdirAll(hermesHome, 0o700); err != nil { @@ -214,13 +235,17 @@ func TestDoctorOmitsInapplicableKeys(t *testing.T) { } } } - for _, format := range []moirai.Format{moirai.FormatOpenCode, moirai.FormatHermes} { + for _, format := range []moirai.Format{moirai.FormatOpenCode, moirai.FormatHermes, moirai.FormatAmp} { row := result.row(t, format) - if !isTrue(row.Exists) || !isTrue(row.Readable) || len(row.Warnings) != 0 && row.Warnings[0].Code != "executable_missing" { + wantCodes := []string{} + if format == moirai.FormatOpenCode { + wantCodes = append(wantCodes, "executable_missing") + } + if !isTrue(row.Exists) || !isTrue(row.Readable) || !slices.Equal(warningCodes(row), wantCodes) { t.Errorf("%s row: %+v", format, row) } if result.hasKey(format, "writable") { - t.Errorf("%s is a database file and must not report writable: %s", format, result.raw[format]["writable"]) + t.Errorf("%s is file-backed or source-only and must not report writable: %s", format, result.raw[format]["writable"]) } } if !strings.Contains(result.human, "opencode OpenCode read,write,discover,continue\n executable: opencode (not on PATH)\n store: "+openCodeDB+" (OPENCODE_DB is set)\n status: readable\n") { @@ -382,29 +407,53 @@ func TestDoctorOverridePrecedence(t *testing.T) { type precedence struct { format moirai.Format first, second string + fallback string } cases := []precedence{ - {moirai.FormatPi, "PI_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_DIR"}, - {moirai.FormatCampfire, "CAMPFIRE_CODING_AGENT_SESSION_DIR", "CAMPFIRE_CODING_AGENT_DIR"}, - {moirai.FormatAmp, "AMP_THREADS_DIR", "XDG_DATA_HOME"}, - {moirai.FormatOpenCode, "OPENCODE_DB", "XDG_DATA_HOME"}, + {moirai.FormatPi, "PI_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_DIR", "sessions"}, + {moirai.FormatCampfire, "CAMPFIRE_CODING_AGENT_SESSION_DIR", "CAMPFIRE_CODING_AGENT_DIR", "sessions"}, + {moirai.FormatAmp, "AMP_THREADS_DIR", "XDG_DATA_HOME", filepath.Join("amp", "threads")}, + {moirai.FormatOpenCode, "OPENCODE_DB", "XDG_DATA_HOME", filepath.Join("opencode", "opencode.db")}, } if runtime.GOOS == "windows" { - cases = append(cases, precedence{moirai.FormatCowork, "COWORK_SESSIONS_DIR", "APPDATA"}) + cases = append(cases, precedence{moirai.FormatCowork, "COWORK_SESSIONS_DIR", "APPDATA", filepath.Join("Claude", "local-agent-mode-sessions")}) } for _, c := range cases { t.Run(string(c.format), func(t *testing.T) { isolatedDoctorEnv(t) + defaults := runDoctor(t) + if defaults.hasKey(c.format, "active_override") { + t.Fatal("empty overrides must not report an active_override") + } winner := filepath.Join(t.TempDir(), "winner") if c.first == "OPENCODE_DB" { winner += ".db" } t.Setenv(c.first, winner) - t.Setenv(c.second, filepath.Join(t.TempDir(), "loser")) - row := runDoctor(t).row(t, c.format) + second := filepath.Join(t.TempDir(), "shadowed-override-value") + t.Setenv(c.second, second) + result := runDoctor(t) + row := result.row(t, c.format) if row.Store != winner || row.ActiveOverride != c.first || !slices.Equal(row.StoreOverrides, []string{c.first, c.second}) { t.Fatalf("row: %+v", row) } + encoded, err := json.Marshal(row) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "shadowed-override-value") { + t.Fatalf("row prints the shadowed override value: %s", encoded) + } + t.Setenv(c.first, "") + row = runDoctor(t).row(t, c.format) + if row.Store != filepath.Join(second, c.fallback) || row.ActiveOverride != c.second { + t.Fatalf("empty first override must fall back to second: %+v", row) + } + t.Setenv(c.second, "") + result = runDoctor(t) + if row = result.row(t, c.format); row.Store != defaults.rows[c.format].Store || result.hasKey(c.format, "active_override") { + t.Fatalf("empty overrides must fall back to the default root: %+v", row) + } }) } } @@ -455,3 +504,125 @@ func TestDoctorRejectsArguments(t *testing.T) { } } } + +func TestDoctorExecutables(t *testing.T) { + for _, installed := range []bool{false, true} { + name := "empty PATH" + if installed { + name = "fake executables" + } + t.Run(name, func(t *testing.T) { + _, bin := isolatedDoctorEnv(t) + if installed { + for _, info := range moirai.DefaultRegistry.Harnesses() { + if command, err := moirai.CommandFor(info.Format, moirai.SessionRef{}); err == nil { + fakeExecutable(t, bin, command.Program) + } + } + // A binary alone cannot make a source-only harness launchable. + fakeExecutable(t, bin, "amp") + fakeExecutable(t, bin, "hermes") + } + result := runDoctor(t) + for _, info := range moirai.DefaultRegistry.Harnesses() { + row := result.row(t, info.Format) + if row.DisplayName != info.DisplayName || row.Capabilities != info.Capability { + t.Errorf("%s metadata differs from registry: %+v", info.Format, row) + } + command, err := moirai.CommandFor(info.Format, moirai.SessionRef{}) + if err != nil { + if result.hasKey(info.Format, "executable") || result.hasKey(info.Format, "installed") { + t.Errorf("%s has no launch command but reports executable status: %+v", info.Format, row) + } + if _, ok := findWarning(row, "executable_missing"); ok { + t.Errorf("%s has no launch command but warns about an executable", info.Format) + } + continue + } + if row.Executable != command.Program || row.Installed == nil || *row.Installed != installed { + t.Errorf("%s executable status: %+v", info.Format, row) + } + if _, ok := findWarning(row, "executable_missing"); ok == installed { + t.Errorf("%s executable warning: %+v", info.Format, row.Warnings) + } + if info.Format == moirai.FormatCowork || info.Format == moirai.FormatCursorDesktop { + if !strings.Contains(result.human, " launcher: "+command.Program+" (") { + t.Errorf("%s must label the program as a launcher:\n%s", info.Format, result.human) + } + } + } + }) + } +} + +func TestDoctorFollowsStoreSymlinks(t *testing.T) { + for _, file := range []bool{false, true} { + name := "directory" + if file { + name = "file" + } + t.Run(name, func(t *testing.T) { + home, bin := isolatedDoctorEnv(t) + target := filepath.Join(home, "target") + link := filepath.Join(home, "store") + format, variable := moirai.FormatPi, "PI_CODING_AGENT_SESSION_DIR" + if file { + format, variable = moirai.FormatOpenCode, "OPENCODE_DB" + if err := os.WriteFile(target, []byte("not a database; doctor must not parse it"), 0o600); err != nil { + t.Fatal(err) + } + } else if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("symlinks unavailable: %v", err) + } + t.Fatal(err) + } + command, err := moirai.CommandFor(format, moirai.SessionRef{}) + if err != nil { + t.Fatal(err) + } + fakeExecutable(t, bin, command.Program) + t.Setenv(variable, link) + result := runDoctor(t) + row := result.row(t, format) + if row.Store != link || !isTrue(row.Exists) || !isTrue(row.Readable) || len(row.Warnings) != 0 { + t.Fatalf("symlink to %s: %+v", name, row) + } + if file && result.hasKey(format, "writable") { + t.Fatal("file-backed symlink must not report writable") + } + }) + } +} + +func TestFormatsHumanOutputUnchanged(t *testing.T) { + var stdout, stderr bytes.Buffer + a := app{out: &stdout, err: &stderr} + if err := a.run(context.Background(), []string{"formats"}); err != nil { + t.Fatal(err) + } + // Captured from main before extracting capabilityNames. + const want = `simple Simple read,write +claude_code Claude Code read,write,discover,continue +codex Codex read,write,discover,continue +pi pi read,write,discover,continue +amp Amp read,write,discover,source-only +opencode OpenCode read,write,discover,continue +cursor Cursor Agent read,write,discover,continue +cursor_desktop Cursor read,write,discover +grok Grok CLI read,write,discover,continue +hermes Hermes Agent read,write,discover,source-only +antigravity Antigravity CLI read,write,discover,continue +campfire Campfire read,write,discover,continue +cowork Claude Cowork read,write,discover +fx fx read,write,discover,continue +claude_chat Claude Chat read,source-only +chatgpt ChatGPT read,source-only +` + if stdout.String() != want { + t.Fatalf("formats output changed:\ngot %q\nwant %q", stdout.String(), want) + } +} diff --git a/cmd/moirai/doctor_unix.go b/cmd/moirai/doctor_unix.go index 0f8caf4..a559f82 100644 --- a/cmd/moirai/doctor_unix.go +++ b/cmd/moirai/doctor_unix.go @@ -4,10 +4,10 @@ package main import "golang.org/x/sys/unix" -// writableDir reports whether the calling user may create entries in the -// directory at path. access(2) is asked for both the write and search bits: +// writableDir reports whether the process's real user/group may create entries +// in the directory at path. access(2) is asked for both the write and search bits: // a directory that is writable but not searchable cannot receive new files. -// The permission is queried, never probed by writing. +// The permission is advisory and queried without writing. func writableDir(path string) *bool { writable := unix.Access(path, unix.W_OK|unix.X_OK) == nil return &writable diff --git a/cmd/moirai/doctor_unix_test.go b/cmd/moirai/doctor_unix_test.go index c5f4af0..b4a6826 100644 --- a/cmd/moirai/doctor_unix_test.go +++ b/cmd/moirai/doctor_unix_test.go @@ -3,7 +3,10 @@ package main import ( + "context" + "encoding/json" "os" + "os/exec" "path/filepath" "slices" "strings" @@ -17,7 +20,7 @@ import ( func skipAsRoot(t *testing.T) { t.Helper() if os.Geteuid() == 0 { - t.Skip("permission checks always pass for root") + t.Skip("root can bypass ordinary mode permission checks") } } @@ -89,45 +92,124 @@ func TestDoctorReportsStatFailure(t *testing.T) { result := runDoctor(t) row := result.row(t, moirai.FormatClaudeCode) failed, ok := findWarning(row, "store_stat_failed") - if result.hasKey(moirai.FormatClaudeCode, "exists") || !ok || failed.Path != row.Store || !strings.Contains(failed.Message, "permission denied") { + if !ok || failed.Path != row.Store || !strings.Contains(failed.Message, "permission denied") { t.Fatalf("row: %+v", row) } + for _, key := range []string{"exists", "readable", "writable"} { + if result.hasKey(moirai.FormatClaudeCode, key) { + t.Errorf("stat failure must omit %s: %v", key, result.raw[moirai.FormatClaudeCode]) + } + } if !strings.Contains(result.human, " status: unknown\n") { t.Fatalf("human output:\n%s", result.human) } } func TestDoctorNeverOpensFIFO(t *testing.T) { + for _, c := range []struct { + format moirai.Format + variable string + }{ + {moirai.FormatPi, "PI_CODING_AGENT_SESSION_DIR"}, + {moirai.FormatOpenCode, "OPENCODE_DB"}, + } { + for _, kind := range []string{"direct", "symlink"} { + t.Run(string(c.format)+"/"+kind, func(t *testing.T) { + home, _ := isolatedDoctorEnv(t) + fifo := filepath.Join(home, "fifo") + if err := unix.Mkfifo(fifo, 0o600); err != nil { + t.Fatal(err) + } + root := fifo + if kind == "symlink" { + root = filepath.Join(home, "link") + if err := os.Symlink(fifo, root); err != nil { + t.Fatal(err) + } + } + t.Setenv(c.variable, root) + // Use a child process so a regression can be killed without + // leaving a blocked goroutine behind during environment cleanup. + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binary, "-test.run=^TestDoctorFIFOProcess$") + cmd.Env = append(os.Environ(), "MOIRAI_TEST_DOCTOR_FIFO=1") + output, err := cmd.CombinedOutput() + if ctx.Err() != nil { + t.Fatal("doctor blocked: it must never open a FIFO store root") + } + if err != nil { + t.Fatalf("doctor subprocess: %v\n%s", err, output) + } + var rows []doctorRow + if err := json.Unmarshal(output, &rows); err != nil { + t.Fatalf("doctor JSON: %v\n%s", err, output) + } + index := slices.IndexFunc(rows, func(row doctorRow) bool { return row.Format == c.format }) + if index < 0 { + t.Fatalf("no %s row", c.format) + } + row := rows[index] + wrong, ok := findWarning(row, "store_wrong_type") + if !isTrue(row.Exists) || row.Readable != nil || row.Writable != nil || !ok || !strings.Contains(wrong.Message, "named pipe") { + t.Fatalf("row: %+v", row) + } + var raw []map[string]json.RawMessage + if err := json.Unmarshal(output, &raw); err != nil { + t.Fatal(err) + } + for _, key := range []string{"readable", "writable"} { + if _, ok := raw[index][key]; ok { + t.Errorf("FIFO root must omit %s: %v", key, raw[index]) + } + } + }) + } + } +} + +func TestDoctorFIFOProcess(t *testing.T) { + if os.Getenv("MOIRAI_TEST_DOCTOR_FIFO") != "1" { + return + } + a := app{out: os.Stdout, err: os.Stderr} + if err := a.run(context.Background(), []string{"doctor", "--json"}); err != nil { + t.Fatal(err) + } + os.Exit(0) +} + +func TestDoctorSymlinkLoopHasUnknownStatus(t *testing.T) { home, _ := isolatedDoctorEnv(t) - fifo := filepath.Join(home, "opencode.db") - if err := unix.Mkfifo(fifo, 0o600); err != nil { + root := filepath.Join(home, "opencode.db") + if err := os.Symlink(root, root); err != nil { t.Fatal(err) } - t.Setenv("OPENCODE_DB", fifo) - type outcome struct { - rows []doctorRow - err error - } - done := make(chan outcome, 1) - go func() { - rows, _, err := doctorJSON() - done <- outcome{rows, err} - }() - select { - case got := <-done: - if got.err != nil { - t.Fatal(got.err) - } - index := slices.IndexFunc(got.rows, func(row doctorRow) bool { return row.Format == moirai.FormatOpenCode }) - if index < 0 { - t.Fatal("no opencode row") - } - row := got.rows[index] - wrong, ok := findWarning(row, "store_wrong_type") - if !isTrue(row.Exists) || row.Readable != nil || row.Writable != nil || !ok || !strings.Contains(wrong.Message, "named pipe") { - t.Fatalf("row: %+v", row) + t.Setenv("OPENCODE_DB", root) + result := runDoctor(t) + row := result.row(t, moirai.FormatOpenCode) + if warning, ok := findWarning(row, "store_stat_failed"); !ok || warning.Path != root { + t.Fatalf("symlink loop must report stat failure: %+v", row) + } + for _, key := range []string{"exists", "readable", "writable"} { + if result.hasKey(moirai.FormatOpenCode, key) { + t.Errorf("stat failure must omit %s: %v", key, result.raw[moirai.FormatOpenCode]) } - case <-time.After(10 * time.Second): - t.Fatal("doctor blocked: it must never open a FIFO store root") + } +} + +func TestDoctorHealthyDirectoryIsWritable(t *testing.T) { + _, projects := claudeConfig(t) + result := runDoctor(t) + row := result.row(t, moirai.FormatClaudeCode) + if !isTrue(row.Exists) || !isTrue(row.Readable) || !isTrue(row.Writable) { + t.Fatalf("healthy directory: %+v", row) + } + if !strings.Contains(result.human, " store: "+projects+" (CLAUDE_CONFIG_DIR is set)\n status: readable, writable\n") { + t.Fatalf("healthy directory status:\n%s", result.human) } }