package capdiag_test import ( "encoding/json" "os" "path/filepath" "runtime" "strings" "testing" "reasonix/internal/capdiag" "reasonix/internal/hook" "reasonix/internal/pluginpkg" ) func TestCollectStaticNoNetworkSideEffects(t *testing.T) { root := t.TempDir() home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix")) // Shadowed skill + missing description + command override. write(t, filepath.Join(root, ".reasonix", "skills", "demo", "SKILL.md"), "---\nname: demo\ndescription: project demo\n---\nbody\n") write(t, filepath.Join(home, ".reasonix", "skills", "demo", "SKILL.md"), "---\nname: demo\ndescription: global demo\n---\nbody\n") write(t, filepath.Join(root, ".reasonix", "skills", "nodesc", "SKILL.md"), "---\nname: nodesc\n---\nbody\n") write(t, filepath.Join(root, ".reasonix", "commands", "hi.md"), "---\ndescription: project hi\n---\nP $ARGUMENTS\n") write(t, filepath.Join(home, ".reasonix", "commands", "hi.md"), "---\ndescription: home hi\n---\nH $ARGUMENTS\n") // Untrusted project hooks. write(t, filepath.Join(root, ".reasonix", "settings.json"), `{ "hooks": { "PreToolUse": [{"match": "(", "command": "echo bad"}, {"match": ".*", "command": "echo ok"}] } }`) // MCP with missing command. write(t, filepath.Join(root, "reasonix.toml"), ` [[plugins]] name = "broken" type = "stdio" command = "definitely-not-a-real-binary-xyzzy-reasonix" auto_start = false `) // Instruction file. write(t, filepath.Join(root, "AGENTS.md"), "# Agents\nUse go test.\n") r := capdiag.Collect(capdiag.Options{ Root: root, HomeDir: home, ReasonixHomeDir: filepath.Join(home, ".reasonix"), Live: false, }) if r.SchemaVersion != 1 { t.Fatalf("schema = %d", r.SchemaVersion) } if r.Live { t.Fatal("static mode must set live=false") } // Missing convention dirs should not produce warnings. for _, is := range r.Issues { if strings.Contains(is.Message, "missing") && is.Subsystem == "skills" && is.Code != "skill.missing_description" { t.Fatalf("unexpected missing-dir issue: %+v", is) } } codes := map[string]bool{} for _, is := range r.Issues { codes[is.Code] = true } for _, want := range []string{ "skill.shadowed", "skill.missing_description", "command.shadowed", "hook.untrusted_project", "hook.invalid_matcher", "mcp.command_not_found", } { if !codes[want] { t.Fatalf("missing issue code %s in %+v", want, codes) } } // Path redaction: no raw home username paths. raw, err := json.Marshal(r) if err != nil { t.Fatal(err) } text := string(raw) + capdiag.RenderText(r) if strings.Contains(text, home) { t.Fatalf("report leaked home path %q", home) } if strings.Contains(text, root) && !strings.Contains(text, "") { // Root itself is rewritten to ; full root abs path must not appear. t.Fatalf("report leaked workspace abs path") } if strings.Contains(text, "token=") || strings.Contains(text, "Bearer ") { t.Fatal("report leaked secret-like material") } // Deterministic JSON round. j1, _ := capdiag.RenderJSON(r) r2 := capdiag.Collect(capdiag.Options{ Root: root, HomeDir: home, ReasonixHomeDir: filepath.Join(home, ".reasonix"), }) j2, _ := capdiag.RenderJSON(r2) if j1 != j2 { t.Fatal("JSON report not deterministic") } // Instructions listed. if len(r.Instructions.Docs) == 0 { t.Fatal("expected AGENTS.md in instructions") } } func TestMissingConventionDirsNoWarning(t *testing.T) { root := t.TempDir() home := t.TempDir() t.Setenv("HOME", home) t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix")) r := capdiag.Collect(capdiag.Options{ Root: root, HomeDir: home, ReasonixHomeDir: filepath.Join(home, ".reasonix"), }) if r.Issues == nil { t.Fatal("empty issues must be a non-nil slice for JSON consumers") } raw, err := json.Marshal(r) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), `"issues":[]`) { t.Fatalf("empty issues must marshal as [], got %s", raw) } for _, is := range r.Issues { if is.Severity == "warning" && strings.Contains(strings.ToLower(is.Message), "directory") { t.Fatalf("missing dir should not warn: %+v", is) } } // Roots may be missing; that is a normal status, not an issue. _ = r.Skills.Roots } func TestTrustedProjectHooks(t *testing.T) { root := t.TempDir() home := t.TempDir() t.Setenv("HOME", home) t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix")) write(t, filepath.Join(root, ".reasonix", "settings.json"), `{ "hooks": {"Stop": [{"command": "echo done"}]} }`) if err := hook.Trust(root, home); err != nil { t.Fatal(err) } r := capdiag.Collect(capdiag.Options{ Root: root, HomeDir: home, ReasonixHomeDir: filepath.Join(home, ".reasonix"), }) for _, is := range r.Issues { if is.Code == "hook.untrusted_project" { t.Fatalf("trusted project should not warn untrusted: %+v", is) } } if !r.Hooks.TrustedProject { t.Fatal("expected trusted_project") } } func TestHasErrorSeverity(t *testing.T) { if capdiag.HasErrorSeverity(capdiag.Report{}) { t.Fatal("empty should be clean") } if !capdiag.HasErrorSeverity(capdiag.Report{Issues: []capdiag.Issue{{Severity: "error"}}}) { t.Fatal("error severity not detected") } if capdiag.HasErrorSeverity(capdiag.Report{Issues: []capdiag.Issue{{Severity: "warning"}}}) { t.Fatal("warning alone should not fail") } } func TestLoadForRootReadOnlyDoesNotRewriteTier(t *testing.T) { root := t.TempDir() home := t.TempDir() t.Setenv("HOME", home) t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix")) userCfg := filepath.Join(home, ".reasonix", "config.toml") if err := os.MkdirAll(filepath.Dir(userCfg), 0o755); err != nil { t.Fatal(err) } original := "[[plugins]]\nname = \"x\"\ncommand = \"echo\"\ntier = \"eager\"\n" if err := os.WriteFile(userCfg, []byte(original), 0o644); err != nil { t.Fatal(err) } _ = capdiag.Collect(capdiag.Options{ Root: root, HomeDir: home, ReasonixHomeDir: filepath.Join(home, ".reasonix"), }) raw, err := os.ReadFile(userCfg) if err != nil { t.Fatal(err) } if string(raw) != original { t.Fatalf("static diagnostics rewrote config file:\n got %q\nwant %q", raw, original) } } func TestUnknownHookEventIsReported(t *testing.T) { root := t.TempDir() home := t.TempDir() t.Setenv("HOME", home) t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix")) write(t, filepath.Join(root, ".reasonix", "settings.json"), `{ "hooks": { "NotARealEvent": [{"command": "echo hi"}] } }`) // Trust so project hooks load into inspect entries. if err := hook.Trust(root, home); err != nil { t.Fatal(err) } r := capdiag.Collect(capdiag.Options{ Root: root, HomeDir: home, ReasonixHomeDir: filepath.Join(home, ".reasonix"), }) found := false for _, is := range r.Issues { if is.Code == "hook.unknown_event" { found = true break } } if !found { t.Fatalf("expected hook.unknown_event, issues=%+v", r.Issues) } } func TestPluginPackageCommandsAreReported(t *testing.T) { root := t.TempDir() home := t.TempDir() reasonixHome := filepath.Join(home, ".reasonix") t.Setenv("HOME", home) t.Setenv("REASONIX_HOME", reasonixHome) pluginRoot := filepath.Join(reasonixHome, "plugins", "demo") write(t, filepath.Join(pluginRoot, pluginpkg.NativeManifest), `{"name":"demo","commands":["commands"]}`) write(t, filepath.Join(pluginRoot, "commands", "ship.md"), "---\ndescription: ship it\n---\nShip $ARGUMENTS\n") if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{ Name: "demo", Root: "plugins/demo", ManifestKind: "reasonix", Enabled: true, }); err != nil { t.Fatal(err) } r := capdiag.Collect(capdiag.Options{ Root: root, HomeDir: home, ReasonixHomeDir: reasonixHome, }) if len(r.Plugins.Packages) != 1 { t.Fatalf("plugin packages = %+v, want demo", r.Plugins.Packages) } pkg := r.Plugins.Packages[0] if pkg.Commands != 1 { t.Fatalf("plugin commands = %d, want 1", pkg.Commands) } if r.Commands.Winners != 1 { t.Fatalf("command winners = %d, want plugin command", r.Commands.Winners) } if text := capdiag.RenderText(r); !strings.Contains(text, "commands=1") { t.Fatalf("text report omitted plugin commands:\n%s", text) } } func TestDisplayPathExternal(t *testing.T) { // External absolute path should not include user components in JSON. root := t.TempDir() home := t.TempDir() ext := filepath.Join(t.TempDir(), "secret-user-bin", "tool") write(t, filepath.Join(root, "reasonix.toml"), ` [[plugins]] name = "ext" type = "stdio" command = "`+filepath.ToSlash(ext)+`" `) // Create the binary so we don't also get command_not_found noise on path form. write(t, ext, "#!/bin/sh\n") if runtime.GOOS != "windows" { _ = os.Chmod(ext, 0o755) } r := capdiag.Collect(capdiag.Options{ Root: root, HomeDir: home, ReasonixHomeDir: filepath.Join(home, ".reasonix"), }) raw, _ := json.Marshal(r) if strings.Contains(string(raw), "secret-user-bin") { // basename of command is "tool"; parent dir name must not appear. // displayPath uses base only for external: /tool t.Fatalf("leaked external parent path: %s", raw) } } func write(t *testing.T, path, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } }