306 lines
9.2 KiB
Go
306 lines
9.2 KiB
Go
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, "<workspace>") {
|
|
// Root itself is rewritten to <workspace>; 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: <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)
|
|
}
|
|
}
|