diff --git a/internal/cmd/install.go b/internal/cmd/install.go index 50f1533..1189a39 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -59,7 +59,10 @@ Not auto-configurable (manual setup required): armis-cli install claude # Check installed version - armis-cli install --version`, + armis-cli install --version + + # Debug a broken or missing MCP setup + armis-cli mcp doctor`, RunE: runInstall, } @@ -396,6 +399,7 @@ func installTargets(targets []string, force, withKnowledge bool) error { if ci.HasExistingEnv() { fmt.Fprintln(os.Stderr, "Credentials configured. Restart Claude Code to pick up the updated plugin.") + fmt.Fprintln(os.Stderr, "Run 'armis-cli mcp doctor' to verify the setup works.") } else { fmt.Fprintln(os.Stderr, "Next steps:") fmt.Fprintf(os.Stderr, " 1. Set your credentials in %s:\n", ci.EnvFilePath()) @@ -426,6 +430,7 @@ func installTargets(targets []string, force, withKnowledge bool) error { func printCredentialStatus(ei *install.EditorInstaller) { if ei.HasExistingEnv() { fmt.Fprintln(os.Stderr, "Credentials configured. Restart your editors to use the MCP server.") + fmt.Fprintln(os.Stderr, "Run 'armis-cli mcp doctor' to verify the setup works.") } else { fmt.Fprintln(os.Stderr, "Next steps:") fmt.Fprintf(os.Stderr, " 1. Set your credentials in %s:\n", ei.EnvFilePath()) @@ -459,4 +464,7 @@ func printKnowledgeResult(res knowledgeResult) { } else if !res.skipped { fmt.Fprintln(os.Stderr, " ⚠ Knowledge was not registered in any editor.") } + if len(res.warnings) > 0 { + fmt.Fprintln(os.Stderr, "Run 'armis-cli mcp doctor' to see what's wrong.") + } } diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go new file mode 100644 index 0000000..89833d3 --- /dev/null +++ b/internal/cmd/mcp.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var mcpCmd = &cobra.Command{ + Use: "mcp", + Short: "Inspect and debug MCP server setup", + Long: `Inspect and debug the MCP servers armis-cli install registered. + +Use 'armis-cli mcp doctor' to check the scanner and knowledge MCP servers: +plugin files, credentials, editor registrations, and a live handshake. + +Use 'armis-cli mcp update' to update them to the latest version.`, +} + +func init() { + rootCmd.AddCommand(mcpCmd) +} diff --git a/internal/cmd/mcp_doctor.go b/internal/cmd/mcp_doctor.go new file mode 100644 index 0000000..66b76ac --- /dev/null +++ b/internal/cmd/mcp_doctor.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/ArmisSecurity/armis-cli/internal/cli" + "github.com/ArmisSecurity/armis-cli/internal/cmd/cmdutil" + "github.com/ArmisSecurity/armis-cli/internal/install" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" +) + +var ( + mcpDoctorFormat string + mcpDoctorNoHandshake bool + mcpDoctorTimeout time.Duration +) + +var mcpDoctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Diagnose the installed MCP servers and their editor registrations", + Long: `Diagnose everything 'armis-cli install' may have set up: the scanner and +knowledge MCP servers' plugin files and credentials, whether each registered +editor's config still contains the entry, Claude Code's plugin registry, and +Codex CLI's config.toml — then, unless --no-handshake is set, spawns each +server and performs a live MCP "initialize" handshake to confirm it actually +starts and responds. + +Exits non-zero if any check fails.`, + Example: ` # Full diagnostic, including live handshake + armis-cli mcp doctor + + # Structural checks only, skip spawning the servers + armis-cli mcp doctor --no-handshake + + # Machine-readable output + armis-cli mcp doctor --format json`, + Args: cobra.NoArgs, + RunE: runMCPDoctor, +} + +func init() { + mcpCmd.AddCommand(mcpDoctorCmd) + mcpDoctorCmd.Flags().StringVarP(&mcpDoctorFormat, "format", "f", agentFormatPlain, "Output format: plain, json") + mcpDoctorCmd.Flags().BoolVar(&mcpDoctorNoHandshake, "no-handshake", false, "Skip spawning MCP servers for a live handshake check") + mcpDoctorCmd.Flags().DurationVar(&mcpDoctorTimeout, "timeout", install.DefaultHandshakeTimeout, "Timeout for the live handshake check") +} + +func runMCPDoctor(cmd *cobra.Command, _ []string) error { + switch mcpDoctorFormat { + case agentFormatPlain, agentFormatJSON: + default: + return fmt.Errorf("invalid --format value %q: must be plain or json", mcpDoctorFormat) + } + + report := install.RunDoctor(install.DoctorOptions{ + Handshake: !mcpDoctorNoHandshake, + Timeout: mcpDoctorTimeout, + }) + + switch mcpDoctorFormat { + case agentFormatJSON: + if err := printMCPDoctorJSON(cmd, report); err != nil { + return err + } + default: + printMCPDoctorPlain(cmd, report) + } + + if report.HasFailures() { + return fmt.Errorf("mcp doctor found failing checks — see output above") + } + return nil +} + +func printMCPDoctorJSON(cmd *cobra.Command, report *install.DoctorReport) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(report) +} + +func printMCPDoctorPlain(cmd *cobra.Command, report *install.DoctorReport) { + out := cmd.ErrOrStderr() + + if len(report.Checks) == 0 { + _, _ = fmt.Fprintln(out, "No checks produced any output.") + return + } + + accessible := !cli.ColorsEnabled() + var lastComponent string + for _, c := range report.Checks { + if c.Component != lastComponent { + _, _ = fmt.Fprintf(out, "%s:\n", c.Component) + lastComponent = c.Component + } + _, _ = fmt.Fprintf(out, " %s %-20s %s\n", statusSymbol(c.Status, accessible), c.Name, c.Detail) + } +} + +// statusSymbol renders a check's status, matching the color/theme handling +// (cli.ColorsEnabled) and ASCII fallback used by the rest of the install/ +// uninstall output (see install_interactive.go, uninstall.go) so `mcp doctor` +// doesn't diverge from the CLI's centralized styling. +func statusSymbol(s install.CheckStatus, accessible bool) string { + if accessible { + switch s { + case install.StatusOK: + return "[OK]" + case install.StatusWarn: + return "[WARN]" + default: + return "[FAIL]" + } + } + switch s { + case install.StatusOK: + return lipgloss.NewStyle().Foreground(cmdutil.BrandSuccess).Render("✓") + case install.StatusWarn: + return lipgloss.NewStyle().Foreground(cmdutil.BrandWarn).Render("⚠") + default: + return lipgloss.NewStyle().Foreground(cmdutil.BrandError).Render("✗") + } +} diff --git a/internal/cmd/mcp_update.go b/internal/cmd/mcp_update.go new file mode 100644 index 0000000..9960626 --- /dev/null +++ b/internal/cmd/mcp_update.go @@ -0,0 +1,162 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "sort" + "strings" + + "github.com/ArmisSecurity/armis-cli/internal/install" + "github.com/spf13/cobra" +) + +var mcpUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update the installed MCP servers to the latest version", + Long: `Update the Armis AppSec MCP server, and every editor it's registered in, to +the latest version. + +Reads the install manifest written by 'armis-cli install' to find out what's +already registered — no editor names needed. Armis Knowledge is updated too +if it was previously installed, or if --with-knowledge is passed.`, + Example: ` # Update everything the manifest knows about + armis-cli mcp update + + # Also install/update Armis Knowledge, even if it wasn't set up before + armis-cli mcp update --with-knowledge + + # Force a reinstall even if already current + armis-cli mcp update --force`, + Args: cobra.NoArgs, + RunE: runMCPUpdate, +} + +func init() { + mcpCmd.AddCommand(mcpUpdateCmd) + mcpUpdateCmd.Flags().Bool("force", false, "Force reinstall even if already up to date") + mcpUpdateCmd.Flags().Bool("with-knowledge", false, "Also update Armis Knowledge for the same targets, even if not previously installed") +} + +func runMCPUpdate(cmd *cobra.Command, _ []string) error { + force, err := cmd.Flags().GetBool("force") + if err != nil { + return fmt.Errorf("reading --force flag: %w", err) + } + withKnowledgeFlag, err := cmd.Flags().GetBool("with-knowledge") + if err != nil { + return fmt.Errorf("reading --with-knowledge flag: %w", err) + } + + ei := install.NewEditorInstaller() + manifest := install.ReadManifest(ei.PluginDir()) + if manifest == nil { + return fmt.Errorf("Armis AppSec MCP server is not installed — run: armis-cli install") //nolint:staticcheck // proper noun + } + + fmt.Fprintln(os.Stderr, "Checking for updates...") + if err := ei.FetchPlugin(force); err != nil { + if errors.Is(err, install.ErrAlreadyCurrent) { + fmt.Fprintf(os.Stderr, "Armis AppSec MCP server v%s is already up to date.\n\n", ei.InstalledVersion()) + } else { + return fmt.Errorf("update failed: %w", err) + } + } else { + fmt.Fprintf(os.Stderr, "MCP server updated to v%s.\n\n", ei.InstalledVersion()) + } + manifest.PluginVersion = ei.InstalledVersion() + + var registered []string + var failed []string + var kt knowledgeTargets + + editorIDs := make([]install.EditorID, 0, len(manifest.Editors)) + for id := range manifest.Editors { + editorIDs = append(editorIDs, id) + } + sort.Slice(editorIDs, func(i, j int) bool { return editorIDs[i] < editorIDs[j] }) + + for _, id := range editorIDs { + e, ok := install.EditorByID(id) + if !ok { + fmt.Fprintf(os.Stderr, " ⚠ %s: no longer supported by this CLI version — skipping\n", id) + failed = append(failed, string(id)) + continue + } + if err := e.Register(ei.PluginDir()); err != nil { + fmt.Fprintf(os.Stderr, " ✗ %s: %v\n", e.Name, err) + failed = append(failed, e.Name) + } else { + fmt.Fprintf(os.Stderr, " ✓ %s\n", e.Name) + registered = append(registered, e.Name) + manifest.AddEditor(e.ID, e.ConfigPath(), install.ConfigFormat(e.ID)) + kt.editors = append(kt.editors, e) + } + + if hc, ok := install.HookClientByID(install.HookClientID(id)); ok { + if err := install.InstallNativeHook(hc, ei.PluginDir()); err != nil { + fmt.Fprintf(os.Stderr, " ⚠ %s (hooks): %v\n", e.Name, err) + } + } + } + + if manifest.Claude != nil { + ci, ciErr := install.NewClaudeInstaller() + if ciErr != nil { + fmt.Fprintf(os.Stderr, " ✗ Claude Code: %v\n", ciErr) + failed = append(failed, "Claude Code") + } else if err := ci.Install(); err != nil { + fmt.Fprintf(os.Stderr, " ✗ Claude Code: %v\n", err) + failed = append(failed, "Claude Code") + } else { + fmt.Fprintf(os.Stderr, " ✓ Claude Code v%s\n", ci.InstalledVersion()) + registered = append(registered, "Claude Code") + manifest.SetClaude(ci.PluginCacheDir()) + kt.claude = true + } + } + + if manifest.Codex != nil { + if err := install.RegisterCodexMCP(ei.PluginDir()); err != nil { + fmt.Fprintf(os.Stderr, " ✗ Codex CLI (MCP): %v\n", err) + failed = append(failed, "Codex CLI") + } else { + fmt.Fprintf(os.Stderr, " ✓ Codex CLI (MCP)\n") + registered = append(registered, "Codex CLI") + manifest.SetCodex(install.CodexConfigPath()) + kt.codex = true + } + if hc, ok := install.HookClientByID(install.HookClientCodex); ok { + if err := install.InstallNativeHook(hc, ei.PluginDir()); err != nil { + fmt.Fprintf(os.Stderr, " ⚠ Codex CLI (hooks): %v\n", err) + } else { + fmt.Fprintf(os.Stderr, " ✓ Codex CLI (hooks)\n") + } + } + } + + withKnowledge := withKnowledgeFlag || manifest.Knowledge != nil + var kres knowledgeResult + if withKnowledge { + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Updating Armis Knowledge...") + kres = installKnowledgeFor(kt, force, manifest) + } + + if err := install.WriteManifest(manifest); err != nil { + fmt.Fprintf(os.Stderr, " ⚠ Could not write install manifest: %v\n", err) + } + + fmt.Fprintln(os.Stderr, "") + if len(registered) > 0 { + fmt.Fprintf(os.Stderr, "Updated: %s\n", strings.Join(registered, ", ")) + } + if len(failed) > 0 { + fmt.Fprintf(os.Stderr, "Failed: %s\n", strings.Join(failed, ", ")) + } + if withKnowledge { + printKnowledgeResult(kres) + } + + return nil +} diff --git a/internal/cmd/mcp_update_test.go b/internal/cmd/mcp_update_test.go new file mode 100644 index 0000000..33d4792 --- /dev/null +++ b/internal/cmd/mcp_update_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "strings" + "testing" +) + +// TestRunMCPUpdateNoManifest pins the guard that stops runMCPUpdate before it +// ever calls FetchPlugin (a real network call) when nothing is installed yet. +func TestRunMCPUpdateNoManifest(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + err := runMCPUpdate(mcpUpdateCmd, nil) + if err == nil { + t.Fatal("expected error when no manifest is present") + } + if !strings.Contains(err.Error(), "armis-cli install") { + t.Errorf("error should point at 'armis-cli install', got: %v", err) + } +} + +func TestMCPUpdateHasForceFlag(t *testing.T) { + f := mcpUpdateCmd.Flags().Lookup("force") + if f == nil { + t.Fatal("mcp update command is missing the --force flag") + } + if f.DefValue != "false" { + t.Errorf("--force default = %q, want false", f.DefValue) + } +} + +func TestMCPUpdateHasWithKnowledgeFlag(t *testing.T) { + f := mcpUpdateCmd.Flags().Lookup("with-knowledge") + if f == nil { + t.Fatal("mcp update command is missing the --with-knowledge flag") + } + if f.DefValue != "false" { + t.Errorf("--with-knowledge default = %q, want false (only auto-enabled via the manifest)", f.DefValue) + } +} + +func TestMCPUpdateRegisteredUnderMCPCommand(t *testing.T) { + for _, c := range mcpCmd.Commands() { + if c.Name() == "update" { + return + } + } + t.Fatal("'update' is not registered under the 'mcp' command") +} diff --git a/internal/install/doctor.go b/internal/install/doctor.go new file mode 100644 index 0000000..5f3019b --- /dev/null +++ b/internal/install/doctor.go @@ -0,0 +1,637 @@ +package install + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// maxHandshakeLineSize bounds a single line read from a spawned MCP server's +// stdout during the doctor's live handshake — generous for a JSON-RPC +// initialize response, but small enough to stop a broken or hostile server +// process from growing the read buffer without limit (CWE-770). +const maxHandshakeLineSize = 1 << 20 // 1 MB + +// knowledgeJSONIdentifier and knowledgeCodexIdentifier are the substrings used +// to spot a knowledge-bridge entry in an editor config, mirroring how +// mcpServerName/codexMCPServerName identify the scanner. The knowledge server +// name carries an env suffix (e.g. "armis-knowledge-dev"), so exact-key checks +// don't work here — a substring match does, the same way agentdetect matches +// the scanner's identifier. +const ( + knowledgeJSONIdentifier = "armis-knowledge" + knowledgeCodexIdentifier = "armis_knowledge" +) + +// DefaultHandshakeTimeout bounds how long RunDoctor waits for an MCP server to +// answer the initialize handshake before reporting it as unresponsive. +const DefaultHandshakeTimeout = 10 * time.Second + +// CheckStatus is the outcome of a single doctor check. +type CheckStatus string + +const ( + StatusOK CheckStatus = "ok" + StatusWarn CheckStatus = "warn" + StatusFail CheckStatus = "fail" +) + +// DoctorCheck is one diagnostic result reported by RunDoctor. +type DoctorCheck struct { + Component string `json:"component"` + Name string `json:"name"` + Status CheckStatus `json:"status"` + Detail string `json:"detail"` +} + +// DoctorReport is the full set of diagnostic results from RunDoctor. +type DoctorReport struct { + Checks []DoctorCheck `json:"checks"` +} + +func (r *DoctorReport) add(component, name string, status CheckStatus, detail string) { + r.Checks = append(r.Checks, DoctorCheck{Component: component, Name: name, Status: status, Detail: detail}) +} + +// HasFailures reports whether any check in the report failed. +func (r *DoctorReport) HasFailures() bool { + for _, c := range r.Checks { + if c.Status == StatusFail { + return true + } + } + return false +} + +// DoctorOptions configures RunDoctor. +type DoctorOptions struct { + // Handshake, when true, spawns each registered MCP server and performs a + // live JSON-RPC initialize handshake over stdio. + Handshake bool + // Timeout bounds how long the handshake waits for a response. Defaults to + // DefaultHandshakeTimeout when zero. + Timeout time.Duration +} + +// RunDoctor inspects everything armis-cli install may have registered — the +// shared scanner plugin, the knowledge bridge, and every editor config +// recorded in the install manifest — and, when requested, spawns each MCP +// server to confirm it actually answers a protocol handshake. +func RunDoctor(opts DoctorOptions) *DoctorReport { + report := &DoctorReport{} + + ei := NewEditorInstaller() + manifest := ReadManifest(ei.PluginDir()) + if manifest == nil { + report.add("install", "manifest", StatusFail, + fmt.Sprintf("no install manifest found at %s — run: armis-cli install", ei.PluginDir())) + return report + } + + checkScannerPlugin(report, ei, opts) + checkManifestEditors(report, "scanner", mcpServerName, manifest.Editors) + checkClaudeSection(report, "scanner", manifest.Claude, pluginName) + checkCodexSection(report, "scanner", manifest.Codex, codexMCPServerName) + + if manifest.Knowledge != nil { + checkKnowledgePlugin(report, manifest.Knowledge, opts) + checkManifestEditors(report, "knowledge", knowledgeJSONIdentifier, manifest.Knowledge.Editors) + checkClaudeSection(report, "knowledge", manifest.Knowledge.Claude, knowledgeJSONIdentifier) + checkCodexSection(report, "knowledge", manifest.Knowledge.Codex, knowledgeCodexIdentifier) + } + + return report +} + +func checkScannerPlugin(report *DoctorReport, ei *EditorInstaller, opts DoctorOptions) { + const component = "scanner" + + if v := ei.GetInstalledVersion(); v == "" { + report.add(component, "plugin version", StatusWarn, "no installed version recorded") + } else { + report.add(component, "plugin version", StatusOK, "v"+v) + } + + pythonPath := venvPython(ei.PluginDir()) + if !isExecutableFile(pythonPath) { + report.add(component, "python venv", StatusFail, fmt.Sprintf("missing or not executable: %s", pythonPath)) + return + } + report.add(component, "python venv", StatusOK, pythonPath) + + serverPy := filepath.Join(ei.PluginDir(), "server.py") + if _, err := os.Stat(serverPy); err != nil { + report.add(component, "server script", StatusFail, fmt.Sprintf("missing: %s", serverPy)) + return + } + report.add(component, "server script", StatusOK, serverPy) + + env := checkCredentials(report, component, ei.EnvFilePath()) + + if opts.Handshake { + runHandshakeCheck(report, component, pythonPath, []string{serverPy}, env, opts.Timeout) + } +} + +func checkKnowledgePlugin(report *DoctorReport, k *ManifestKnowledge, opts DoctorOptions) { + const component = "knowledge" + + if k.SHA != "" { + report.add(component, "bridge commit", StatusOK, k.SHA) + } + + found := false + venvFound := false + for _, sub := range []string{"prod", "stage", "dev"} { + envDir := filepath.Join(k.PluginDir, sub) + bridge := filepath.Join(envDir, "bridge.py") + if _, err := os.Stat(bridge); err != nil { + continue + } + found = true + + // Fetch extracts the whole knowledge repo (see EnvDir's doc comment), + // so every environment's bridge.py lands on disk even though only the + // one the user actually chose gets a venv (createPluginVenv is only + // called for that EnvDir). A sibling env with bridge.py but no .venv/ + // at all was never installed here — skip it rather than reporting a + // false failure; only flag a venv as broken once it was set up. + if _, err := os.Stat(filepath.Join(envDir, ".venv")); err != nil { + continue + } + venvFound = true + + subComponent := component + " " + sub + pythonPath := venvPython(envDir) + if !isExecutableFile(pythonPath) { + report.add(subComponent, "python venv", StatusFail, fmt.Sprintf("missing or not executable: %s", pythonPath)) + continue + } + report.add(subComponent, "python venv", StatusOK, pythonPath) + + env := checkCredentials(report, subComponent, filepath.Join(envDir, ".env")) + + if opts.Handshake { + runHandshakeCheck(report, subComponent, pythonPath, []string{bridge}, env, opts.Timeout) + } + } + switch { + case !found: + report.add(component, "bridge", StatusFail, fmt.Sprintf("no bridge.py found under %s", k.PluginDir)) + case !venvFound: + report.add(component, "python venv", StatusFail, fmt.Sprintf("bridge.py found under %s but no environment has a .venv — install may have failed", k.PluginDir)) + } +} + +// checkCredentials validates envFile carries both required credentials and +// returns its contents for reuse by a following live handshake. +func checkCredentials(report *DoctorReport, component, envFile string) map[string]string { + env, err := parseEnvFile(envFile) + if err != nil { + report.add(component, "credentials", StatusWarn, fmt.Sprintf("%s: %v", envFile, err)) + return env + } + if env["ARMIS_CLIENT_ID"] == "" || env["ARMIS_CLIENT_SECRET"] == "" { + report.add(component, "credentials", StatusWarn, + fmt.Sprintf("ARMIS_CLIENT_ID/ARMIS_CLIENT_SECRET not set in %s", envFile)) + return env + } + report.add(component, "credentials", StatusOK, "configured") + return env +} + +// checkManifestEditors verifies, for every editor the manifest recorded a +// registration for, that the config file still exists, still contains an +// entry matching identifier, and that the entry's command still exists on +// disk. That last check matters most on Windows, where a profile rename, a +// drive-letter change, or a reinstall into a new plugin dir leaves editors +// pointing at a command path that no longer resolves — the entry is still +// present by name, so a name-only check would report this as healthy. +func checkManifestEditors(report *DoctorReport, component, identifier string, editors map[EditorID]ManifestEntry) { + for id, entry := range editors { + name := string(id) + if ed, ok := EditorByID(id); ok { + name = ed.Name + } + + // readBoundedConfigFile applies the same regular-file and size guards as + // readJSONFileAsMap/readYAMLFileAsMap, so a non-regular or oversized + // config is reported here rather than silently read as empty by + // lookupEntryCommand below and misreported as "entry not found". + content, err := readBoundedConfigFile(entry.ConfigFile) + if err != nil { + report.add(component, name, StatusFail, fmt.Sprintf("config file %s: %v", entry.ConfigFile, err)) + continue + } + // readJSONFileAsMap/readYAMLFileAsMap also return an empty map on a + // parse error, so a corrupted or non-object config (null, an array, + // invalid YAML, ...) would otherwise fall through to the same "entry + // not found" warning as a genuinely edited-out entry. Catch that case + // explicitly. + var obj map[string]interface{} + var parseErr error + if entry.Format == configFormatContinue { + parseErr = yaml.Unmarshal(content, &obj) + } else { + parseErr = json.Unmarshal(content, &obj) + } + // Both unmarshalers accept a top-level `null` without error (obj just + // stays nil), so an err-only check would miss it — require a non-nil + // object too. + if parseErr == nil && obj == nil { + parseErr = fmt.Errorf("top-level value is not an object") + } + if parseErr != nil { + report.add(component, name, StatusFail, fmt.Sprintf("config file %s is not valid: %v", entry.ConfigFile, parseErr)) + continue + } + + command, found := lookupEntryCommand(entry.ConfigFile, entry.Format, identifier) + if !found { + report.add(component, name, StatusWarn, + fmt.Sprintf("registered at %s but entry not found — was it edited or removed?", entry.ConfigFile)) + continue + } + if command != "" && !isExecutableFile(command) { + report.add(component, name, StatusFail, + fmt.Sprintf("entry found in %s but its command does not exist: %s — likely stale after a reinstall or profile/home directory change; re-run armis-cli install", entry.ConfigFile, command)) + continue + } + report.add(component, name, StatusOK, entry.ConfigFile) + } +} + +func checkClaudeSection(report *DoctorReport, component string, claude *ManifestClaude, pluginKeyPrefix string) { + if claude == nil { + return + } + if _, err := os.Stat(claude.CacheDir); err != nil { + report.add(component, "Claude Code", StatusFail, fmt.Sprintf("cache dir missing: %s", claude.CacheDir)) + return + } + + installed, enabled := claudeRegistryStatus(homeDir(".claude"), pluginKeyPrefix) + switch { + case !installed: + report.add(component, "Claude Code", StatusWarn, "not found in installed_plugins.json — re-run install") + case !enabled: + report.add(component, "Claude Code", StatusWarn, "installed but not enabled in settings.json") + default: + report.add(component, "Claude Code", StatusOK, claude.CacheDir) + } +} + +// readBoundedConfigFile reads path the same way editors.go's +// readJSONFileAsMap/readYAMLFileAsMap do: reject non-regular files (devices, +// FIFOs, symlinks to either) and cap the size at maxEditorConfigSize before +// reading, so a doctor check can't block or exhaust memory on a config path +// that isn't the plain file it's expected to be (CWE-770). +func readBoundedConfigFile(path string) ([]byte, error) { + clean := filepath.Clean(path) + info, err := os.Stat(clean) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("%s is not a regular file", clean) + } + if info.Size() > maxEditorConfigSize { + return nil, fmt.Errorf("%s exceeds %d bytes", clean, maxEditorConfigSize) + } + // armis:ignore cwe:22 cwe:770 reason:path from the install manifest/known config locations; regular-file and size checks above bound the read + return os.ReadFile(clean) //nolint:gosec +} + +// claudeRegistryStatus reports whether any plugin key containing +// pluginKeyPrefix is recorded as installed and/or enabled in Claude Code's +// own registry files. +func claudeRegistryStatus(claudeDir, pluginKeyPrefix string) (installed, enabled bool) { + if claudeDir == "" { + return false, false + } + prefix := strings.ToLower(pluginKeyPrefix) + + instFile := filepath.Join(claudeDir, "plugins", "installed_plugins.json") + if b, err := readBoundedConfigFile(instFile); err == nil { + var data struct { + Plugins map[string]json.RawMessage `json:"plugins"` + } + if json.Unmarshal(b, &data) == nil { + for k := range data.Plugins { + if strings.Contains(strings.ToLower(k), prefix) { + installed = true + break + } + } + } + } + + settingsFile := filepath.Join(claudeDir, "settings.json") + if b, err := readBoundedConfigFile(settingsFile); err == nil { + var data struct { + EnabledPlugins map[string]bool `json:"enabledPlugins"` + } + if json.Unmarshal(b, &data) == nil { + for k, v := range data.EnabledPlugins { + if v && strings.Contains(strings.ToLower(k), prefix) { + enabled = true + break + } + } + } + } + return installed, enabled +} + +func checkCodexSection(report *DoctorReport, component string, codex *ManifestCodex, identifier string) { + if codex == nil { + return + } + content, err := readBoundedConfigFile(codex.ConfigFile) + if err != nil { + report.add(component, "Codex CLI", StatusFail, fmt.Sprintf("config file %s: %v", codex.ConfigFile, err)) + return + } + if !strings.Contains(strings.ToLower(string(content)), strings.ToLower(identifier)) { + report.add(component, "Codex CLI", StatusWarn, + fmt.Sprintf("registered at %s but entry not found — was it edited or removed?", codex.ConfigFile)) + return + } + report.add(component, "Codex CLI", StatusOK, codex.ConfigFile) +} + +// lookupEntryCommand finds the server entry matching identifier in configFile +// (read per the manifest's recorded format) and returns the command path it +// declares. found is true as soon as a matching entry name exists, even when +// command comes back empty because the format stores it somewhere this +// function doesn't understand — callers must treat an empty command as +// "unknown", not "missing". +func lookupEntryCommand(configFile, format, identifier string) (command string, found bool) { + identifier = strings.ToLower(identifier) + + matchEntry := func(servers map[string]interface{}) (map[string]interface{}, bool) { + for k, v := range servers { + if strings.Contains(strings.ToLower(k), identifier) { + m, _ := v.(map[string]interface{}) + return m, true + } + } + return nil, false + } + + switch format { + case configFormatVSCode: + servers, _ := readJSONFileAsMap(configFile)["servers"].(map[string]interface{}) + entry, ok := matchEntry(servers) + if !ok { + return "", false + } + cmd, _ := entry[jsonKeyCommand].(string) + return cmd, true + case configFormatZed: + servers, _ := readJSONFileAsMap(configFile)["context_servers"].(map[string]interface{}) + entry, ok := matchEntry(servers) + if !ok { + return "", false + } + cmdObj, _ := entry[jsonKeyCommand].(map[string]interface{}) + cmd, _ := cmdObj[jsonKeyPath].(string) + return cmd, true + case configFormatContinue: + list, _ := readYAMLFileAsMap(configFile)["mcpServers"].([]interface{}) + for _, item := range list { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + if n, _ := m["name"].(string); strings.Contains(strings.ToLower(n), identifier) { + cmd, _ := m[jsonKeyCommand].(string) + return cmd, true + } + } + return "", false + default: // "mcpServers" + servers, _ := readJSONFileAsMap(configFile)["mcpServers"].(map[string]interface{}) + entry, ok := matchEntry(servers) + if !ok { + return "", false + } + cmd, _ := entry[jsonKeyCommand].(string) + return cmd, true + } +} + +func isExecutableFile(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + if runtime.GOOS == osWindows { + return true + } + return info.Mode()&0o111 != 0 +} + +// parseEnvFile reads a "KEY=VALUE" per line .env file, as written by +// writeEnvFromEnvironment/WriteEnvFromValues. +func parseEnvFile(path string) (map[string]string, error) { + b, err := readBoundedConfigFile(path) + if err != nil { + return nil, err + } + env := make(map[string]string) + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + env[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + return env, nil +} + +// handshakeResult carries the identity the MCP server reported in its +// initialize response. +type handshakeResult struct { + ServerName string + ServerVersion string +} + +type mcpInitResult struct { + ServerInfo struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"serverInfo"` +} + +func runHandshakeCheck(report *DoctorReport, component, command string, args []string, env map[string]string, timeout time.Duration) { + res, stderrTail, err := mcpHandshake(command, args, env, timeout) + if err != nil { + detail := err.Error() + if stderrTail != "" { + detail += " — stderr: " + stderrTail + } + report.add(component, "live handshake", StatusFail, detail) + return + } + detail := "responded to initialize" + if res.ServerName != "" { + detail = res.ServerName + " responded" + if res.ServerVersion != "" { + detail = fmt.Sprintf("%s v%s responded", res.ServerName, res.ServerVersion) + } + } + report.add(component, "live handshake", StatusOK, detail) +} + +// mcpHandshake spawns command as an MCP stdio server, sends a single +// "initialize" JSON-RPC request, and waits up to timeout for a response line. +// The process is always killed and waited-on before returning, so stderr can +// be read back safely (os/exec only finishes copying stderr into the buffer +// once Wait returns). +func mcpHandshake(command string, args []string, env map[string]string, timeout time.Duration) (*handshakeResult, string, error) { + if timeout <= 0 { + timeout = DefaultHandshakeTimeout + } + + // armis:ignore cwe:78 cwe:88 reason:command/args come from the CLI's own recorded install paths (venv interpreter + server script), not user input + cmd := exec.Command(command, args...) //nolint:gosec // command/args are the CLI's own recorded install paths + cmd.Env = os.Environ() + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, "", fmt.Errorf("opening stdin: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + _ = stdin.Close() + return nil, "", fmt.Errorf("opening stdout: %w", err) + } + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + // Start() failing means Wait() will never run to close these pipes for us + // (that cleanup is documented as conditional on a successful Start), so + // close them ourselves rather than leaking the file descriptors. + if err := cmd.Start(); err != nil { + _ = stdin.Close() + _ = stdout.Close() + return nil, "", fmt.Errorf("starting process: %w", err) + } + + result, opErr := communicateInitialize(stdin, stdout, timeout) + + // armis:ignore cwe:404 reason:best-effort cleanup of a short-lived diagnostic subprocess we just spawned + _ = cmd.Process.Kill() + // On the timeout path, communicateInitialize's reader goroutine may still + // be blocked reading stdout when we get here. os/exec's docs warn it is + // "incorrect to call Wait before all reads from the pipe have completed" + // because Wait closes this same pipe as part of its own cleanup — close + // it here first so the unblock is explicit and ordered rather than racing + // Wait's internal close. + _ = stdout.Close() + _ = stdin.Close() + _ = cmd.Wait() + + if opErr != nil { + return nil, stderrTail(&stderrBuf), opErr + } + return result, "", nil +} + +func communicateInitialize(stdin io.WriteCloser, stdout io.ReadCloser, timeout time.Duration) (*handshakeResult, error) { + req := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]interface{}{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]interface{}{}, + "clientInfo": map[string]interface{}{"name": "armis-cli-doctor", "version": "1.0"}, + }, + } + line, err := json.Marshal(req) + if err != nil { + return nil, err + } + + type readOutcome struct { + line []byte + err error + } + lineCh := make(chan readOutcome, 1) + go func() { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), maxHandshakeLineSize) + if scanner.Scan() { + lineCh <- readOutcome{append([]byte(nil), scanner.Bytes()...), nil} + return + } + lineCh <- readOutcome{nil, scanner.Err()} + }() + + if _, err := stdin.Write(append(line, '\n')); err != nil { + return nil, fmt.Errorf("writing initialize request: %w", err) + } + + select { + case <-time.After(timeout): + return nil, fmt.Errorf("timed out waiting for response after %s", timeout) + case out := <-lineCh: + if len(out.line) == 0 { + if errors.Is(out.err, bufio.ErrTooLong) { + return nil, fmt.Errorf("response exceeded %d bytes", maxHandshakeLineSize) + } + if out.err != nil { + return nil, fmt.Errorf("no response: %w", out.err) + } + return nil, fmt.Errorf("no response") + } + var rpc struct { + Result *mcpInitResult `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(out.line, &rpc); err != nil { + return nil, fmt.Errorf("invalid response: %w", err) + } + if rpc.Error != nil { + return nil, fmt.Errorf("server returned error: %s", rpc.Error.Message) + } + if rpc.Result == nil { + return nil, fmt.Errorf("response missing result") + } + return &handshakeResult{ + ServerName: rpc.Result.ServerInfo.Name, + ServerVersion: rpc.Result.ServerInfo.Version, + }, nil + } +} + +func stderrTail(buf *bytes.Buffer) string { + s := strings.TrimSpace(buf.String()) + const maxLen = 300 + if len(s) > maxLen { + s = s[len(s)-maxLen:] + } + return s +} diff --git a/internal/install/doctor_test.go b/internal/install/doctor_test.go new file mode 100644 index 0000000..92e2f41 --- /dev/null +++ b/internal/install/doctor_test.go @@ -0,0 +1,399 @@ +package install + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// TestMain intercepts re-exec calls used by the mcpHandshake tests: when +// ARMIS_TEST_MCP_HELPER is set, this process acts as a fake MCP stdio server +// instead of running the test suite. +func TestMain(m *testing.M) { + if mode := os.Getenv("ARMIS_TEST_MCP_HELPER"); mode != "" { + runMCPHelperProcess(mode) + return + } + os.Exit(m.Run()) +} + +func runMCPHelperProcess(mode string) { + switch mode { + case "hang": + select {} + case "garbage": + _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') + _, _ = fmt.Fprintln(os.Stdout, "not json") + case "error": + _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') + _, _ = fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"boom"}}`) + default: // "ok" + _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') + _, _ = fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"fake-mcp","version":"9.9.9"}}}`) + } +} + +func TestMCPHandshakeSuccess(t *testing.T) { + res, tail, err := mcpHandshake(os.Args[0], nil, map[string]string{"ARMIS_TEST_MCP_HELPER": "ok"}, 5*time.Second) + if err != nil { + t.Fatalf("mcpHandshake() error = %v (stderr: %s)", err, tail) + } + if res.ServerName != "fake-mcp" || res.ServerVersion != "9.9.9" { + t.Errorf("mcpHandshake() result = %+v, want fake-mcp v9.9.9", res) + } +} + +func TestMCPHandshakeServerError(t *testing.T) { + _, _, err := mcpHandshake(os.Args[0], nil, map[string]string{"ARMIS_TEST_MCP_HELPER": "error"}, 5*time.Second) + if err == nil { + t.Fatal("mcpHandshake() error = nil, want error from server's JSON-RPC error response") + } +} + +func TestMCPHandshakeInvalidResponse(t *testing.T) { + _, _, err := mcpHandshake(os.Args[0], nil, map[string]string{"ARMIS_TEST_MCP_HELPER": "garbage"}, 5*time.Second) + if err == nil { + t.Fatal("mcpHandshake() error = nil, want error from invalid JSON response") + } +} + +func TestMCPHandshakeTimeout(t *testing.T) { + _, _, err := mcpHandshake(os.Args[0], nil, map[string]string{"ARMIS_TEST_MCP_HELPER": "hang"}, 300*time.Millisecond) + if err == nil { + t.Fatal("mcpHandshake() error = nil, want timeout error") + } +} + +func TestParseEnvFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + content := "ARMIS_CLIENT_ID=abc123\nARMIS_CLIENT_SECRET=s3cr3t\n# comment\n\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + env, err := parseEnvFile(path) + if err != nil { + t.Fatalf("parseEnvFile() error = %v", err) + } + if env["ARMIS_CLIENT_ID"] != "abc123" || env["ARMIS_CLIENT_SECRET"] != "s3cr3t" { + t.Errorf("parseEnvFile() = %+v, want ARMIS_CLIENT_ID/ARMIS_CLIENT_SECRET set", env) + } +} + +func TestParseEnvFileMissing(t *testing.T) { + if _, err := parseEnvFile(filepath.Join(t.TempDir(), "missing.env")); err == nil { + t.Fatal("parseEnvFile() error = nil, want error for missing file") + } +} + +func TestLookupEntryCommand(t *testing.T) { + dir := t.TempDir() + + tests := []struct { + name string + format string + content string + fileName string + identifier string + wantFound bool + wantCommand string + }{ + { + name: "mcpServers format match", + format: "mcpServers", + content: `{"mcpServers":{"armis-appsec":{"command":"/bin/python"}}}`, + fileName: "mcp.json", + identifier: "armis-appsec", + wantFound: true, + wantCommand: "/bin/python", + }, + { + name: "mcpServers format no match", + format: "mcpServers", + content: `{"mcpServers":{"other":{"command":"/bin/python"}}}`, + fileName: "mcp2.json", + identifier: "armis-appsec", + wantFound: false, + }, + { + name: "vscode-servers format match", + format: configFormatVSCode, + content: `{"servers":{"armis-appsec":{"type":"stdio","command":"/bin/python"}}}`, + fileName: "vscode.json", + identifier: "armis-appsec", + wantFound: true, + wantCommand: "/bin/python", + }, + { + name: "zed-context_servers format match", + format: configFormatZed, + content: `{"context_servers":{"armis-appsec":{"command":{"path":"/bin/python","args":[]}}}}`, + fileName: "zed.json", + identifier: "armis-appsec", + wantFound: true, + wantCommand: "/bin/python", + }, + { + name: "continue-yaml format match", + format: configFormatContinue, + content: "mcpServers:\n - name: armis-knowledge\n command: /bin/python\n", + fileName: "continue.yaml", + identifier: "armis-knowledge", + wantFound: true, + wantCommand: "/bin/python", + }, + { + name: "continue-yaml format no match", + format: configFormatContinue, + content: "mcpServers:\n - name: other\n command: /bin/python\n", + fileName: "continue2.yaml", + identifier: "armis-knowledge", + wantFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(dir, tt.fileName) + if err := os.WriteFile(path, []byte(tt.content), 0o600); err != nil { + t.Fatal(err) + } + gotCommand, gotFound := lookupEntryCommand(path, tt.format, tt.identifier) + if gotFound != tt.wantFound { + t.Errorf("lookupEntryCommand() found = %v, want %v", gotFound, tt.wantFound) + } + if gotCommand != tt.wantCommand { + t.Errorf("lookupEntryCommand() command = %q, want %q", gotCommand, tt.wantCommand) + } + }) + } +} + +func TestCheckManifestEditors(t *testing.T) { + dir := t.TempDir() + + realCommand := filepath.Join(dir, "python") + _ = os.WriteFile(realCommand, []byte("x"), 0o700) // #nosec G306 -- needs exec bit for isExecutableFile checks + + presentFile := filepath.Join(dir, "present.json") + mustWriteJSON(t, presentFile, map[string]interface{}{ + "mcpServers": map[string]interface{}{"armis-appsec": map[string]interface{}{"command": realCommand}}, + }) + + staleFile := filepath.Join(dir, "stale.json") + _ = os.WriteFile(staleFile, []byte(`{"mcpServers":{}}`), 0o600) + + missingFile := filepath.Join(dir, "does-not-exist.json") + + // Simulates the exact bug found by exercising the tool: the entry is + // still present by name (e.g. VS Code's mcp.json survived), but the + // command path it points to no longer exists — the kind of drift a + // Windows profile rename or a reinstall into a new plugin dir causes. + deadCommandFile := filepath.Join(dir, "dead-command.json") + mustWriteJSON(t, deadCommandFile, map[string]interface{}{ + "mcpServers": map[string]interface{}{ + "armis-appsec": map[string]interface{}{ + "command": filepath.Join(dir, "no-longer-exists", "python"), + }, + }, + }) + + editors := map[EditorID]ManifestEntry{ + EditorCursor: {ConfigFile: presentFile, Format: "mcpServers"}, + EditorWindsurf: {ConfigFile: staleFile, Format: "mcpServers"}, + EditorZed: {ConfigFile: missingFile, Format: "mcpServers"}, + EditorVSCode: {ConfigFile: deadCommandFile, Format: "mcpServers"}, + } + + report := &DoctorReport{} + checkManifestEditors(report, "scanner", "armis-appsec", editors) + + statuses := make(map[string]CheckStatus) + for _, c := range report.Checks { + statuses[c.Name] = c.Status + } + + if statuses["Cursor"] != StatusOK { + t.Errorf("Cursor status = %v, want ok", statuses["Cursor"]) + } + if statuses["Windsurf"] != StatusWarn { + t.Errorf("Windsurf status = %v, want warn (entry removed)", statuses["Windsurf"]) + } + if statuses["Zed"] != StatusFail { + t.Errorf("Zed status = %v, want fail (config missing)", statuses["Zed"]) + } + if statuses["VS Code"] != StatusFail { + t.Errorf("VS Code status = %v, want fail (command path dead)", statuses["VS Code"]) + } +} + +func TestClaudeRegistryStatus(t *testing.T) { + dir := t.TempDir() + pluginsDir := filepath.Join(dir, "plugins") + _ = os.MkdirAll(pluginsDir, 0o750) + + mustWriteJSON(t, filepath.Join(pluginsDir, "installed_plugins.json"), map[string]interface{}{ + "plugins": map[string]interface{}{ + "armis-appsec@armis-appsec-mcp": []interface{}{}, + }, + }) + mustWriteJSON(t, filepath.Join(dir, "settings.json"), map[string]interface{}{ + "enabledPlugins": map[string]interface{}{ + "armis-appsec@armis-appsec-mcp": true, + }, + }) + + installed, enabled := claudeRegistryStatus(dir, "armis-appsec") + if !installed || !enabled { + t.Errorf("claudeRegistryStatus() = (%v, %v), want (true, true)", installed, enabled) + } + + installed, enabled = claudeRegistryStatus(dir, "armis-knowledge") + if installed || enabled { + t.Errorf("claudeRegistryStatus() for unrelated prefix = (%v, %v), want (false, false)", installed, enabled) + } +} + +func TestCheckCodexSection(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "config.toml") + _ = os.WriteFile(configFile, []byte("[mcp_servers.armis_scanner]\ncommand = \"/bin/python\"\n"), 0o600) + + report := &DoctorReport{} + checkCodexSection(report, "scanner", &ManifestCodex{ConfigFile: configFile}, "armis_scanner") + if len(report.Checks) != 1 || report.Checks[0].Status != StatusOK { + t.Errorf("checkCodexSection() = %+v, want single ok check", report.Checks) + } + + report2 := &DoctorReport{} + checkCodexSection(report2, "scanner", &ManifestCodex{ConfigFile: configFile}, "armis_knowledge") + if len(report2.Checks) != 1 || report2.Checks[0].Status != StatusWarn { + t.Errorf("checkCodexSection() with missing identifier = %+v, want single warn check", report2.Checks) + } +} + +func TestIsExecutableFile(t *testing.T) { + dir := t.TempDir() + if isExecutableFile(filepath.Join(dir, "missing")) { + t.Error("isExecutableFile() = true for a missing file") + } + + regular := filepath.Join(dir, "not-executable") + _ = os.WriteFile(regular, []byte("x"), 0o600) + if runtime.GOOS != osWindows && isExecutableFile(regular) { + t.Error("isExecutableFile() = true for a non-executable file") + } + + executable := filepath.Join(dir, "executable") + _ = os.WriteFile(executable, []byte("x"), 0o700) // #nosec G306 -- needs exec bit for isExecutableFile checks + if !isExecutableFile(executable) { + t.Error("isExecutableFile() = false for an executable file") + } +} + +func TestRunDoctorNoManifest(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + report := RunDoctor(DoctorOptions{Handshake: false}) + if !report.HasFailures() { + t.Fatal("RunDoctor() with no manifest should report a failure") + } + if len(report.Checks) != 1 || report.Checks[0].Component != "install" { + t.Errorf("RunDoctor() checks = %+v, want a single install/manifest failure", report.Checks) + } +} + +func TestRunDoctorStructuralChecks(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + pluginDir := filepath.Join(home, ".armis", "plugins", "armis-appsec-mcp") + writeFakeVenv(t, pluginDir) + _ = os.WriteFile(filepath.Join(pluginDir, "server.py"), []byte("# server"), 0o600) + _ = os.WriteFile(filepath.Join(pluginDir, ".env"), + []byte("ARMIS_CLIENT_ID=id\nARMIS_CLIENT_SECRET=secret\n"), 0o600) + + editorConfig := filepath.Join(home, "editor-mcp.json") + _ = os.WriteFile(editorConfig, []byte(`{"mcpServers":{"armis-appsec":{}}}`), 0o600) + + manifest := NewManifest(pluginDir, "1.2.3") + manifest.AddEditor(EditorCursor, editorConfig, "mcpServers") + if err := WriteManifest(manifest); err != nil { + t.Fatalf("WriteManifest() error = %v", err) + } + + report := RunDoctor(DoctorOptions{Handshake: false}) + if report.HasFailures() { + t.Fatalf("RunDoctor() unexpected failures: %+v", report.Checks) + } + + found := false + for _, c := range report.Checks { + if c.Component == "scanner" && c.Name == "Cursor" && c.Status == StatusOK { + found = true + } + } + if !found { + t.Errorf("RunDoctor() checks = %+v, want ok check for scanner/Cursor", report.Checks) + } +} + +// TestCheckKnowledgePluginSkipsUninstalledSiblingEnv pins the fix for a false +// failure: Fetch extracts the whole knowledge repo, so every env's bridge.py +// (prod/stage/dev) lands on disk even though only the chosen env gets a venv. +// A sibling env with bridge.py but no .venv/ was never installed and must not +// be reported as a failure. +func TestCheckKnowledgePluginSkipsUninstalledSiblingEnv(t *testing.T) { + dir := t.TempDir() + + // "prod" is the env the user actually installed: bridge.py + a real venv. + writeFakeVenv(t, filepath.Join(dir, "prod")) + _ = os.WriteFile(filepath.Join(dir, "prod", "bridge.py"), []byte("# bridge"), 0o600) + + // "dev" is a sibling extracted alongside it, with no venv ever created. + _ = os.MkdirAll(filepath.Join(dir, "dev"), 0o750) + _ = os.WriteFile(filepath.Join(dir, "dev", "bridge.py"), []byte("# bridge"), 0o600) + + report := &DoctorReport{} + checkKnowledgePlugin(report, &ManifestKnowledge{PluginDir: dir}, DoctorOptions{Handshake: false}) + + if report.HasFailures() { + t.Errorf("checkKnowledgePlugin() unexpected failures for uninstalled sibling env: %+v", report.Checks) + } + + var sawProdOK bool + for _, c := range report.Checks { + if c.Component == "knowledge prod" && c.Name == "python venv" && c.Status == StatusOK { + sawProdOK = true + } + if c.Component == "knowledge dev" || strings.HasPrefix(c.Name, "dev ") { + t.Errorf("checkKnowledgePlugin() reported a check for uninstalled sibling env dev: %+v", c) + } + } + if !sawProdOK { + t.Errorf("checkKnowledgePlugin() checks = %+v, want ok check for knowledge prod / python venv", report.Checks) + } +} + +// writeFakeVenv creates a fake venv python executable so structural checks +// (which only stat for existence + executable bit) pass without a real +// Python install. +func writeFakeVenv(t *testing.T, pluginDir string) { + t.Helper() + python := venvPython(pluginDir) + if err := os.MkdirAll(filepath.Dir(python), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(python, []byte("#!/bin/sh\n"), 0o700); err != nil { // #nosec G306 -- needs exec bit to run as fake venv python + t.Fatal(err) + } +} diff --git a/internal/install/editors.go b/internal/install/editors.go index 0dc534c..4c4890d 100644 --- a/internal/install/editors.go +++ b/internal/install/editors.go @@ -32,6 +32,13 @@ const ( jsonTypeCommand = "command" ) +// Config format identifiers returned by ConfigFormat, keyed by editor. +const ( + configFormatVSCode = "vscode-servers" + configFormatZed = "zed-context_servers" + configFormatContinue = "continue-yaml" +) + // EditorID identifies a supported editor. type EditorID string diff --git a/internal/install/manifest.go b/internal/install/manifest.go index 2084bf5..859272f 100644 --- a/internal/install/manifest.go +++ b/internal/install/manifest.go @@ -170,11 +170,11 @@ func (k *ManifestKnowledge) SetCodex(configFile string) { func ConfigFormat(id EditorID) string { switch id { case EditorVSCode: - return "vscode-servers" + return configFormatVSCode case EditorZed: - return "zed-context_servers" + return configFormatZed case EditorContinue: - return "continue-yaml" + return configFormatContinue default: return "mcpServers" } diff --git a/internal/install/uninstall.go b/internal/install/uninstall.go index 67fd9df..b39ce85 100644 --- a/internal/install/uninstall.go +++ b/internal/install/uninstall.go @@ -514,11 +514,11 @@ func knowledgeServerNames() []string { func deregisterServerFromFile(configFile, format string, names []string) error { key := "mcpServers" switch format { - case "vscode-servers": + case configFormatVSCode: key = "servers" - case "zed-context_servers": + case configFormatZed: key = "context_servers" - case "continue-yaml": + case configFormatContinue: // Continue's servers are a YAML list, not a JSON map. return removeContinueEntry(configFile, names) }