From a1985de23234e3cb9d623e03d6268865409ef9a9 Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Fri, 10 Jul 2026 23:25:32 +0530 Subject: [PATCH 01/10] feat(marketplace): add catalog validation --- internal/marketplace/catalog.go | 444 ++++++++++++++++++++++++++ internal/marketplace/catalog_test.go | 226 +++++++++++++ internal/marketplace/registry.go | 167 ++++++++++ internal/marketplace/registry_test.go | 89 ++++++ 4 files changed, 926 insertions(+) create mode 100644 internal/marketplace/catalog.go create mode 100644 internal/marketplace/catalog_test.go create mode 100644 internal/marketplace/registry.go create mode 100644 internal/marketplace/registry_test.go diff --git a/internal/marketplace/catalog.go b/internal/marketplace/catalog.go new file mode 100644 index 00000000..9c8fc3e7 --- /dev/null +++ b/internal/marketplace/catalog.go @@ -0,0 +1,444 @@ +package marketplace + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "path" + "regexp" + "sort" + "strings" + + "github.com/Gitlawb/zero/internal/plugins" +) + +type VerificationStatus string + +const ( + VerificationSigned VerificationStatus = "signed" + VerificationUnsigned VerificationStatus = "unsigned" + VerificationStale VerificationStatus = "stale" + VerificationInvalid VerificationStatus = "invalid" +) + +type ReviewStatus string + +const ( + ReviewStatusReviewed ReviewStatus = "reviewed" + ReviewStatusCommunity ReviewStatus = "community" + ReviewStatusUnreviewed ReviewStatus = "unreviewed" +) + +type Catalog struct { + SchemaVersion int `json:"schemaVersion"` + ID string `json:"id"` + Owner string `json:"owner"` + Description string `json:"description,omitempty"` + Plugins []CatalogPlugin `json:"plugins"` +} + +type CatalogPlugin struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Author CatalogAuthor `json:"author"` + License string `json:"license"` + Homepage string `json:"homepage,omitempty"` + Tags []string `json:"tags,omitempty"` + Category string `json:"category,omitempty"` + Review ReviewRecord `json:"review"` + Releases []Release `json:"releases"` +} + +type CatalogAuthor struct { + Name string `json:"name"` + Email string `json:"email,omitempty"` + URL string `json:"url,omitempty"` +} + +type ReviewRecord struct { + Status ReviewStatus `json:"status"` + Date string `json:"date,omitempty"` + Reviewer string `json:"reviewer,omitempty"` + URL string `json:"url,omitempty"` +} + +type Release struct { + Version string `json:"version"` + Repository string `json:"repository"` + Commit string `json:"commit"` + Subdir string `json:"subdir,omitempty"` + TreeHash string `json:"treeHash"` + Components ComponentInventory `json:"components"` +} + +type ComponentInventory struct { + Tools []ToolComponent `json:"tools,omitempty"` + Hooks []HookComponent `json:"hooks,omitempty"` + Skills []NamedComponent `json:"skills,omitempty"` + Prompts []NamedComponent `json:"prompts,omitempty"` +} + +type ToolComponent struct { + Name string `json:"name"` + Permission plugins.ToolPermission `json:"permission"` +} + +type HookComponent struct { + Name string `json:"name"` + Event plugins.HookEvent `json:"event"` +} + +type NamedComponent struct { + Name string `json:"name"` +} + +type Verification struct { + Status VerificationStatus `json:"status"` + KeyFingerprint string `json:"keyFingerprint,omitempty"` + Error string `json:"error,omitempty"` +} + +type RiskReport struct { + Tools []ToolComponent `json:"tools,omitempty"` + Hooks []HookComponent `json:"hooks,omitempty"` + Skills []NamedComponent `json:"skills,omitempty"` + Prompts []NamedComponent `json:"prompts,omitempty"` + Permissions []string `json:"permissions,omitempty"` +} + +type InstalledPlugin struct { + ID string `json:"id"` + Scope string `json:"scope"` + Catalog string `json:"catalog,omitempty"` + Version string `json:"version,omitempty"` + Commit string `json:"commit,omitempty"` + Subdir string `json:"subdir,omitempty"` + Hash string `json:"hash,omitempty"` + Pinned bool `json:"pinned,omitempty"` + Enabled bool `json:"enabled"` + Quarantined bool `json:"quarantined,omitempty"` +} + +var ( + idPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + semverPattern = regexp.MustCompile(`^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) + commitPattern = regexp.MustCompile(`^[A-Fa-f0-9]{40}$`) + treeHashPattern = regexp.MustCompile(`^sha256:[A-Fa-f0-9]{64}$`) +) + +// ParseCatalog parses and validates catalog.json. Validation is intentionally +// strict: catalog metadata is signed, so loose interpretation would make install +// comparisons ambiguous. +func ParseCatalog(data []byte) (Catalog, error) { + var catalog Catalog + if err := json.Unmarshal(data, &catalog); err != nil { + return Catalog{}, fmt.Errorf("parse catalog: %w", err) + } + if err := ValidateCatalog(catalog); err != nil { + return Catalog{}, err + } + return catalog, nil +} + +func ValidateCatalog(catalog Catalog) error { + if catalog.SchemaVersion != 1 { + return fmt.Errorf("schemaVersion: expected 1") + } + if err := validateID("id", catalog.ID); err != nil { + return err + } + if strings.TrimSpace(catalog.Owner) == "" { + return fmt.Errorf("owner: required") + } + + seenPlugins := map[string]struct{}{} + for index, plugin := range catalog.Plugins { + field := fmt.Sprintf("plugins.%d", index) + if err := validatePlugin(field, plugin); err != nil { + return err + } + if _, exists := seenPlugins[plugin.ID]; exists { + return fmt.Errorf("%s.id: duplicate plugin id %q", field, plugin.ID) + } + seenPlugins[plugin.ID] = struct{}{} + } + return nil +} + +func validatePlugin(field string, plugin CatalogPlugin) error { + if err := validateID(field+".id", plugin.ID); err != nil { + return err + } + if strings.TrimSpace(plugin.Name) == "" { + return fmt.Errorf("%s.name: required", field) + } + if strings.TrimSpace(plugin.Author.Name) == "" { + return fmt.Errorf("%s.author.name: required", field) + } + if strings.TrimSpace(plugin.License) == "" { + return fmt.Errorf("%s.license: required", field) + } + if err := validateReview(field+".review", plugin.Review); err != nil { + return err + } + if len(plugin.Releases) == 0 { + return fmt.Errorf("%s.releases: at least one release is required", field) + } + seenVersions := map[string]struct{}{} + for index, release := range plugin.Releases { + releaseField := fmt.Sprintf("%s.releases.%d", field, index) + if err := validateRelease(releaseField, release); err != nil { + return err + } + if _, exists := seenVersions[release.Version]; exists { + return fmt.Errorf("%s.version: duplicate release version %q", releaseField, release.Version) + } + seenVersions[release.Version] = struct{}{} + } + return nil +} + +func validateReview(field string, review ReviewRecord) error { + switch review.Status { + case ReviewStatusReviewed, ReviewStatusCommunity, ReviewStatusUnreviewed: + return nil + default: + return fmt.Errorf("%s.status: expected reviewed, community, or unreviewed", field) + } +} + +func validateRelease(field string, release Release) error { + if !semverPattern.MatchString(release.Version) { + return fmt.Errorf("%s.version: expected semantic version", field) + } + if _, err := ParseCatalogSource(release.Repository); err != nil { + return fmt.Errorf("%s.repository: %w", field, err) + } + if !commitPattern.MatchString(release.Commit) { + return fmt.Errorf("%s.commit: expected 40-character git commit SHA", field) + } + if !treeHashPattern.MatchString(release.TreeHash) { + return fmt.Errorf("%s.treeHash: expected sha256:<64 hex chars>", field) + } + if err := validateSafeSubdir(field+".subdir", release.Subdir); err != nil { + return err + } + return validateComponents(field+".components", release.Components) +} + +func validateComponents(field string, components ComponentInventory) error { + for index, tool := range components.Tools { + item := fmt.Sprintf("%s.tools.%d", field, index) + if err := validateID(item+".name", tool.Name); err != nil { + return err + } + switch tool.Permission { + case "", plugins.PermissionPrompt, plugins.PermissionAllow, plugins.PermissionDeny: + default: + return fmt.Errorf("%s.permission: expected allow, prompt, or deny", item) + } + } + for index, hook := range components.Hooks { + item := fmt.Sprintf("%s.hooks.%d", field, index) + if err := validateID(item+".name", hook.Name); err != nil { + return err + } + if !allowedMarketplaceHookEvent(hook.Event) { + return fmt.Errorf("%s.event: unsupported hook event %q", item, hook.Event) + } + } + for index, skill := range components.Skills { + if err := validateID(fmt.Sprintf("%s.skills.%d.name", field, index), skill.Name); err != nil { + return err + } + } + for index, prompt := range components.Prompts { + if err := validateID(fmt.Sprintf("%s.prompts.%d.name", field, index), prompt.Name); err != nil { + return err + } + } + return nil +} + +func allowedMarketplaceHookEvent(event plugins.HookEvent) bool { + switch event { + case plugins.HookBeforeTool, plugins.HookAfterTool, plugins.HookSessionStart, plugins.HookSessionEnd: + return true + default: + return false + } +} + +func validateID(field string, value string) error { + value = strings.TrimSpace(value) + if value == "" { + return fmt.Errorf("%s: required", field) + } + if !idPattern.MatchString(value) { + return fmt.Errorf("%s: invalid identifier %q", field, value) + } + return nil +} + +func validateSafeSubdir(field string, subdir string) error { + subdir = strings.TrimSpace(subdir) + if subdir == "" { + return nil + } + if strings.Contains(subdir, `\`) || path.IsAbs(subdir) { + return fmt.Errorf("%s: expected safe relative path", field) + } + clean := path.Clean(subdir) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") { + return fmt.Errorf("%s: expected safe relative path", field) + } + return nil +} + +func VerifyCatalogSignature(data []byte, signature []byte, publicKey ed25519.PublicKey) Verification { + fingerprint := publicKeyFingerprint(publicKey) + if len(signature) == 0 { + return Verification{Status: VerificationUnsigned, KeyFingerprint: fingerprint} + } + if len(publicKey) != ed25519.PublicKeySize { + return Verification{Status: VerificationInvalid, Error: "invalid public key"} + } + if !ed25519.Verify(publicKey, data, signature) { + return Verification{Status: VerificationInvalid, KeyFingerprint: fingerprint, Error: "signature mismatch"} + } + return Verification{Status: VerificationSigned, KeyFingerprint: fingerprint} +} + +func publicKeyFingerprint(publicKey ed25519.PublicKey) string { + if len(publicKey) == 0 { + return "" + } + sum := sha256.Sum256(publicKey) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func Search(catalog Catalog, query string) []CatalogPlugin { + terms := strings.Fields(strings.ToLower(strings.TrimSpace(query))) + if len(terms) == 0 { + return append([]CatalogPlugin{}, catalog.Plugins...) + } + matches := []CatalogPlugin{} + for _, plugin := range catalog.Plugins { + haystack := strings.ToLower(strings.Join(pluginSearchFields(plugin), " ")) + ok := true + for _, term := range terms { + if !strings.Contains(haystack, term) { + ok = false + break + } + } + if ok { + matches = append(matches, plugin) + } + } + return matches +} + +func pluginSearchFields(plugin CatalogPlugin) []string { + fields := []string{plugin.ID, plugin.Name, plugin.Description, plugin.Author.Name, plugin.Category, plugin.License} + fields = append(fields, plugin.Tags...) + for _, release := range plugin.Releases { + for _, tool := range release.Components.Tools { + fields = append(fields, tool.Name, string(tool.Permission), "tool") + } + for _, hook := range release.Components.Hooks { + fields = append(fields, hook.Name, string(hook.Event), "hook") + } + for _, skill := range release.Components.Skills { + fields = append(fields, skill.Name, "skill") + } + for _, prompt := range release.Components.Prompts { + fields = append(fields, prompt.Name, "prompt") + } + } + sort.Strings(fields) + return fields +} + +func RiskForRelease(release Release) RiskReport { + permissions := []string{} + seen := map[string]bool{} + for _, tool := range release.Components.Tools { + permission := string(tool.Permission) + if permission == "" { + permission = string(plugins.PermissionPrompt) + } + if !seen[permission] { + seen[permission] = true + permissions = append(permissions, permission) + } + } + sort.Strings(permissions) + return RiskReport{ + Tools: append([]ToolComponent{}, release.Components.Tools...), + Hooks: append([]HookComponent{}, release.Components.Hooks...), + Skills: append([]NamedComponent{}, release.Components.Skills...), + Prompts: append([]NamedComponent{}, release.Components.Prompts...), + Permissions: permissions, + } +} + +type CatalogSourceKind string + +const ( + CatalogSourceGitHub CatalogSourceKind = "github" + CatalogSourceGit CatalogSourceKind = "git" + CatalogSourceHTTPS CatalogSourceKind = "https" + CatalogSourceLocal CatalogSourceKind = "local" +) + +type CatalogSource struct { + Kind CatalogSourceKind `json:"kind"` + Raw string `json:"raw"` + Canonical string `json:"canonical"` +} + +var githubShorthandPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$`) + +func ParseCatalogSource(raw string) (CatalogSource, error) { + source := strings.TrimSpace(raw) + if source == "" { + return CatalogSource{}, fmt.Errorf("source is required") + } + if strings.Contains(source, "://") { + parsed, err := url.Parse(source) + if err != nil { + return CatalogSource{}, err + } + if parsed.User != nil { + return CatalogSource{}, fmt.Errorf("embedded credentials are not allowed") + } + switch parsed.Scheme { + case "https": + kind := CatalogSourceHTTPS + if strings.HasSuffix(parsed.Path, ".git") { + kind = CatalogSourceGit + } + return CatalogSource{Kind: kind, Raw: raw, Canonical: source}, nil + case "git", "ssh", "git+ssh", "file": + return CatalogSource{Kind: CatalogSourceGit, Raw: raw, Canonical: source}, nil + default: + return CatalogSource{}, fmt.Errorf("unsupported source scheme %q", parsed.Scheme) + } + } + if githubShorthandPattern.MatchString(source) { + return CatalogSource{ + Kind: CatalogSourceGitHub, + Raw: raw, + Canonical: "https://github.com/" + source + ".git", + }, nil + } + if strings.Contains(source, "@") && strings.Contains(source, ":") { + return CatalogSource{Kind: CatalogSourceGit, Raw: raw, Canonical: source}, nil + } + return CatalogSource{Kind: CatalogSourceLocal, Raw: raw, Canonical: source}, nil +} diff --git a/internal/marketplace/catalog_test.go b/internal/marketplace/catalog_test.go new file mode 100644 index 00000000..a5d21bc4 --- /dev/null +++ b/internal/marketplace/catalog_test.go @@ -0,0 +1,226 @@ +package marketplace + +import ( + "crypto/ed25519" + "strings" + "testing" +) + +func TestParseCatalogValidatesMarketplaceContract(t *testing.T) { + catalog := testCatalogJSON() + + parsed, err := ParseCatalog([]byte(catalog)) + if err != nil { + t.Fatalf("ParseCatalog returned error: %v", err) + } + + if parsed.SchemaVersion != 1 || parsed.ID != "official" { + t.Fatalf("unexpected catalog metadata: %#v", parsed) + } + if len(parsed.Plugins) != 2 || parsed.Plugins[0].ID != "zero.demo" { + t.Fatalf("unexpected plugins: %#v", parsed.Plugins) + } + release := parsed.Plugins[0].Releases[0] + if release.Version != "1.2.3" || release.Commit != strings.Repeat("a", 40) { + t.Fatalf("unexpected release: %#v", release) + } + if len(release.Components.Tools) != 1 || release.Components.Tools[0].Permission != "prompt" { + t.Fatalf("tool inventory not parsed: %#v", release.Components.Tools) + } + if len(release.Components.Hooks) != 1 || release.Components.Hooks[0].Event != "beforeTool" { + t.Fatalf("hook inventory not parsed: %#v", release.Components.Hooks) + } +} + +func TestParseCatalogRejectsDuplicatePluginAndReleaseIDs(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + { + name: "duplicate plugin id", + body: strings.Replace(testCatalogJSON(), `"id": "zero.second"`, `"id": "zero.demo"`, 1), + want: `duplicate plugin id "zero.demo"`, + }, + { + name: "duplicate release version", + body: strings.Replace(testCatalogJSON(), `"version": "1.2.4"`, `"version": "1.2.3"`, 1), + want: `duplicate release version "1.2.3"`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ParseCatalog([]byte(tc.body)) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected %q error, got %v", tc.want, err) + } + }) + } +} + +func TestParseCatalogRejectsInvalidReleaseAndSpecialistHookEvents(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + { + name: "invalid semver", + body: strings.Replace(testCatalogJSON(), `"version": "1.2.3"`, `"version": "latest"`, 1), + want: "semantic version", + }, + { + name: "invalid commit", + body: strings.Replace(testCatalogJSON(), strings.Repeat("a", 40), "main", 1), + want: "40-character git commit SHA", + }, + { + name: "invalid hash", + body: strings.Replace(testCatalogJSON(), `sha256:`+strings.Repeat("b", 64), "sha256:nothex", 1), + want: "sha256:", + }, + { + name: "unsafe subdir", + body: strings.Replace(testCatalogJSON(), `"subdir": "plugins/demo"`, `"subdir": "../escape"`, 1), + want: "safe relative path", + }, + { + name: "specialist hook", + body: strings.Replace(testCatalogJSON(), `"beforeTool"`, `"specialistStart"`, 1), + want: `unsupported hook event "specialistStart"`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ParseCatalog([]byte(tc.body)) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected %q error, got %v", tc.want, err) + } + }) + } +} + +func TestVerifyCatalogSignature(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + data := []byte(testCatalogJSON()) + signature := ed25519.Sign(privateKey, data) + + result := VerifyCatalogSignature(data, signature, publicKey) + if result.Status != VerificationSigned || result.KeyFingerprint == "" { + t.Fatalf("signed verification failed: %#v", result) + } + + result = VerifyCatalogSignature(data, nil, publicKey) + if result.Status != VerificationUnsigned { + t.Fatalf("unsigned verification = %#v", result) + } + + result = VerifyCatalogSignature(append(data, '\n'), signature, publicKey) + if result.Status != VerificationInvalid { + t.Fatalf("invalid verification = %#v", result) + } +} + +func TestParseCatalogSource(t *testing.T) { + cases := []struct { + source string + kind CatalogSourceKind + canon string + }{ + {"Gitlawb/zero-plugins", CatalogSourceGitHub, "https://github.com/Gitlawb/zero-plugins.git"}, + {"https://example.com/catalog.json", CatalogSourceHTTPS, "https://example.com/catalog.json"}, + {"git@github.com:Gitlawb/zero-plugins.git", CatalogSourceGit, "git@github.com:Gitlawb/zero-plugins.git"}, + {"./catalog.json", CatalogSourceLocal, "./catalog.json"}, + } + for _, tc := range cases { + t.Run(tc.source, func(t *testing.T) { + parsed, err := ParseCatalogSource(tc.source) + if err != nil { + t.Fatalf("ParseCatalogSource: %v", err) + } + if parsed.Kind != tc.kind || parsed.Canonical != tc.canon { + t.Fatalf("source = %#v, want kind=%s canonical=%s", parsed, tc.kind, tc.canon) + } + }) + } + + for _, source := range []string{"https://user:pass@example.com/catalog.json", "https://token@example.com/catalog.json"} { + t.Run(source, func(t *testing.T) { + _, err := ParseCatalogSource(source) + if err == nil || !strings.Contains(err.Error(), "embedded credentials") { + t.Fatalf("expected credential rejection, got %v", err) + } + }) + } +} + +func testCatalogJSON() string { + return `{ + "schemaVersion": 1, + "id": "official", + "owner": "Gitlawb", + "description": "Official Zero plugins", + "plugins": [ + { + "id": "zero.demo", + "name": "Zero Demo", + "description": "Demo plugin", + "author": {"name": "Zero"}, + "license": "MIT", + "homepage": "https://example.com/zero.demo", + "tags": ["demo", "docs"], + "category": "productivity", + "review": { + "status": "reviewed", + "date": "2026-07-10", + "reviewer": "Zero Security", + "url": "https://github.com/Gitlawb/zero-plugins/pull/1" + }, + "releases": [ + { + "version": "1.2.3", + "repository": "https://github.com/Gitlawb/zero-demo-plugin.git", + "commit": "` + strings.Repeat("a", 40) + `", + "subdir": "plugins/demo", + "treeHash": "sha256:` + strings.Repeat("b", 64) + `", + "components": { + "tools": [{"name": "lookup", "permission": "prompt"}], + "hooks": [{"name": "preflight", "event": "beforeTool"}], + "skills": [{"name": "review"}], + "prompts": [{"name": "summarize"}] + } + }, + { + "version": "1.2.4", + "repository": "https://github.com/Gitlawb/zero-demo-plugin.git", + "commit": "` + strings.Repeat("c", 40) + `", + "treeHash": "sha256:` + strings.Repeat("d", 64) + `", + "components": {"tools": [{"name": "lookup", "permission": "prompt"}]} + } + ] + }, + { + "id": "zero.second", + "name": "Second", + "author": {"name": "Zero"}, + "license": "MIT", + "review": {"status": "community"}, + "releases": [ + { + "version": "0.1.0", + "repository": "https://github.com/Gitlawb/zero-second-plugin.git", + "commit": "` + strings.Repeat("e", 40) + `", + "treeHash": "sha256:` + strings.Repeat("f", 64) + `", + "components": {"prompts": [{"name": "review"}]} + } + ] + } + ] +}` +} diff --git a/internal/marketplace/registry.go b/internal/marketplace/registry.go new file mode 100644 index 00000000..4ab86699 --- /dev/null +++ b/internal/marketplace/registry.go @@ -0,0 +1,167 @@ +package marketplace + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + OfficialCatalogID = "official" + OfficialCatalogSource = "Gitlawb/zero-plugins" +) + +type Scope string + +const ( + ScopeUser Scope = "user" + ScopeProject Scope = "project" +) + +type Registry struct { + Catalogs []RegisteredCatalog `json:"catalogs"` +} + +type RegisteredCatalog struct { + ID string `json:"id"` + Source string `json:"source"` + PublicKeyPath string `json:"publicKeyPath,omitempty"` + VerificationStatus VerificationStatus `json:"verificationStatus,omitempty"` +} + +func (registry *Registry) Add(catalog RegisteredCatalog) error { + if err := validateID("id", catalog.ID); err != nil { + return err + } + if strings.TrimSpace(catalog.Source) == "" { + return fmt.Errorf("source: required") + } + if catalog.ID == OfficialCatalogID { + return fmt.Errorf("catalog id %q is reserved", OfficialCatalogID) + } + for _, existing := range registry.Catalogs { + if existing.ID == catalog.ID { + return fmt.Errorf("catalog id %q already exists", catalog.ID) + } + } + registry.Catalogs = append(registry.Catalogs, catalog) + return nil +} + +func LoadRegistry(path string) (Registry, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Registry{}, nil + } + return Registry{}, fmt.Errorf("read marketplace registry: %w", err) + } + if len(strings.TrimSpace(string(data))) == 0 { + return Registry{}, nil + } + var registry Registry + if err := json.Unmarshal(data, ®istry); err != nil { + return Registry{}, fmt.Errorf("parse marketplace registry: %w", err) + } + return registry, nil +} + +func SaveRegistry(path string, registry Registry) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create marketplace registry dir: %w", err) + } + data, err := json.MarshalIndent(registry, "", " ") + if err != nil { + return fmt.Errorf("encode marketplace registry: %w", err) + } + temp, err := os.CreateTemp(filepath.Dir(path), ".marketplaces-*.tmp") + if err != nil { + return fmt.Errorf("create marketplace registry temp: %w", err) + } + tempName := temp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tempName) + } + }() + if _, err := temp.Write(append(data, '\n')); err != nil { + _ = temp.Close() + return fmt.Errorf("write marketplace registry temp: %w", err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close marketplace registry temp: %w", err) + } + if err := os.Rename(tempName, path); err != nil { + return fmt.Errorf("replace marketplace registry: %w", err) + } + cleanup = false + return nil +} + +func RegistryPathForScope(scope Scope, cwd string, env map[string]string) (string, error) { + switch scope { + case ScopeProject: + if strings.TrimSpace(cwd) == "" { + return "", fmt.Errorf("workspace root is required for project scope") + } + return filepath.Join(cwd, ".zero", "marketplaces.json"), nil + case "", ScopeUser: + configHome := strings.TrimSpace(envValue(env, "XDG_CONFIG_HOME")) + if configHome == "" { + home := strings.TrimSpace(firstNonEmpty(envValue(env, "HOME"), envValue(env, "USERPROFILE"))) + if home == "" { + var err error + home, err = os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve user home: %w", err) + } + } + configHome = filepath.Join(home, ".config") + } + return filepath.Join(configHome, "zero", "marketplaces.json"), nil + default: + return "", fmt.Errorf("unsupported scope %q", scope) + } +} + +func ReadLocalCatalog(path string) ([]byte, []byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, fmt.Errorf("read catalog: %w", err) + } + signature, err := os.ReadFile(signaturePathForCatalog(path)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return data, nil, nil + } + return nil, nil, fmt.Errorf("read catalog signature: %w", err) + } + return data, signature, nil +} + +func signaturePathForCatalog(catalogPath string) string { + if strings.EqualFold(filepath.Base(catalogPath), "catalog.json") { + return filepath.Join(filepath.Dir(catalogPath), "catalog.sig") + } + return catalogPath + ".sig" +} + +func envValue(env map[string]string, key string) string { + if env == nil { + return os.Getenv(key) + } + return env[key] +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/marketplace/registry_test.go b/internal/marketplace/registry_test.go new file mode 100644 index 00000000..692b11cd --- /dev/null +++ b/internal/marketplace/registry_test.go @@ -0,0 +1,89 @@ +package marketplace + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRegistryAddRejectsReservedAndDuplicateCatalogIDs(t *testing.T) { + registry := Registry{} + + if err := registry.Add(RegisteredCatalog{ID: OfficialCatalogID, Source: "https://example.com/catalog.json"}); err == nil { + t.Fatalf("expected official catalog id collision to be rejected") + } + + if err := registry.Add(RegisteredCatalog{ID: "team", Source: "./catalog.json"}); err != nil { + t.Fatalf("Add returned error: %v", err) + } + if err := registry.Add(RegisteredCatalog{ID: "team", Source: "./other.json"}); err == nil { + t.Fatalf("expected duplicate catalog id rejection") + } +} + +func TestRegistryLoadSaveRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "marketplaces.json") + registry := Registry{} + if err := registry.Add(RegisteredCatalog{ + ID: "team", + Source: "./catalog.json", + PublicKeyPath: "./catalog.pub", + VerificationStatus: VerificationUnsigned, + }); err != nil { + t.Fatalf("Add: %v", err) + } + + if err := SaveRegistry(path, registry); err != nil { + t.Fatalf("SaveRegistry: %v", err) + } + loaded, err := LoadRegistry(path) + if err != nil { + t.Fatalf("LoadRegistry: %v", err) + } + if len(loaded.Catalogs) != 1 || loaded.Catalogs[0].ID != "team" || loaded.Catalogs[0].Source != "./catalog.json" { + t.Fatalf("unexpected registry: %#v", loaded) + } +} + +func TestRegistryPathForScope(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + cwd := filepath.Join(t.TempDir(), "repo") + env := map[string]string{"XDG_CONFIG_HOME": filepath.Join(home, "config")} + + userPath, err := RegistryPathForScope(ScopeUser, cwd, env) + if err != nil { + t.Fatalf("user path: %v", err) + } + if !strings.HasSuffix(userPath, filepath.Join("config", "zero", "marketplaces.json")) { + t.Fatalf("unexpected user path: %s", userPath) + } + + projectPath, err := RegistryPathForScope(ScopeProject, cwd, env) + if err != nil { + t.Fatalf("project path: %v", err) + } + if projectPath != filepath.Join(cwd, ".zero", "marketplaces.json") { + t.Fatalf("project path = %s", projectPath) + } +} + +func TestReadLocalCatalogReadsCatalogAndOptionalSignature(t *testing.T) { + dir := t.TempDir() + catalogPath := filepath.Join(dir, "catalog.json") + sigPath := filepath.Join(dir, "catalog.sig") + if err := os.WriteFile(catalogPath, []byte(testCatalogJSON()), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sigPath, []byte("signature"), 0o644); err != nil { + t.Fatal(err) + } + + data, sig, err := ReadLocalCatalog(catalogPath) + if err != nil { + t.Fatalf("ReadLocalCatalog: %v", err) + } + if !strings.Contains(string(data), `"id": "official"`) || string(sig) != "signature" { + t.Fatalf("unexpected data/signature: %q %q", data, sig) + } +} From bfc2af05dd2a54b0302b879d3ad6d6143bf108e4 Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Fri, 10 Jul 2026 23:25:40 +0530 Subject: [PATCH 02/10] feat(plugins): quarantine disabled plugins --- internal/plugins/disable_test.go | 165 ++++++++++++ internal/plugins/install.go | 420 ++++++++++++++++++++++++++++- internal/plugins/integrity_test.go | 57 ++++ internal/plugins/plugins.go | 129 +++++++-- 4 files changed, 735 insertions(+), 36 deletions(-) create mode 100644 internal/plugins/disable_test.go create mode 100644 internal/plugins/integrity_test.go diff --git a/internal/plugins/disable_test.go b/internal/plugins/disable_test.go new file mode 100644 index 00000000..a612ad48 --- /dev/null +++ b/internal/plugins/disable_test.go @@ -0,0 +1,165 @@ +package plugins + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestReadLockDecodesMarketplaceFieldsAdditively(t *testing.T) { + dir := t.TempDir() + data := []byte(`{ + "zero.demo": { + "source": "https://github.com/Gitlawb/zero-demo-plugin.git", + "hash": "sha256:abc", + "catalog": "official", + "version": "1.2.3", + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "subdir": "plugins/demo", + "enabled": false, + "pinned": true + } +}`) + if err := os.WriteFile(filepath.Join(dir, LockFileName), data, 0o644); err != nil { + t.Fatal(err) + } + + lock, err := ReadLock(dir) + if err != nil { + t.Fatalf("ReadLock: %v", err) + } + entry := lock["zero.demo"] + if entry.Catalog != "official" || entry.Version != "1.2.3" || entry.Commit != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" || entry.Subdir != "plugins/demo" || entry.Enabled == nil || *entry.Enabled || !entry.Pinned { + t.Fatalf("marketplace fields did not decode: %#v", entry) + } +} + +func TestDisableMovesPluginToQuarantineAndLoaderListsDisabled(t *testing.T) { + root := t.TempDir() + src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest()) + if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: root}); err != nil { + t.Fatalf("Install: %v", err) + } + + if err := Disable(root, "zero.demo"); err != nil { + t.Fatalf("Disable: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "zero.demo")); !os.IsNotExist(err) { + t.Fatalf("active plugin dir should be absent after disable, err=%v", err) + } + if _, err := os.Stat(filepath.Join(root, disabledDirName, "zero.demo", manifestFileName)); err != nil { + t.Fatalf("quarantined plugin manifest missing: %v", err) + } + lock, err := ReadLock(root) + if err != nil { + t.Fatalf("ReadLock: %v", err) + } + if lock["zero.demo"].Enabled == nil || *lock["zero.demo"].Enabled { + t.Fatalf("lock entry should record enabled:false: %#v", lock["zero.demo"]) + } + + loaded, err := Load(LoadOptions{Roots: []Root{{Source: SourceUser, Path: root}}}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(loaded.Plugins) != 1 || loaded.Plugins[0].ID != "zero.demo" || loaded.Plugins[0].Enabled { + t.Fatalf("disabled plugin should be listed but inactive: %#v", loaded.Plugins) + } +} + +func TestEnableMovesQuarantinedPluginBackToActiveRoot(t *testing.T) { + root := t.TempDir() + src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest()) + if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: root}); err != nil { + t.Fatalf("Install: %v", err) + } + if err := Disable(root, "zero.demo"); err != nil { + t.Fatalf("Disable: %v", err) + } + + if err := Enable(root, "zero.demo"); err != nil { + t.Fatalf("Enable: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "zero.demo", manifestFileName)); err != nil { + t.Fatalf("active plugin manifest missing after enable: %v", err) + } + if _, err := os.Stat(filepath.Join(root, disabledDirName, "zero.demo")); !os.IsNotExist(err) { + t.Fatalf("quarantine dir should be absent after enable, err=%v", err) + } + lock, err := ReadLock(root) + if err != nil { + t.Fatalf("ReadLock: %v", err) + } + if lock["zero.demo"].Enabled == nil || !*lock["zero.demo"].Enabled { + t.Fatalf("lock entry should record enabled:true: %#v", lock["zero.demo"]) + } +} + +func TestInstallPreservesDisabledStateThroughUpdate(t *testing.T) { + root := t.TempDir() + src := filepath.Join(t.TempDir(), "src") + writeSourcePlugin(t, src, validManifest()) + if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: root}); err != nil { + t.Fatalf("Install: %v", err) + } + if err := Disable(root, "zero.demo"); err != nil { + t.Fatalf("Disable: %v", err) + } + + bumped := validManifest() + bumped["version"] = "0.2.0" + writeSourcePlugin(t, src, bumped) + if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: root}); err != nil { + t.Fatalf("disabled update install: %v", err) + } + + if _, err := os.Stat(filepath.Join(root, "zero.demo")); !os.IsNotExist(err) { + t.Fatalf("active plugin dir should remain absent after disabled update, err=%v", err) + } + loaded, err := Load(LoadOptions{Roots: []Root{{Source: SourceUser, Path: root}}}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(loaded.Plugins) != 1 || loaded.Plugins[0].Version != "0.2.0" || loaded.Plugins[0].Enabled { + t.Fatalf("disabled update should stay quarantined at new version: %#v", loaded.Plugins) + } + lock, err := ReadLock(root) + if err != nil { + t.Fatalf("ReadLock: %v", err) + } + if lock["zero.demo"].Enabled == nil || *lock["zero.demo"].Enabled { + t.Fatalf("lock should preserve enabled:false: %#v", lock["zero.demo"]) + } +} + +func TestDisabledProjectPluginShadowsUserPlugin(t *testing.T) { + dir := t.TempDir() + userRoot := filepath.Join(dir, "user") + projectRoot := filepath.Join(dir, "project") + writePluginManifest(t, filepath.Join(userRoot, "demo"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "User Demo", + "version": "0.1.0", + }) + writePluginManifest(t, filepath.Join(projectRoot, disabledDirName, "demo"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Project Demo", + "version": "0.2.0", + }) + + result, err := Load(LoadOptions{ + Roots: []Root{ + {Source: SourceUser, Path: userRoot}, + {Source: SourceProject, Path: projectRoot}, + }, + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(result.Plugins) != 1 || result.Plugins[0].Name != "Project Demo" || result.Plugins[0].Enabled { + t.Fatalf("disabled project plugin should shadow user plugin: %#v", result.Plugins) + } +} diff --git a/internal/plugins/install.go b/internal/plugins/install.go index c201b27e..d54217a2 100644 --- a/internal/plugins/install.go +++ b/internal/plugins/install.go @@ -17,6 +17,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "sort" "strings" ) @@ -27,10 +28,14 @@ const manifestFileName = "plugin.json" // LockFileName maps an installed plugin id to its source and content hash. const LockFileName = "plugins.lock" +const disabledDirName = ".disabled" + // ErrNameClash is returned when an install would overwrite a plugin already // installed from a DIFFERENT source, unless InstallOptions.Force is set. var ErrNameClash = errors.New("a different plugin with that id is already installed") +var installCommitPattern = regexp.MustCompile(`^[A-Fa-f0-9]{40}$`) + // GitRunner fetches the plugin at source into destination. The default runner // shallow-clones with the system git (inheriting the process environment, so // proxy/egress settings are honored). It is injectable so tests never hit the @@ -50,6 +55,40 @@ type InstallOptions struct { // GitRunner overrides the fetch implementation. When nil, a git source is // shallow-cloned with the system git. GitRunner GitRunner + // Commit pins git installs to an exact commit. Local paths ignore this field + // but still record it in the lockfile when supplied by a catalog release. + Commit string + // Subdir selects the plugin directory inside the fetched source. + Subdir string + // ExpectedHash, when set, must match the filtered tree hash before install. + ExpectedHash string + // ExpectedID/ExpectedVersion, when set, must match the parsed manifest. + ExpectedID string + ExpectedVersion string + // ExpectedComponents, when non-nil, must exactly match the parsed manifest's + // tools/hooks/skills/prompts inventory by name plus permission/event where + // applicable. + ExpectedComponents *InstallComponentInventory + // Catalog metadata is additive lockfile state used by marketplace installs. + Catalog string + Pinned bool +} + +type InstallComponentInventory struct { + Tools []InstallToolComponent + Hooks []InstallHookComponent + Skills []string + Prompts []string +} + +type InstallToolComponent struct { + Name string + Permission ToolPermission +} + +type InstallHookComponent struct { + Name string + Event HookEvent } // InstallResult reports what an install did. @@ -66,8 +105,14 @@ type InstallResult struct { // LockEntry records the source and content hash for one installed plugin. type LockEntry struct { - Source string `json:"source"` - Hash string `json:"hash"` + Source string `json:"source,omitempty"` + Hash string `json:"hash,omitempty"` + Catalog string `json:"catalog,omitempty"` + Version string `json:"version,omitempty"` + Commit string `json:"commit,omitempty"` + Subdir string `json:"subdir,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Pinned bool `json:"pinned,omitempty"` } // Install fetches the plugin at options.Source, validates its manifest, copies @@ -91,8 +136,17 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) return InstallResult{}, err } defer cleanup() + if strings.TrimSpace(options.Commit) != "" && !isLocalPath(source) { + if err := checkoutCommit(ctx, fetchDir, options.Commit); err != nil { + return InstallResult{}, err + } + } - pluginDir, err := locatePluginDir(fetchDir) + sourceRoot := fetchDir + if strings.TrimSpace(options.Subdir) != "" { + sourceRoot = filepath.Join(fetchDir, filepath.FromSlash(options.Subdir)) + } + pluginDir, err := locatePluginDir(sourceRoot) if err != nil { return InstallResult{}, err } @@ -119,6 +173,17 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) return InstallResult{}, fmt.Errorf("invalid plugin manifest: %w", err) } id := parsed.ID + if expectedID := strings.TrimSpace(options.ExpectedID); expectedID != "" && parsed.ID != expectedID { + return InstallResult{}, fmt.Errorf("catalog expected plugin id %q, manifest has %q", expectedID, parsed.ID) + } + if expectedVersion := strings.TrimSpace(options.ExpectedVersion); expectedVersion != "" && parsed.Version != expectedVersion { + return InstallResult{}, fmt.Errorf("catalog expected plugin version %q, manifest has %q", expectedVersion, parsed.Version) + } + if options.ExpectedComponents != nil { + if err := compareInstallInventory(parsed, *options.ExpectedComponents); err != nil { + return InstallResult{}, err + } + } // Hash the SAME filtered tree that copyTree installs (not just the manifest), // so a change to any installed file — a tool script, prompt, or bundled skill — @@ -127,6 +192,9 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) if err != nil { return InstallResult{}, fmt.Errorf("hash plugin: %w", err) } + if expectedHash := strings.TrimSpace(options.ExpectedHash); expectedHash != "" && hash != expectedHash { + return InstallResult{}, fmt.Errorf("catalog expected tree hash %s, filesystem has %s", expectedHash, hash) + } lock, err := ReadLock(dir) if err != nil { @@ -137,23 +205,93 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) return InstallResult{}, fmt.Errorf("%w: %q is installed from %s (use --force to overwrite)", ErrNameClash, id, previous.Source) } + installDisabled := existed && previous.Enabled != nil && !*previous.Enabled target := filepath.Join(dir, id) + if installDisabled { + target = filepath.Join(dir, disabledDirName, id) + } if err := os.MkdirAll(dir, 0o755); err != nil { return InstallResult{}, fmt.Errorf("create plugins dir: %w", err) } - if err := os.RemoveAll(target); err != nil { - return InstallResult{}, fmt.Errorf("clear previous plugin: %w", err) + if installDisabled { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return InstallResult{}, fmt.Errorf("create disabled plugins dir: %w", err) + } } + stage, err := os.MkdirTemp(dir, "."+id+"-stage-") + if err != nil { + return InstallResult{}, fmt.Errorf("create staged plugin dir: %w", err) + } + stagePublished := false + defer func() { + if !stagePublished { + _ = os.RemoveAll(stage) + } + }() // Copy the whole plugin tree (entry scripts, prompts, skills) so the installed // plugin is runnable through activation. Copy DATA only — never execute it. - if err := copyTree(pluginDir, target); err != nil { + if err := copyTree(pluginDir, stage); err != nil { return InstallResult{}, fmt.Errorf("copy plugin: %w", err) } + stagedHash, err := hashTree(stage) + if err != nil { + return InstallResult{}, fmt.Errorf("hash staged plugin: %w", err) + } + if stagedHash != hash { + return InstallResult{}, fmt.Errorf("staged plugin hash mismatch: source %s, staged %s", hash, stagedHash) + } + + backup := "" + if _, err := os.Stat(target); err == nil { + backup = filepath.Join(dir, "."+id+"-backup-*") + tempBackup, err := os.MkdirTemp(dir, "."+id+"-backup-") + if err != nil { + return InstallResult{}, fmt.Errorf("create backup dir: %w", err) + } + if err := os.Remove(tempBackup); err != nil { + return InstallResult{}, fmt.Errorf("prepare backup dir: %w", err) + } + backup = tempBackup + if err := os.Rename(target, backup); err != nil { + return InstallResult{}, fmt.Errorf("backup previous plugin: %w", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return InstallResult{}, fmt.Errorf("stat previous plugin: %w", err) + } + rolledBack := false + rollbackPublish := func() { + if rolledBack { + return + } + rolledBack = true + _ = os.RemoveAll(target) + if backup != "" { + _ = os.Rename(backup, target) + } + } + if err := os.Rename(stage, target); err != nil { + rollbackPublish() + return InstallResult{}, fmt.Errorf("publish plugin: %w", err) + } + stagePublished = true - lock[id] = LockEntry{Source: source, Hash: hash} + lock[id] = LockEntry{ + Source: source, + Hash: hash, + Catalog: strings.TrimSpace(options.Catalog), + Version: strings.TrimSpace(options.ExpectedVersion), + Commit: strings.TrimSpace(options.Commit), + Subdir: strings.TrimSpace(options.Subdir), + Enabled: previous.Enabled, + Pinned: options.Pinned, + } if err := writeLock(dir, lock); err != nil { + rollbackPublish() return InstallResult{}, err } + if backup != "" { + _ = os.RemoveAll(backup) + } result := InstallResult{ ID: id, @@ -207,6 +345,163 @@ func Remove(dir string, id string) error { return nil } +func Disable(dir string, id string) error { + dir = strings.TrimSpace(dir) + id = strings.TrimSpace(id) + if dir == "" || id == "" { + return errors.New("a plugins directory and plugin id are required") + } + if !validInstallID(id) { + return fmt.Errorf("invalid plugin id %q", id) + } + return withPluginRootLock(dir, func() error { + lock, err := ReadLock(dir) + if err != nil { + return err + } + entry := lock[id] + target := filepath.Join(dir, id) + quarantineRoot := filepath.Join(dir, disabledDirName) + quarantine := filepath.Join(quarantineRoot, id) + if _, err := os.Stat(target); err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("plugin %q is not active", id) + } + return fmt.Errorf("stat active plugin: %w", err) + } + if _, err := os.Stat(quarantine); err == nil { + return fmt.Errorf("disabled plugin %q already exists", id) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat disabled plugin: %w", err) + } + if err := verifyLockedHash(target, entry); err != nil { + return err + } + if err := os.MkdirAll(quarantineRoot, 0o755); err != nil { + return fmt.Errorf("create disabled plugins dir: %w", err) + } + if err := os.Rename(target, quarantine); err != nil { + return fmt.Errorf("quarantine plugin: %w", err) + } + enabled := false + entry.Enabled = &enabled + lock[id] = entry + if err := writeLock(dir, lock); err != nil { + _ = os.Rename(quarantine, target) + return err + } + return nil + }) +} + +func Enable(dir string, id string) error { + dir = strings.TrimSpace(dir) + id = strings.TrimSpace(id) + if dir == "" || id == "" { + return errors.New("a plugins directory and plugin id are required") + } + if !validInstallID(id) { + return fmt.Errorf("invalid plugin id %q", id) + } + return withPluginRootLock(dir, func() error { + lock, err := ReadLock(dir) + if err != nil { + return err + } + entry := lock[id] + target := filepath.Join(dir, id) + quarantine := filepath.Join(dir, disabledDirName, id) + if _, err := os.Stat(quarantine); err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("plugin %q is not disabled", id) + } + return fmt.Errorf("stat disabled plugin: %w", err) + } + if _, err := os.Stat(target); err == nil { + return fmt.Errorf("active plugin %q already exists", id) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat active plugin: %w", err) + } + if err := verifyLockedHash(quarantine, entry); err != nil { + return err + } + if err := os.Rename(quarantine, target); err != nil { + return fmt.Errorf("enable plugin: %w", err) + } + enabled := true + entry.Enabled = &enabled + lock[id] = entry + if err := writeLock(dir, lock); err != nil { + _ = os.Rename(target, quarantine) + return err + } + return nil + }) +} + +func Pin(dir string, id string, version string) (LockEntry, error) { + dir = strings.TrimSpace(dir) + id = strings.TrimSpace(id) + if dir == "" || id == "" { + return LockEntry{}, errors.New("a plugins directory and plugin id are required") + } + if !validInstallID(id) { + return LockEntry{}, fmt.Errorf("invalid plugin id %q", id) + } + var pinned LockEntry + err := withPluginRootLock(dir, func() error { + lock, err := ReadLock(dir) + if err != nil { + return err + } + entry, ok := lock[id] + if !ok { + return fmt.Errorf("plugin %q is not installed", id) + } + entry.Pinned = true + if strings.TrimSpace(version) != "" { + entry.Version = strings.TrimSpace(version) + } + lock[id] = entry + if err := writeLock(dir, lock); err != nil { + return err + } + pinned = entry + return nil + }) + return pinned, err +} + +func Unpin(dir string, id string) (LockEntry, error) { + dir = strings.TrimSpace(dir) + id = strings.TrimSpace(id) + if dir == "" || id == "" { + return LockEntry{}, errors.New("a plugins directory and plugin id are required") + } + if !validInstallID(id) { + return LockEntry{}, fmt.Errorf("invalid plugin id %q", id) + } + var unpinned LockEntry + err := withPluginRootLock(dir, func() error { + lock, err := ReadLock(dir) + if err != nil { + return err + } + entry, ok := lock[id] + if !ok { + return fmt.Errorf("plugin %q is not installed", id) + } + entry.Pinned = false + lock[id] = entry + if err := writeLock(dir, lock); err != nil { + return err + } + unpinned = entry + return nil + }) + return unpinned, err +} + // ReadLock loads the plugins lockfile from dir. A missing lockfile yields an // empty map with no error. func ReadLock(dir string) (map[string]LockEntry, error) { @@ -231,6 +526,100 @@ func ReadLock(dir string) (map[string]LockEntry, error) { return entries, nil } +func verifyLockedHash(pluginDir string, entry LockEntry) error { + if strings.TrimSpace(entry.Hash) == "" { + return nil + } + hash, err := hashTree(pluginDir) + if err != nil { + return fmt.Errorf("hash plugin: %w", err) + } + if hash != entry.Hash { + return fmt.Errorf("plugin integrity mismatch: lock has %s, filesystem has %s", entry.Hash, hash) + } + return nil +} + +func compareInstallInventory(plugin LoadedPlugin, expected InstallComponentInventory) error { + actualTools := make([]string, 0, len(plugin.Tools)) + for _, tool := range plugin.Tools { + actualTools = append(actualTools, tool.Name+":"+string(tool.Permission)) + } + expectedTools := make([]string, 0, len(expected.Tools)) + for _, tool := range expected.Tools { + expectedTools = append(expectedTools, tool.Name+":"+string(tool.Permission)) + } + if !sameSortedStrings(actualTools, expectedTools) { + return fmt.Errorf("catalog component inventory mismatch for tools: expected %v, manifest has %v", sortedStrings(expectedTools), sortedStrings(actualTools)) + } + + actualHooks := make([]string, 0, len(plugin.Hooks)) + for _, hook := range plugin.Hooks { + actualHooks = append(actualHooks, hook.Name+":"+string(hook.Event)) + } + expectedHooks := make([]string, 0, len(expected.Hooks)) + for _, hook := range expected.Hooks { + expectedHooks = append(expectedHooks, hook.Name+":"+string(hook.Event)) + } + if !sameSortedStrings(actualHooks, expectedHooks) { + return fmt.Errorf("catalog component inventory mismatch for hooks: expected %v, manifest has %v", sortedStrings(expectedHooks), sortedStrings(actualHooks)) + } + + actualSkills := make([]string, 0, len(plugin.Skills)) + for _, skill := range plugin.Skills { + actualSkills = append(actualSkills, skill.Name) + } + if !sameSortedStrings(actualSkills, expected.Skills) { + return fmt.Errorf("catalog component inventory mismatch for skills: expected %v, manifest has %v", sortedStrings(expected.Skills), sortedStrings(actualSkills)) + } + + actualPrompts := make([]string, 0, len(plugin.Prompts)) + for _, prompt := range plugin.Prompts { + actualPrompts = append(actualPrompts, prompt.Name) + } + if !sameSortedStrings(actualPrompts, expected.Prompts) { + return fmt.Errorf("catalog component inventory mismatch for prompts: expected %v, manifest has %v", sortedStrings(expected.Prompts), sortedStrings(actualPrompts)) + } + return nil +} + +func sameSortedStrings(left []string, right []string) bool { + left = sortedStrings(left) + right = sortedStrings(right) + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func sortedStrings(values []string) []string { + copied := append([]string{}, values...) + sort.Strings(copied) + return copied +} + +func withPluginRootLock(dir string, fn func() error) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create plugins dir: %w", err) + } + lockPath := filepath.Join(dir, ".plugins-root.lock") + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + if errors.Is(err, os.ErrExist) { + return fmt.Errorf("plugins root is locked") + } + return fmt.Errorf("acquire plugins root lock: %w", err) + } + _ = file.Close() + defer func() { _ = os.Remove(lockPath) }() + return fn() +} + func writeLock(dir string, entries map[string]LockEntry) error { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create plugins dir: %w", err) @@ -291,6 +680,23 @@ func defaultGitRunner(ctx context.Context, destination string, source string) er return nil } +func checkoutCommit(ctx context.Context, repo string, commit string) error { + if !installCommitPattern.MatchString(commit) { + return fmt.Errorf("commit must be a 40-character git SHA") + } + fetch := exec.CommandContext(ctx, "git", "-C", repo, "fetch", "--depth", "1", "origin", commit) + fetch.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + if output, err := fetch.CombinedOutput(); err != nil { + return fmt.Errorf("git fetch commit failed: %v: %s", err, strings.TrimSpace(string(output))) + } + checkout := exec.CommandContext(ctx, "git", "-C", repo, "checkout", "--detach", commit) + checkout.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + if output, err := checkout.CombinedOutput(); err != nil { + return fmt.Errorf("git checkout commit failed: %v: %s", err, strings.TrimSpace(string(output))) + } + return nil +} + // locatePluginDir finds the directory holding plugin.json within root: the root // itself, or exactly one immediate subdirectory. func locatePluginDir(root string) (string, error) { diff --git a/internal/plugins/integrity_test.go b/internal/plugins/integrity_test.go new file mode 100644 index 00000000..e680f7b4 --- /dev/null +++ b/internal/plugins/integrity_test.go @@ -0,0 +1,57 @@ +package plugins + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestLoadBlocksTamperedManagedPlugin(t *testing.T) { + root := t.TempDir() + src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Zero Demo", + "version": "0.1.0", + "prompts": []any{map[string]any{"name": "review", "path": "review.md"}}, + }) + if err := os.WriteFile(filepath.Join(src, "review.md"), []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: root}); err != nil { + t.Fatalf("Install: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "zero.demo", "review.md"), []byte("tampered"), 0o644); err != nil { + t.Fatal(err) + } + + result, err := Load(LoadOptions{Roots: []Root{{Source: SourceUser, Path: root}}}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(result.Plugins) != 0 { + t.Fatalf("tampered managed plugin should be blocked: %#v", result.Plugins) + } + if !hasPluginDiagnostic(result.Diagnostics, DiagnosticIntegrity, "zero.demo") { + t.Fatalf("missing integrity diagnostic: %#v", result.Diagnostics) + } +} + +func TestLoadAllowsUnmanagedPluginWithoutHash(t *testing.T) { + root := t.TempDir() + writePluginManifest(t, filepath.Join(root, "demo"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Zero Demo", + "version": "0.1.0", + }) + + result, err := Load(LoadOptions{Roots: []Root{{Source: SourceUser, Path: root}}}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(result.Plugins) != 1 || result.Plugins[0].ID != "zero.demo" { + t.Fatalf("unmanaged plugin should load: %#v", result.Plugins) + } +} diff --git a/internal/plugins/plugins.go b/internal/plugins/plugins.go index 4e9370a3..f9bf05eb 100644 --- a/internal/plugins/plugins.go +++ b/internal/plugins/plugins.go @@ -27,6 +27,7 @@ const ( DiagnosticJSON DiagnosticKind = "json" DiagnosticSchema DiagnosticKind = "schema" DiagnosticDuplicate DiagnosticKind = "duplicate" + DiagnosticIntegrity DiagnosticKind = "integrity" ) const ( @@ -245,46 +246,48 @@ func Load(options LoadOptions) (LoadResult, error) { continue } + lock, lockErr := ReadLock(rootPath) + if lockErr != nil { + diagnostics = append(diagnostics, Diagnostic{ + Kind: DiagnosticIO, + Source: root.Source, + Root: rootPath, + Message: lockErr.Error(), + }) + lock = map[string]LockEntry{} + } + for _, entry := range entries { - if !entry.IsDir() { + if !entry.IsDir() || entry.Name() == disabledDirName { continue } pluginDir := filepath.Join(rootPath, entry.Name()) - manifestPath := filepath.Join(pluginDir, "plugin.json") - data, err := os.ReadFile(manifestPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - continue - } - diagnostics = append(diagnostics, toDiagnostic(err, root, rootPath, pluginDir, manifestPath)) - continue + if plugin, ok := loadPluginDir(root, rootPath, pluginDir, false, options, lock, &diagnostics); ok { + discovered = append(discovered, plugin) } + } - var manifest any - if err := json.Unmarshal(data, &manifest); err != nil { + disabledRoot := filepath.Join(rootPath, disabledDirName) + disabledEntries, err := os.ReadDir(disabledRoot) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { diagnostics = append(diagnostics, Diagnostic{ - Kind: DiagnosticJSON, - Source: root.Source, - Root: rootPath, - PluginPath: pluginDir, - ManifestPath: manifestPath, - Message: err.Error(), + Kind: DiagnosticIO, + Source: root.Source, + Root: disabledRoot, + Message: err.Error(), }) - continue } - - plugin, err := ParseManifest(manifest, ParseManifestOptions{ - Source: root.Source, - Root: rootPath, - PluginDir: pluginDir, - ManifestPath: manifestPath, - AllowManifestToolAutoApproval: options.AllowManifestToolAutoApproval, - }) - if err != nil { - diagnostics = append(diagnostics, toDiagnostic(err, root, rootPath, pluginDir, manifestPath)) + continue + } + for _, entry := range disabledEntries { + if !entry.IsDir() { continue } - discovered = append(discovered, plugin) + pluginDir := filepath.Join(disabledRoot, entry.Name()) + if plugin, ok := loadPluginDir(root, rootPath, pluginDir, true, options, lock, &diagnostics); ok { + discovered = append(discovered, plugin) + } } } @@ -295,6 +298,74 @@ func Load(options LoadOptions) (LoadResult, error) { }, nil } +func loadPluginDir(root Root, rootPath string, pluginDir string, disabled bool, options LoadOptions, lock map[string]LockEntry, diagnostics *[]Diagnostic) (LoadedPlugin, bool) { + manifestPath := filepath.Join(pluginDir, "plugin.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return LoadedPlugin{}, false + } + *diagnostics = append(*diagnostics, toDiagnostic(err, root, rootPath, pluginDir, manifestPath)) + return LoadedPlugin{}, false + } + + var manifest any + if err := json.Unmarshal(data, &manifest); err != nil { + *diagnostics = append(*diagnostics, Diagnostic{ + Kind: DiagnosticJSON, + Source: root.Source, + Root: rootPath, + PluginPath: pluginDir, + ManifestPath: manifestPath, + Message: err.Error(), + }) + return LoadedPlugin{}, false + } + + plugin, err := ParseManifest(manifest, ParseManifestOptions{ + Source: root.Source, + Root: rootPath, + PluginDir: pluginDir, + ManifestPath: manifestPath, + AllowManifestToolAutoApproval: options.AllowManifestToolAutoApproval, + }) + if err != nil { + *diagnostics = append(*diagnostics, toDiagnostic(err, root, rootPath, pluginDir, manifestPath)) + return LoadedPlugin{}, false + } + if disabled { + plugin.Enabled = false + } + if entry, ok := lock[plugin.ID]; ok && strings.TrimSpace(entry.Hash) != "" { + hash, err := hashTree(pluginDir) + if err != nil { + *diagnostics = append(*diagnostics, Diagnostic{ + Kind: DiagnosticIntegrity, + Source: root.Source, + Root: rootPath, + PluginPath: pluginDir, + ManifestPath: manifestPath, + PluginID: plugin.ID, + Message: "failed to hash managed plugin: " + err.Error(), + }) + return LoadedPlugin{}, false + } + if hash != entry.Hash { + *diagnostics = append(*diagnostics, Diagnostic{ + Kind: DiagnosticIntegrity, + Source: root.Source, + Root: rootPath, + PluginPath: pluginDir, + ManifestPath: manifestPath, + PluginID: plugin.ID, + Message: fmt.Sprintf("managed plugin hash mismatch: lock has %s, filesystem has %s", entry.Hash, hash), + }) + return LoadedPlugin{}, false + } + } + return plugin, true +} + func ParseManifest(raw any, options ParseManifestOptions) (LoadedPlugin, error) { obj, ok := raw.(map[string]any) if !ok { From 0ce78f2dc1850f33d26cdd341e441b564d71242c Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Fri, 10 Jul 2026 23:25:50 +0530 Subject: [PATCH 03/10] feat(cli): add plugin marketplace commands --- internal/cli/distribution.go | 263 ++++++ internal/cli/extensions.go | 21 + internal/cli/marketplace_cli_test.go | 138 +++ internal/cli/marketplace_install_cli_test.go | 144 +++ internal/cli/plugin_disable_cli_test.go | 74 ++ internal/cli/plugin_marketplace.go | 853 ++++++++++++++++++ internal/cli/plugin_pin_cli_test.go | 67 ++ internal/zerocommands/backend_snapshots.go | 47 +- .../zerocommands/backend_snapshots_test.go | 35 + 9 files changed, 1641 insertions(+), 1 deletion(-) create mode 100644 internal/cli/marketplace_cli_test.go create mode 100644 internal/cli/marketplace_install_cli_test.go create mode 100644 internal/cli/plugin_disable_cli_test.go create mode 100644 internal/cli/plugin_marketplace.go create mode 100644 internal/cli/plugin_pin_cli_test.go diff --git a/internal/cli/distribution.go b/internal/cli/distribution.go index 8cf2d640..bc45b485 100644 --- a/internal/cli/distribution.go +++ b/internal/cli/distribution.go @@ -26,6 +26,17 @@ type distributionAddOptions struct { json bool } +type pluginToggleOptions struct { + json bool + scope plugins.Source +} + +type pluginPinOptions struct { + json bool + scope plugins.Source + version string +} + // parseDistributionAddArgs splits a positional source from the add flags. func parseDistributionAddArgs(args []string, label string) (string, distributionAddOptions, bool, error) { options := distributionAddOptions{} @@ -251,6 +262,113 @@ func runPluginRemove(args []string, dir string, stdout io.Writer, stderr io.Writ return exitSuccess } +func runPluginToggle(args []string, deps appDeps, stdout io.Writer, stderr io.Writer, enable bool) int { + command := "disable" + if enable { + command = "enable" + } + id, options, help, err := parsePluginToggleArgs(args, command) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writePluginToggleHelp(stdout, command); err != nil { + return exitCrash + } + return exitSuccess + } + if id == "" { + return writeExecUsageError(stderr, fmt.Sprintf("usage: zero plugins %s [--scope user|project] [--json]", command)) + } + dir, err := pluginDirForScope(deps, options.scope) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if dir == "" { + return writeAppError(stderr, "could not resolve the plugins directory", exitCrash) + } + if enable { + err = plugins.Enable(dir, id) + } else { + err = plugins.Disable(dir, id) + } + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } + if options.json { + payload := struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + Scope string `json:"scope"` + }{ID: id, Enabled: enable, Scope: string(defaultPluginScope(options.scope))} + if err := writePrettyJSON(stdout, payload); err != nil { + return exitCrash + } + return exitSuccess + } + state := "Disabled" + if enable { + state = "Enabled" + } + if _, err := fmt.Fprintf(stdout, "%s plugin %q.\n", state, id); err != nil { + return exitCrash + } + return exitSuccess +} + +func runPluginPin(args []string, deps appDeps, stdout io.Writer, stderr io.Writer, pin bool) int { + command := "unpin" + if pin { + command = "pin" + } + id, options, help, err := parsePluginPinArgs(args, command) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writePluginPinHelp(stdout, command); err != nil { + return exitCrash + } + return exitSuccess + } + if id == "" { + return writeExecUsageError(stderr, fmt.Sprintf("usage: zero plugins %s [--version ] [--scope user|project] [--json]", command)) + } + dir, err := pluginDirForScope(deps, options.scope) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + var entry plugins.LockEntry + if pin { + entry, err = plugins.Pin(dir, id, options.version) + } else { + entry, err = plugins.Unpin(dir, id) + } + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } + if options.json { + payload := struct { + ID string `json:"id"` + Pinned bool `json:"pinned"` + Version string `json:"version,omitempty"` + Scope string `json:"scope"` + }{ID: id, Pinned: entry.Pinned, Version: entry.Version, Scope: string(defaultPluginScope(options.scope))} + if err := writePrettyJSON(stdout, payload); err != nil { + return exitCrash + } + return exitSuccess + } + state := "Unpinned" + if pin { + state = "Pinned" + } + if _, err := fmt.Fprintf(stdout, "%s plugin %q.\n", state, id); err != nil { + return exitCrash + } + return exitSuccess +} + // --- tools make / list --- func runTools(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { @@ -272,6 +390,123 @@ func runTools(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) i } } +func parsePluginToggleArgs(args []string, command string) (string, pluginToggleOptions, bool, error) { + options := pluginToggleOptions{} + id := "" + for index := 0; index < len(args); index++ { + arg := args[index] + switch arg { + case "-h", "--help", "help": + return id, options, true, nil + case "--json": + options.json = true + case "--scope": + index++ + if index >= len(args) { + return id, options, false, execUsageError{"--scope requires user or project"} + } + scope, err := parsePluginScope(args[index]) + if err != nil { + return id, options, false, err + } + options.scope = scope + default: + if strings.HasPrefix(arg, "-") { + return id, options, false, execUsageError{fmt.Sprintf("unknown plugin %s flag %q", command, arg)} + } + if id != "" { + return id, options, false, execUsageError{fmt.Sprintf("plugin %s accepts a single id", command)} + } + id = arg + } + } + return id, options, false, nil +} + +func parsePluginPinArgs(args []string, command string) (string, pluginPinOptions, bool, error) { + options := pluginPinOptions{} + id := "" + for index := 0; index < len(args); index++ { + arg := args[index] + switch arg { + case "-h", "--help", "help": + return id, options, true, nil + case "--json": + options.json = true + case "--scope": + index++ + if index >= len(args) { + return id, options, false, execUsageError{"--scope requires user or project"} + } + scope, err := parsePluginScope(args[index]) + if err != nil { + return id, options, false, err + } + options.scope = scope + case "--version": + index++ + if index >= len(args) || strings.TrimSpace(args[index]) == "" { + return id, options, false, execUsageError{"--version requires a value"} + } + options.version = args[index] + default: + if strings.HasPrefix(arg, "-") { + return id, options, false, execUsageError{fmt.Sprintf("unknown plugin %s flag %q", command, arg)} + } + if id != "" { + return id, options, false, execUsageError{fmt.Sprintf("plugin %s accepts a single id", command)} + } + id = arg + } + } + if command == "unpin" && options.version != "" { + return id, options, false, execUsageError{"plugin unpin does not accept --version"} + } + return id, options, false, nil +} + +func parsePluginScope(value string) (plugins.Source, error) { + switch value { + case "", "user": + return plugins.SourceUser, nil + case "project": + return plugins.SourceProject, nil + default: + return "", execUsageError{"--scope must be user or project"} + } +} + +func defaultPluginScope(scope plugins.Source) plugins.Source { + if scope == "" { + return plugins.SourceUser + } + return scope +} + +func pluginDirForScope(deps appDeps, scope plugins.Source) (string, error) { + switch defaultPluginScope(scope) { + case plugins.SourceUser: + return deps.pluginsDir(), nil + case plugins.SourceProject: + cwd, err := deps.getwd() + if err != nil { + return "", fmt.Errorf("failed to resolve workspace: %w", err) + } + roots, err := plugins.ResolveRoots(plugins.ResolveRootOptions{Cwd: cwd}) + if err != nil { + return "", err + } + for _, root := range roots { + if root.Source == plugins.SourceProject { + return root.Path, nil + } + } + return "", fmt.Errorf("could not resolve the project plugins directory") + default: + return "", fmt.Errorf("--scope must be user or project") + } +} + type toolsMakeOptions struct { runtime string description string @@ -566,6 +801,34 @@ Removes an installed plugin directory and its plugins.lock entry. return err } +func writePluginToggleHelp(w io.Writer, command string) error { + _, err := fmt.Fprintf(w, `Usage: + zero plugins %s [flags] + +Flags: + --scope user|project Plugin scope (default user) + --json Print JSON + -h, --help Show this help +`, command) + return err +} + +func writePluginPinHelp(w io.Writer, command string) error { + versionLine := " --version Pin this version\n" + if command == "unpin" { + versionLine = "" + } + _, err := fmt.Fprintf(w, `Usage: + zero plugins %s [flags] + +Flags: +%s --scope user|project Plugin scope (default user) + --json Print JSON + -h, --help Show this help +`, command, versionLine) + return err +} + func writeToolsHelp(w io.Writer) error { _, err := fmt.Fprint(w, `Usage: zero tools diff --git a/internal/cli/extensions.go b/internal/cli/extensions.go index 1a9adae5..ae0e80fb 100644 --- a/internal/cli/extensions.go +++ b/internal/cli/extensions.go @@ -76,10 +76,24 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) return exitCrash } return exitSuccess + case "browse": + return runPluginBrowse(args[1:], stdout, stderr, deps) + case "install": + return runPluginMarketplaceInstall(args[1:], stdout, stderr, deps) + case "marketplace": + return runPluginMarketplace(args[1:], stdout, stderr, deps) case "add": return runPluginAdd(args[1:], deps.pluginsDir(), stdout, stderr) case "remove", "rm": return runPluginRemove(args[1:], deps.pluginsDir(), stdout, stderr) + case "enable": + return runPluginToggle(args[1:], deps, stdout, stderr, true) + case "disable": + return runPluginToggle(args[1:], deps, stdout, stderr, false) + case "pin": + return runPluginPin(args[1:], deps, stdout, stderr, true) + case "unpin": + return runPluginPin(args[1:], deps, stdout, stderr, false) default: return writeExecUsageError(stderr, fmt.Sprintf("unknown plugins subcommand %q", args[0])) } @@ -561,8 +575,15 @@ func writePluginsHelp(w io.Writer) error { Commands: list List local Zero plugins + browse [query] Browse marketplace plugins + install Install a marketplace plugin add Install a plugin (manifest-validated, pinned in plugins.lock) remove Remove an installed plugin and its lockfile entry + enable Enable a quarantined plugin + disable Disable a plugin by moving it to .disabled + pin Pin a marketplace-managed plugin + unpin Unpin a marketplace-managed plugin + marketplace Manage plugin catalogs `) return err } diff --git a/internal/cli/marketplace_cli_test.go b/internal/cli/marketplace_cli_test.go new file mode 100644 index 00000000..2ca8ac1b --- /dev/null +++ b/internal/cli/marketplace_cli_test.go @@ -0,0 +1,138 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunPluginsMarketplaceValidateJSON(t *testing.T) { + catalogPath := writeMarketplaceTestCatalog(t) + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"plugins", "marketplace", "validate", catalogPath, "--json"}, &stdout, &stderr, appDeps{}) + if exitCode != exitSuccess { + t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String()) + } + + var payload struct { + Catalog struct { + ID string `json:"id"` + } `json:"catalog"` + Verification struct { + Status string `json:"status"` + } `json:"verification"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("validate JSON failed to decode: %v\n%s", err, stdout.String()) + } + if payload.Catalog.ID != "team" || payload.Verification.Status != "unsigned" { + t.Fatalf("unexpected validate payload: %#v", payload) + } +} + +func TestRunPluginsMarketplaceAddListAndBrowse(t *testing.T) { + configHome := filepath.Join(t.TempDir(), "config") + t.Setenv("XDG_CONFIG_HOME", configHome) + catalogPath := writeMarketplaceTestCatalog(t) + cwd := t.TempDir() + deps := appDeps{getwd: func() (string, error) { return cwd, nil }} + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"plugins", "marketplace", "add", catalogPath, "--allow-unverified", "--json"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("add exitCode = %d stderr=%s", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), `"id": "team"`) || !strings.Contains(stdout.String(), `"verificationStatus": "unsigned"`) { + t.Fatalf("unexpected add output: %s", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + exitCode = runWithDeps([]string{"plugins", "marketplace", "list", "--json"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("list exitCode = %d stderr=%s", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), `"id": "official"`) || !strings.Contains(stdout.String(), `"id": "team"`) { + t.Fatalf("marketplace list missing catalogs: %s", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + exitCode = runWithDeps([]string{"plugins", "browse", "lookup", "--catalog", "team", "--json"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("browse exitCode = %d stderr=%s", exitCode, stderr.String()) + } + var browse struct { + Catalog string `json:"catalog"` + Plugins []struct { + ID string `json:"id"` + } `json:"plugins"` + } + if err := json.Unmarshal(stdout.Bytes(), &browse); err != nil { + t.Fatalf("browse JSON failed to decode: %v\n%s", err, stdout.String()) + } + if browse.Catalog != "team" || len(browse.Plugins) != 1 || browse.Plugins[0].ID != "zero.demo" { + t.Fatalf("unexpected browse output: %#v", browse) + } +} + +func TestRunPluginsMarketplaceAddRequiresAllowUnverified(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) + catalogPath := writeMarketplaceTestCatalog(t) + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"plugins", "marketplace", "add", catalogPath}, &stdout, &stderr, appDeps{}) + if exitCode != exitUsage { + t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String()) + } + if !strings.Contains(stderr.String(), "--allow-unverified") { + t.Fatalf("expected allow-unverified guidance, got %s", stderr.String()) + } +} + +func writeMarketplaceTestCatalog(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "catalog.json") + body := `{ + "schemaVersion": 1, + "id": "team", + "owner": "Platform", + "description": "Team plugins", + "plugins": [ + { + "id": "zero.demo", + "name": "Demo", + "description": "Lookup helper", + "author": {"name": "Platform"}, + "license": "MIT", + "tags": ["lookup"], + "category": "productivity", + "review": {"status": "community"}, + "releases": [ + { + "version": "0.1.0", + "repository": "https://github.com/Gitlawb/zero-demo-plugin.git", + "commit": "` + strings.Repeat("a", 40) + `", + "treeHash": "sha256:` + strings.Repeat("b", 64) + `", + "components": { + "tools": [{"name": "lookup", "permission": "prompt"}], + "hooks": [{"name": "preflight", "event": "beforeTool"}] + } + } + ] + } + ] +}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} diff --git a/internal/cli/marketplace_install_cli_test.go b/internal/cli/marketplace_install_cli_test.go new file mode 100644 index 00000000..a8e79127 --- /dev/null +++ b/internal/cli/marketplace_install_cli_test.go @@ -0,0 +1,144 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/plugins" +) + +func TestRunPluginsInstallFromMarketplaceCatalog(t *testing.T) { + configHome := filepath.Join(t.TempDir(), "config") + t.Setenv("XDG_CONFIG_HOME", configHome) + pluginsDir := t.TempDir() + source := writeSourcePluginDir(t, filepath.Join(t.TempDir(), "src"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Zero Demo", + "version": "0.1.0", + "tools": []any{map[string]any{ + "name": "lookup", + "command": "node", + "permission": "prompt", + }}, + }) + hashRoot := t.TempDir() + hashResult, err := plugins.Install(context.Background(), plugins.InstallOptions{Source: source, Dir: hashRoot}) + if err != nil { + t.Fatalf("seed hash install: %v", err) + } + catalogPath := writeMarketplaceInstallCatalog(t, source, hashResult.Hash) + deps := appDeps{ + getwd: func() (string, error) { return t.TempDir(), nil }, + pluginsDir: func() string { return pluginsDir }, + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + if exit := runWithDeps([]string{"plugins", "marketplace", "add", catalogPath, "--allow-unverified"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("marketplace add exit = %d stderr=%s", exit, stderr.String()) + } + + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "install", "zero.demo@team", "--yes", "--json"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("plugins install exit = %d stderr=%s", exit, stderr.String()) + } + var payload struct { + ID string `json:"id"` + Version string `json:"version"` + Catalog string `json:"catalog"` + Hash string `json:"hash"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("install JSON: %v\n%s", err, stdout.String()) + } + if payload.ID != "zero.demo" || payload.Version != "0.1.0" || payload.Catalog != "team" || payload.Hash != hashResult.Hash { + t.Fatalf("unexpected install payload: %#v", payload) + } + if _, err := os.Stat(filepath.Join(pluginsDir, "zero.demo", "plugin.json")); err != nil { + t.Fatalf("plugin not installed: %v", err) + } + lock, err := plugins.ReadLock(pluginsDir) + if err != nil { + t.Fatalf("ReadLock: %v", err) + } + entry := lock["zero.demo"] + if entry.Catalog != "team" || entry.Version != "0.1.0" || entry.Commit != strings.Repeat("a", 40) { + t.Fatalf("marketplace lock metadata missing: %#v", entry) + } +} + +func TestRunPluginsInstallFromMarketplaceRequiresYes(t *testing.T) { + configHome := filepath.Join(t.TempDir(), "config") + t.Setenv("XDG_CONFIG_HOME", configHome) + source := writeSourcePluginDir(t, filepath.Join(t.TempDir(), "src"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Zero Demo", + "version": "0.1.0", + }) + hashRoot := t.TempDir() + hashResult, err := plugins.Install(context.Background(), plugins.InstallOptions{Source: source, Dir: hashRoot}) + if err != nil { + t.Fatalf("seed hash install: %v", err) + } + catalogPath := writeMarketplaceInstallCatalog(t, source, hashResult.Hash) + deps := appDeps{ + getwd: func() (string, error) { return t.TempDir(), nil }, + pluginsDir: func() string { return t.TempDir() }, + } + var stdout bytes.Buffer + var stderr bytes.Buffer + if exit := runWithDeps([]string{"plugins", "marketplace", "add", catalogPath, "--allow-unverified"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("marketplace add exit = %d stderr=%s", exit, stderr.String()) + } + + stdout.Reset() + stderr.Reset() + exit := runWithDeps([]string{"plugins", "install", "zero.demo@team"}, &stdout, &stderr, deps) + if exit != exitUsage { + t.Fatalf("install without yes exit = %d stderr=%s", exit, stderr.String()) + } + if !strings.Contains(stderr.String(), "--yes") { + t.Fatalf("expected --yes guidance, got %s", stderr.String()) + } +} + +func writeMarketplaceInstallCatalog(t *testing.T, source string, hash string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "catalog.json") + body := `{ + "schemaVersion": 1, + "id": "team", + "owner": "Platform", + "plugins": [ + { + "id": "zero.demo", + "name": "Zero Demo", + "author": {"name": "Platform"}, + "license": "MIT", + "review": {"status": "community"}, + "releases": [ + { + "version": "0.1.0", + "repository": "` + filepath.ToSlash(source) + `", + "commit": "` + strings.Repeat("a", 40) + `", + "treeHash": "` + hash + `", + "components": {"tools": [{"name": "lookup", "permission": "prompt"}]} + } + ] + } + ] +}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} diff --git a/internal/cli/plugin_disable_cli_test.go b/internal/cli/plugin_disable_cli_test.go new file mode 100644 index 00000000..38937066 --- /dev/null +++ b/internal/cli/plugin_disable_cli_test.go @@ -0,0 +1,74 @@ +package cli + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/plugins" +) + +func TestRunPluginDisableEnableJSON(t *testing.T) { + pluginsDir := t.TempDir() + src := writeSourcePluginDir(t, filepath.Join(t.TempDir(), "src"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Zero Demo", + "version": "0.1.0", + }) + deps := appDeps{ + pluginsDir: func() string { return pluginsDir }, + loadPlugins: func(options plugins.LoadOptions) (plugins.LoadResult, error) { + return plugins.Load(plugins.LoadOptions{Roots: []plugins.Root{{Source: plugins.SourceUser, Path: pluginsDir}}}) + }, + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + if exit := runWithDeps([]string{"plugin", "add", src}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("plugin add exit = %d stderr=%s", exit, stderr.String()) + } + + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "disable", "zero.demo", "--json"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("plugin disable exit = %d stderr=%s", exit, stderr.String()) + } + var disabled struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal(stdout.Bytes(), &disabled); err != nil { + t.Fatalf("disable JSON: %v\n%s", err, stdout.String()) + } + if disabled.ID != "zero.demo" || disabled.Enabled { + t.Fatalf("unexpected disable payload: %#v", disabled) + } + + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "list"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("plugins list exit = %d stderr=%s", exit, stderr.String()) + } + if !strings.Contains(stdout.String(), "disabled") { + t.Fatalf("plugins list should show disabled state:\n%s", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "enable", "zero.demo", "--json"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("plugin enable exit = %d stderr=%s", exit, stderr.String()) + } + var enabled struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal(stdout.Bytes(), &enabled); err != nil { + t.Fatalf("enable JSON: %v\n%s", err, stdout.String()) + } + if enabled.ID != "zero.demo" || !enabled.Enabled { + t.Fatalf("unexpected enable payload: %#v", enabled) + } +} diff --git a/internal/cli/plugin_marketplace.go b/internal/cli/plugin_marketplace.go new file mode 100644 index 00000000..dfe903a8 --- /dev/null +++ b/internal/cli/plugin_marketplace.go @@ -0,0 +1,853 @@ +package cli + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "os" + "strings" + + "github.com/Gitlawb/zero/internal/marketplace" + "github.com/Gitlawb/zero/internal/plugins" + "github.com/Gitlawb/zero/internal/redaction" +) + +type marketplaceCommandOptions struct { + json bool + scope marketplace.Scope + catalogID string + publicKeyPath string + allowUnverified bool + version string + yes bool +} + +func runPluginMarketplace(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + if len(args) == 0 { + return writeExecUsageError(stderr, "marketplace subcommand required. Use `zero plugins marketplace list`.") + } + switch args[0] { + case "-h", "--help", "help": + if err := writeMarketplaceHelp(stdout); err != nil { + return exitCrash + } + return exitSuccess + case "add": + return runMarketplaceAdd(args[1:], stdout, stderr, deps) + case "list": + return runMarketplaceList(args[1:], stdout, stderr, deps) + case "remove", "rm": + return runMarketplaceRemove(args[1:], stdout, stderr, deps) + case "validate": + return runMarketplaceValidate(args[1:], stdout, stderr) + default: + return writeExecUsageError(stderr, fmt.Sprintf("unknown marketplace subcommand %q", args[0])) + } +} + +func runPluginBrowse(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + query, options, help, err := parseBrowseArgs(args) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writePluginBrowseHelp(stdout); err != nil { + return exitCrash + } + return exitSuccess + } + if options.catalogID == "" { + options.catalogID = marketplace.OfficialCatalogID + } + cwd, err := deps.getwd() + if err != nil { + return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) + } + catalogEntry, ok, err := findRegisteredCatalog(options.catalogID, cwd) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + if !ok { + return writeExecUsageError(stderr, fmt.Sprintf("marketplace catalog %q is not registered", options.catalogID)) + } + catalog, verification, err := loadLocalCatalogEntry(catalogEntry) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } + matches := marketplace.Search(catalog, query) + if options.json { + payload := struct { + Catalog string `json:"catalog"` + Verification marketplace.Verification `json:"verification"` + Plugins []marketplace.CatalogPlugin `json:"plugins"` + }{Catalog: catalog.ID, Verification: verification, Plugins: matches} + if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintln(stdout, redaction.RedactString(formatMarketplaceBrowse(catalog.ID, matches, verification), redaction.Options{})); err != nil { + return exitCrash + } + return exitSuccess +} + +func runPluginMarketplaceInstall(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + target, options, help, err := parseMarketplaceInstallArgs(args) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writePluginMarketplaceInstallHelp(stdout); err != nil { + return exitCrash + } + return exitSuccess + } + if target == "" { + return writeExecUsageError(stderr, "usage: zero plugins install [--version ] [--scope user|project] [--yes] [--json]") + } + if !options.yes { + return writeExecUsageError(stderr, "marketplace installs require --yes") + } + pluginID, catalogID := splitMarketplaceTarget(target) + cwd, err := deps.getwd() + if err != nil { + return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) + } + resolved, err := resolveMarketplacePlugin(cwd, pluginID, catalogID) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + release, ok := selectRelease(resolved.plugin, options.version) + if !ok { + if options.version == "" { + return writeExecUsageError(stderr, fmt.Sprintf("marketplace plugin %q has no releases", pluginID)) + } + return writeExecUsageError(stderr, fmt.Sprintf("marketplace plugin %q has no version %q", pluginID, options.version)) + } + if options.scope == "" { + options.scope = marketplace.ScopeUser + } + pluginScope := plugins.SourceUser + if options.scope == marketplace.ScopeProject { + pluginScope = plugins.SourceProject + } + dir, err := pluginDirForScope(deps, pluginScope) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + result, err := plugins.Install(context.Background(), plugins.InstallOptions{ + Source: release.Repository, + Dir: dir, + ExpectedID: resolved.plugin.ID, + ExpectedVersion: release.Version, + ExpectedComponents: marketplaceInstallInventory(release.Components), + ExpectedHash: release.TreeHash, + Commit: release.Commit, + Subdir: release.Subdir, + Catalog: resolved.catalog.ID, + }) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } + payload := struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + ManifestPath string `json:"manifestPath"` + Hash string `json:"hash"` + Source string `json:"source"` + Updated bool `json:"updated"` + PreviousHash string `json:"previousHash,omitempty"` + Catalog string `json:"catalog"` + Risk marketplace.RiskReport `json:"risk"` + }{ + ID: result.ID, + Name: result.Name, + Version: result.Version, + ManifestPath: result.ManifestPath, + Hash: result.Hash, + Source: result.Source, + Updated: result.Updated, + PreviousHash: result.PreviousHash, + Catalog: resolved.catalog.ID, + Risk: marketplace.RiskForRelease(release), + } + if options.json { + if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintf(stdout, "Installed plugin %s@%s from %s.\n hash: %s\n", result.ID, result.Version, resolved.catalog.ID, result.Hash); err != nil { + return exitCrash + } + return exitSuccess +} + +func marketplaceInstallInventory(components marketplace.ComponentInventory) *plugins.InstallComponentInventory { + inventory := plugins.InstallComponentInventory{ + Tools: make([]plugins.InstallToolComponent, 0, len(components.Tools)), + Hooks: make([]plugins.InstallHookComponent, 0, len(components.Hooks)), + Skills: make([]string, 0, len(components.Skills)), + Prompts: make([]string, 0, len(components.Prompts)), + } + for _, tool := range components.Tools { + inventory.Tools = append(inventory.Tools, plugins.InstallToolComponent{Name: tool.Name, Permission: tool.Permission}) + } + for _, hook := range components.Hooks { + inventory.Hooks = append(inventory.Hooks, plugins.InstallHookComponent{Name: hook.Name, Event: hook.Event}) + } + for _, skill := range components.Skills { + inventory.Skills = append(inventory.Skills, skill.Name) + } + for _, prompt := range components.Prompts { + inventory.Prompts = append(inventory.Prompts, prompt.Name) + } + return &inventory +} + +func runMarketplaceValidate(args []string, stdout io.Writer, stderr io.Writer) int { + path, options, help, err := parseMarketplacePathArgs(args, "marketplace validate") + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writeMarketplaceValidateHelp(stdout); err != nil { + return exitCrash + } + return exitSuccess + } + if path == "" { + return writeExecUsageError(stderr, "usage: zero plugins marketplace validate [--json] [--public-key ]") + } + catalog, verification, err := validateCatalogPath(path, options.publicKeyPath) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } + if options.json { + payload := struct { + Catalog marketplace.Catalog `json:"catalog"` + Verification marketplace.Verification `json:"verification"` + }{Catalog: catalog, Verification: verification} + if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintf(stdout, "Validated marketplace catalog %s (%s).\n", catalog.ID, verification.Status); err != nil { + return exitCrash + } + return exitSuccess +} + +func runMarketplaceAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + source, options, help, err := parseMarketplaceAddArgs(args) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writeMarketplaceAddHelp(stdout); err != nil { + return exitCrash + } + return exitSuccess + } + if source == "" { + return writeExecUsageError(stderr, "usage: zero plugins marketplace add [--scope user|project] [--public-key ] [--allow-unverified] [--json]") + } + if options.scope == "" { + options.scope = marketplace.ScopeUser + } + cwd, err := deps.getwd() + if err != nil { + return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) + } + parsedSource, err := marketplace.ParseCatalogSource(source) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if parsedSource.Kind != marketplace.CatalogSourceLocal { + return writeExecUsageError(stderr, "only local catalog files can be added before a marketplace cache is created") + } + catalog, verification, err := validateCatalogPath(source, options.publicKeyPath) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } + if verification.Status != marketplace.VerificationSigned && !options.allowUnverified { + return writeExecUsageError(stderr, "unsigned marketplace catalogs require --allow-unverified") + } + path, err := marketplace.RegistryPathForScope(options.scope, cwd, nil) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + registry, err := marketplace.LoadRegistry(path) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + entry := marketplace.RegisteredCatalog{ + ID: catalog.ID, + Source: source, + PublicKeyPath: options.publicKeyPath, + VerificationStatus: verification.Status, + } + if err := registry.Add(entry); err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if err := marketplace.SaveRegistry(path, registry); err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + if options.json { + if err := writePrettyJSON(stdout, redaction.RedactValue(entry, redaction.Options{})); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintf(stdout, "Added marketplace catalog %s (%s).\n", entry.ID, entry.VerificationStatus); err != nil { + return exitCrash + } + return exitSuccess +} + +func runMarketplaceList(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + options, help, err := parseMarketplaceListArgs(args) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writeMarketplaceListHelp(stdout); err != nil { + return exitCrash + } + return exitSuccess + } + cwd, err := deps.getwd() + if err != nil { + return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) + } + catalogs, err := registeredCatalogs(cwd) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + if options.json { + payload := struct { + Catalogs []marketplace.RegisteredCatalog `json:"catalogs"` + }{Catalogs: catalogs} + if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintln(stdout, redaction.RedactString(formatMarketplaceList(catalogs), redaction.Options{})); err != nil { + return exitCrash + } + return exitSuccess +} + +func runMarketplaceRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + id, options, help, err := parseMarketplaceIDArgs(args, "marketplace remove") + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writeMarketplaceRemoveHelp(stdout); err != nil { + return exitCrash + } + return exitSuccess + } + if id == "" { + return writeExecUsageError(stderr, "usage: zero plugins marketplace remove [--scope user|project] [--json]") + } + if id == marketplace.OfficialCatalogID { + return writeExecUsageError(stderr, "official marketplace catalog cannot be removed") + } + cwd, err := deps.getwd() + if err != nil { + return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) + } + if options.scope == "" { + options.scope = marketplace.ScopeUser + } + path, err := marketplace.RegistryPathForScope(options.scope, cwd, nil) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + registry, err := marketplace.LoadRegistry(path) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + removed := false + filtered := registry.Catalogs[:0:0] + for _, catalog := range registry.Catalogs { + if catalog.ID == id { + removed = true + continue + } + filtered = append(filtered, catalog) + } + if !removed { + return writeExecUsageError(stderr, fmt.Sprintf("marketplace catalog %q is not registered", id)) + } + registry.Catalogs = filtered + if err := marketplace.SaveRegistry(path, registry); err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + if options.json { + if err := writePrettyJSON(stdout, map[string]any{"id": id, "removed": true}); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintf(stdout, "Removed marketplace catalog %s.\n", id); err != nil { + return exitCrash + } + return exitSuccess +} + +func validateCatalogPath(path string, publicKeyPath string) (marketplace.Catalog, marketplace.Verification, error) { + data, signature, err := marketplace.ReadLocalCatalog(path) + if err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, err + } + catalog, err := marketplace.ParseCatalog(data) + if err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, err + } + if publicKeyPath == "" { + return catalog, marketplace.Verification{Status: marketplace.VerificationUnsigned}, nil + } + publicKey, err := readEd25519PublicKey(publicKeyPath) + if err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, err + } + return catalog, marketplace.VerifyCatalogSignature(data, signature, publicKey), nil +} + +func loadLocalCatalogEntry(entry marketplace.RegisteredCatalog) (marketplace.Catalog, marketplace.Verification, error) { + source, err := marketplace.ParseCatalogSource(entry.Source) + if err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, err + } + if source.Kind != marketplace.CatalogSourceLocal { + return marketplace.Catalog{}, marketplace.Verification{}, fmt.Errorf("catalog %q has no local cache; run `zero plugins marketplace update %s`", entry.ID, entry.ID) + } + return validateCatalogPath(entry.Source, entry.PublicKeyPath) +} + +func registeredCatalogs(cwd string) ([]marketplace.RegisteredCatalog, error) { + catalogs := []marketplace.RegisteredCatalog{{ + ID: marketplace.OfficialCatalogID, + Source: marketplace.OfficialCatalogSource, + VerificationStatus: marketplace.VerificationUnsigned, + }} + for _, scope := range []marketplace.Scope{marketplace.ScopeUser, marketplace.ScopeProject} { + path, err := marketplace.RegistryPathForScope(scope, cwd, nil) + if err != nil { + return nil, err + } + registry, err := marketplace.LoadRegistry(path) + if err != nil { + return nil, err + } + catalogs = append(catalogs, registry.Catalogs...) + } + return catalogs, nil +} + +func findRegisteredCatalog(id string, cwd string) (marketplace.RegisteredCatalog, bool, error) { + catalogs, err := registeredCatalogs(cwd) + if err != nil { + return marketplace.RegisteredCatalog{}, false, err + } + for _, catalog := range catalogs { + if catalog.ID == id { + return catalog, true, nil + } + } + return marketplace.RegisteredCatalog{}, false, nil +} + +type resolvedMarketplacePlugin struct { + entry marketplace.RegisteredCatalog + catalog marketplace.Catalog + plugin marketplace.CatalogPlugin +} + +func resolveMarketplacePlugin(cwd string, pluginID string, catalogID string) (resolvedMarketplacePlugin, error) { + if strings.TrimSpace(pluginID) == "" { + return resolvedMarketplacePlugin{}, fmt.Errorf("plugin id is required") + } + catalogs, err := registeredCatalogs(cwd) + if err != nil { + return resolvedMarketplacePlugin{}, err + } + matches := []resolvedMarketplacePlugin{} + for _, entry := range catalogs { + if catalogID != "" && entry.ID != catalogID { + continue + } + catalog, _, err := loadLocalCatalogEntry(entry) + if err != nil { + if catalogID != "" { + return resolvedMarketplacePlugin{}, err + } + continue + } + for _, plugin := range catalog.Plugins { + if plugin.ID == pluginID { + matches = append(matches, resolvedMarketplacePlugin{entry: entry, catalog: catalog, plugin: plugin}) + } + } + } + if len(matches) == 0 { + if catalogID != "" { + return resolvedMarketplacePlugin{}, fmt.Errorf("marketplace plugin %q not found in catalog %q", pluginID, catalogID) + } + return resolvedMarketplacePlugin{}, fmt.Errorf("marketplace plugin %q not found", pluginID) + } + if len(matches) > 1 { + ids := make([]string, 0, len(matches)) + for _, match := range matches { + ids = append(ids, match.catalog.ID) + } + return resolvedMarketplacePlugin{}, fmt.Errorf("marketplace plugin %q is ambiguous; use %s@ (%s)", pluginID, pluginID, strings.Join(ids, ", ")) + } + return matches[0], nil +} + +func splitMarketplaceTarget(target string) (string, string) { + pluginID := strings.TrimSpace(target) + catalogID := "" + if before, after, found := strings.Cut(pluginID, "@"); found { + pluginID = before + catalogID = after + } + return pluginID, catalogID +} + +func selectRelease(plugin marketplace.CatalogPlugin, version string) (marketplace.Release, bool) { + if len(plugin.Releases) == 0 { + return marketplace.Release{}, false + } + if strings.TrimSpace(version) != "" { + for _, release := range plugin.Releases { + if release.Version == version { + return release, true + } + } + return marketplace.Release{}, false + } + return plugin.Releases[0], true +} + +func readEd25519PublicKey(path string) (ed25519.PublicKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read public key: %w", err) + } + trimmed := strings.TrimSpace(string(data)) + if decoded, err := hex.DecodeString(strings.TrimPrefix(trimmed, "0x")); err == nil && len(decoded) == ed25519.PublicKeySize { + return ed25519.PublicKey(decoded), nil + } + if decoded, err := base64.StdEncoding.DecodeString(trimmed); err == nil && len(decoded) == ed25519.PublicKeySize { + return ed25519.PublicKey(decoded), nil + } + if len(data) == ed25519.PublicKeySize { + return ed25519.PublicKey(data), nil + } + return nil, fmt.Errorf("public key must be raw, hex, or base64 Ed25519 public key bytes") +} + +func parseBrowseArgs(args []string) (string, marketplaceCommandOptions, bool, error) { + options := marketplaceCommandOptions{} + query := "" + for index := 0; index < len(args); index++ { + arg := args[index] + switch arg { + case "-h", "--help", "help": + return query, options, true, nil + case "--json": + options.json = true + case "--catalog": + index++ + if index >= len(args) || strings.TrimSpace(args[index]) == "" { + return query, options, false, execUsageError{"--catalog requires an id"} + } + options.catalogID = args[index] + case "--refresh": + return query, options, false, execUsageError{"--refresh is not available before marketplace cache update support"} + default: + if strings.HasPrefix(arg, "-") { + return query, options, false, execUsageError{fmt.Sprintf("unknown plugins browse flag %q", arg)} + } + if query != "" { + return query, options, false, execUsageError{"plugins browse accepts at most one query"} + } + query = arg + } + } + return query, options, false, nil +} + +func parseMarketplaceAddArgs(args []string) (string, marketplaceCommandOptions, bool, error) { + options := marketplaceCommandOptions{} + source := "" + for index := 0; index < len(args); index++ { + arg := args[index] + switch arg { + case "-h", "--help", "help": + return source, options, true, nil + case "--json": + options.json = true + case "--allow-unverified": + options.allowUnverified = true + case "--scope": + index++ + if index >= len(args) { + return source, options, false, execUsageError{"--scope requires user or project"} + } + scope, err := parseMarketplaceScope(args[index]) + if err != nil { + return source, options, false, err + } + options.scope = scope + case "--public-key": + index++ + if index >= len(args) || strings.TrimSpace(args[index]) == "" { + return source, options, false, execUsageError{"--public-key requires a file"} + } + options.publicKeyPath = args[index] + default: + if strings.HasPrefix(arg, "-") { + return source, options, false, execUsageError{fmt.Sprintf("unknown marketplace add flag %q", arg)} + } + if source != "" { + return source, options, false, execUsageError{"marketplace add accepts a single source"} + } + source = arg + } + } + return source, options, false, nil +} + +func parseMarketplaceInstallArgs(args []string) (string, marketplaceCommandOptions, bool, error) { + options := marketplaceCommandOptions{} + target := "" + for index := 0; index < len(args); index++ { + arg := args[index] + switch arg { + case "-h", "--help", "help": + return target, options, true, nil + case "--json": + options.json = true + case "--yes", "-y": + options.yes = true + case "--version": + index++ + if index >= len(args) || strings.TrimSpace(args[index]) == "" { + return target, options, false, execUsageError{"--version requires a value"} + } + options.version = args[index] + case "--scope": + index++ + if index >= len(args) { + return target, options, false, execUsageError{"--scope requires user or project"} + } + scope, err := parseMarketplaceScope(args[index]) + if err != nil { + return target, options, false, err + } + options.scope = scope + default: + if strings.HasPrefix(arg, "-") { + return target, options, false, execUsageError{fmt.Sprintf("unknown plugins install flag %q", arg)} + } + if target != "" { + return target, options, false, execUsageError{"plugins install accepts a single plugin id"} + } + target = arg + } + } + return target, options, false, nil +} + +func parseMarketplacePathArgs(args []string, label string) (string, marketplaceCommandOptions, bool, error) { + options := marketplaceCommandOptions{} + path := "" + for index := 0; index < len(args); index++ { + arg := args[index] + switch arg { + case "-h", "--help", "help": + return path, options, true, nil + case "--json": + options.json = true + case "--public-key": + index++ + if index >= len(args) || strings.TrimSpace(args[index]) == "" { + return path, options, false, execUsageError{"--public-key requires a file"} + } + options.publicKeyPath = args[index] + default: + if strings.HasPrefix(arg, "-") { + return path, options, false, execUsageError{fmt.Sprintf("unknown %s flag %q", label, arg)} + } + if path != "" { + return path, options, false, execUsageError{label + " accepts a single path"} + } + path = arg + } + } + return path, options, false, nil +} + +func parseMarketplaceListArgs(args []string) (marketplaceCommandOptions, bool, error) { + options := marketplaceCommandOptions{} + for _, arg := range args { + switch arg { + case "-h", "--help", "help": + return options, true, nil + case "--json": + options.json = true + default: + return options, false, execUsageError{fmt.Sprintf("unknown marketplace list flag %q", arg)} + } + } + return options, false, nil +} + +func parseMarketplaceIDArgs(args []string, label string) (string, marketplaceCommandOptions, bool, error) { + options := marketplaceCommandOptions{} + id := "" + for index := 0; index < len(args); index++ { + arg := args[index] + switch arg { + case "-h", "--help", "help": + return id, options, true, nil + case "--json": + options.json = true + case "--scope": + index++ + if index >= len(args) { + return id, options, false, execUsageError{"--scope requires user or project"} + } + scope, err := parseMarketplaceScope(args[index]) + if err != nil { + return id, options, false, err + } + options.scope = scope + default: + if strings.HasPrefix(arg, "-") { + return id, options, false, execUsageError{fmt.Sprintf("unknown %s flag %q", label, arg)} + } + if id != "" { + return id, options, false, execUsageError{label + " accepts a single id"} + } + id = arg + } + } + return id, options, false, nil +} + +func parseMarketplaceScope(value string) (marketplace.Scope, error) { + switch marketplace.Scope(value) { + case marketplace.ScopeUser, marketplace.ScopeProject: + return marketplace.Scope(value), nil + default: + return "", execUsageError{"--scope must be user or project"} + } +} + +func formatMarketplaceBrowse(catalogID string, plugins []marketplace.CatalogPlugin, verification marketplace.Verification) string { + lines := []string{fmt.Sprintf("Marketplace Plugins (%s, %s):", catalogID, verification.Status)} + if len(plugins) == 0 { + return strings.Join(append(lines, " No plugins found."), "\n") + } + for _, plugin := range plugins { + version := "" + if len(plugin.Releases) > 0 { + version = "@" + plugin.Releases[0].Version + } + lines = append(lines, fmt.Sprintf(" %s%s %s [%s] - %s", plugin.ID, version, plugin.Name, plugin.Review.Status, plugin.Description)) + } + return strings.Join(lines, "\n") +} + +func formatMarketplaceList(catalogs []marketplace.RegisteredCatalog) string { + lines := []string{"Marketplace Catalogs:"} + for _, catalog := range catalogs { + status := catalog.VerificationStatus + if status == "" { + status = marketplace.VerificationUnsigned + } + lines = append(lines, fmt.Sprintf(" %s [%s] %s", catalog.ID, status, catalog.Source)) + } + return strings.Join(lines, "\n") +} + +func writeMarketplaceHelp(w io.Writer) error { + _, err := fmt.Fprint(w, `Usage: + zero plugins marketplace + +Commands: + add Add a plugin catalog + list List configured plugin catalogs + remove Remove a custom plugin catalog + validate Validate a catalog.json file +`) + return err +} + +func writePluginBrowseHelp(w io.Writer) error { + _, err := fmt.Fprint(w, `Usage: + zero plugins browse [query] [--catalog ] [--json] +`) + return err +} + +func writePluginMarketplaceInstallHelp(w io.Writer) error { + _, err := fmt.Fprint(w, `Usage: + zero plugins install [flags] + +Flags: + --version Install exact release and pin lock metadata + --scope user|project Install scope (default user) + --yes Confirm install from catalog metadata + --json Print JSON +`) + return err +} + +func writeMarketplaceAddHelp(w io.Writer) error { + _, err := fmt.Fprint(w, `Usage: + zero plugins marketplace add [flags] + +Flags: + --scope user|project Registry scope (default user) + --public-key Ed25519 public key for signature verification + --allow-unverified Allow unsigned catalogs + --json Print JSON +`) + return err +} + +func writeMarketplaceListHelp(w io.Writer) error { + _, err := fmt.Fprint(w, `Usage: + zero plugins marketplace list [--json] +`) + return err +} + +func writeMarketplaceRemoveHelp(w io.Writer) error { + _, err := fmt.Fprint(w, `Usage: + zero plugins marketplace remove [--scope user|project] [--json] +`) + return err +} + +func writeMarketplaceValidateHelp(w io.Writer) error { + _, err := fmt.Fprint(w, `Usage: + zero plugins marketplace validate [--json] [--public-key ] +`) + return err +} diff --git a/internal/cli/plugin_pin_cli_test.go b/internal/cli/plugin_pin_cli_test.go new file mode 100644 index 00000000..c63a2ecc --- /dev/null +++ b/internal/cli/plugin_pin_cli_test.go @@ -0,0 +1,67 @@ +package cli + +import ( + "bytes" + "encoding/json" + "path/filepath" + "testing" + + "github.com/Gitlawb/zero/internal/plugins" +) + +func TestRunPluginPinUnpinJSON(t *testing.T) { + pluginsDir := t.TempDir() + src := writeSourcePluginDir(t, filepath.Join(t.TempDir(), "src"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Zero Demo", + "version": "0.1.0", + }) + deps := appDeps{pluginsDir: func() string { return pluginsDir }} + + var stdout bytes.Buffer + var stderr bytes.Buffer + if exit := runWithDeps([]string{"plugin", "add", src}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("plugin add exit = %d stderr=%s", exit, stderr.String()) + } + + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "pin", "zero.demo", "--version", "0.1.0", "--json"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("pin exit = %d stderr=%s", exit, stderr.String()) + } + var pinned struct { + ID string `json:"id"` + Pinned bool `json:"pinned"` + Version string `json:"version"` + } + if err := json.Unmarshal(stdout.Bytes(), &pinned); err != nil { + t.Fatalf("pin JSON: %v\n%s", err, stdout.String()) + } + if pinned.ID != "zero.demo" || !pinned.Pinned || pinned.Version != "0.1.0" { + t.Fatalf("unexpected pin payload: %#v", pinned) + } + lock, err := plugins.ReadLock(pluginsDir) + if err != nil { + t.Fatalf("ReadLock: %v", err) + } + if !lock["zero.demo"].Pinned || lock["zero.demo"].Version != "0.1.0" { + t.Fatalf("lock not pinned: %#v", lock["zero.demo"]) + } + + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "unpin", "zero.demo", "--json"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("unpin exit = %d stderr=%s", exit, stderr.String()) + } + var unpinned struct { + ID string `json:"id"` + Pinned bool `json:"pinned"` + } + if err := json.Unmarshal(stdout.Bytes(), &unpinned); err != nil { + t.Fatalf("unpin JSON: %v\n%s", err, stdout.String()) + } + if unpinned.ID != "zero.demo" || unpinned.Pinned { + t.Fatalf("unexpected unpin payload: %#v", unpinned) + } +} diff --git a/internal/zerocommands/backend_snapshots.go b/internal/zerocommands/backend_snapshots.go index e0f1e8d4..6f70638f 100644 --- a/internal/zerocommands/backend_snapshots.go +++ b/internal/zerocommands/backend_snapshots.go @@ -2,6 +2,7 @@ package zerocommands import ( "net/url" + "path/filepath" "sort" "strings" @@ -69,6 +70,13 @@ type PluginSnapshot struct { Root string `json:"root,omitempty"` PluginDir string `json:"pluginDir,omitempty"` ManifestPath string `json:"manifestPath,omitempty"` + Managed bool `json:"managed"` + Catalog string `json:"catalog,omitempty"` + Commit string `json:"commit,omitempty"` + Pinned bool `json:"pinned,omitempty"` + Integrity string `json:"integrity,omitempty"` + State string `json:"state"` + Quarantined bool `json:"quarantined,omitempty"` ToolCount int `json:"toolCount"` PromptCount int `json:"promptCount"` SkillCount int `json:"skillCount"` @@ -207,12 +215,14 @@ func hookSnapshotsWithSource(definitions []hooks.Definition, source hooks.Config // after trimming and redaction because they help triage a load // failure but may include copied token-like strings. func PluginSnapshotFromPlugin(plugin plugins.LoadedPlugin) PluginSnapshot { - return PluginSnapshot{ + snapshot := PluginSnapshot{ ID: redactSnapshotString(plugin.ID), Name: redactSnapshotString(plugin.Name), Version: redactSnapshotString(plugin.Version), Description: redactSnapshotString(plugin.Description), Enabled: plugin.Enabled, + State: pluginState(plugin), + Quarantined: pluginQuarantined(plugin), Source: string(plugin.Source), Root: redactSnapshotString(plugin.Root), PluginDir: redactSnapshotString(plugin.PluginDir), @@ -222,6 +232,41 @@ func PluginSnapshotFromPlugin(plugin plugins.LoadedPlugin) PluginSnapshot { SkillCount: len(plugin.Skills), HookCount: len(plugin.Hooks), } + applyPluginLockMetadata(&snapshot, plugin) + return snapshot +} + +func applyPluginLockMetadata(snapshot *PluginSnapshot, plugin plugins.LoadedPlugin) { + if strings.TrimSpace(plugin.Root) == "" || strings.TrimSpace(plugin.ID) == "" { + snapshot.Integrity = "unmanaged" + return + } + lock, err := plugins.ReadLock(plugin.Root) + if err != nil { + snapshot.Integrity = "unknown" + return + } + entry, ok := lock[plugin.ID] + if !ok { + snapshot.Integrity = "unmanaged" + return + } + snapshot.Managed = true + snapshot.Catalog = redactSnapshotString(entry.Catalog) + snapshot.Commit = redactSnapshotString(entry.Commit) + snapshot.Pinned = entry.Pinned + snapshot.Integrity = "ok" +} + +func pluginState(plugin plugins.LoadedPlugin) string { + if plugin.Enabled { + return "active" + } + return "disabled" +} + +func pluginQuarantined(plugin plugins.LoadedPlugin) bool { + return strings.Contains(filepath.ToSlash(plugin.PluginDir), "/.disabled/") } // PluginSnapshots converts a slice of plugins.LoadedPlugin into a diff --git a/internal/zerocommands/backend_snapshots_test.go b/internal/zerocommands/backend_snapshots_test.go index ff3951ec..699a7a68 100644 --- a/internal/zerocommands/backend_snapshots_test.go +++ b/internal/zerocommands/backend_snapshots_test.go @@ -2,6 +2,8 @@ package zerocommands import ( "encoding/json" + "os" + "path/filepath" "strings" "testing" @@ -105,6 +107,39 @@ func TestMCPServerSnapshotRedactsSecretCommand(t *testing.T) { } } +func TestPluginSnapshotIncludesMarketplaceLockMetadata(t *testing.T) { + plugin := plugins.LoadedPlugin{ + ID: "zero.demo", + Name: "Demo", + Version: "0.1.0", + Enabled: false, + Source: plugins.SourceUser, + Root: t.TempDir(), + PluginDir: "ignored/.disabled/zero.demo", + } + if err := os.WriteFile(filepath.Join(plugin.Root, plugins.LockFileName), []byte(`{ + "zero.demo": { + "source": "https://github.com/Gitlawb/zero-demo-plugin.git", + "hash": "sha256:abc", + "catalog": "team", + "version": "0.1.0", + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enabled": false, + "pinned": true + } + }`), 0o644); err != nil { + t.Fatal(err) + } + + snapshot := PluginSnapshotFromPlugin(plugin) + if !snapshot.Managed || snapshot.Catalog != "team" || snapshot.Commit != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" || !snapshot.Pinned { + t.Fatalf("snapshot missing lock metadata: %#v", snapshot) + } + if snapshot.State != "disabled" || !snapshot.Quarantined || snapshot.Integrity != "ok" { + t.Fatalf("snapshot state/integrity mismatch: %#v", snapshot) + } +} + func TestMCPServerSnapshotsSortsAndReturnsEmptySliceForEmptyInput(t *testing.T) { servers := []mcp.Server{ {Name: "zulu", Type: mcp.ServerTypeHTTP, URL: "https://zulu.test"}, From 124b471066df4c00c849be4112d4fd07a3a4f687 Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Fri, 10 Jul 2026 23:25:59 +0530 Subject: [PATCH 04/10] docs: document plugin marketplace --- README.md | 17 ++++++++- docs/EXTENDING.md | 18 ++++++++-- docs/HOW_ZERO_WORKS.md | 2 +- docs/PLUGIN_MARKETPLACE.md | 73 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 docs/PLUGIN_MARKETPLACE.md diff --git a/README.md b/README.md index 15a881fb..4e8ad117 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,22 @@ Plugins are discovered from `~/.config/zero/plugins//plugin.json` (user scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the `config.UserConfigDir()` path used above) and `/.zero/plugins//plugin.json` (project scope — resolved from the current working directory, not the repo -root), and managed with `zero plugins`. A manifest can declare: +root), and managed with `zero plugins`. Plugin catalogs are registered in +`~/.config/zero/marketplaces.json` or `/.zero/marketplaces.json`: + +```bash +zero plugins marketplace validate ./catalog.json +zero plugins marketplace add ./catalog.json --allow-unverified +zero plugins marketplace list +zero plugins browse lookup --catalog team +zero plugins install zero.demo@team --yes +zero plugins disable zero.demo +zero plugins enable zero.demo +``` + +Disabled managed plugins are physically quarantined under +`/.disabled/` and remain visible in `zero plugins list` without +activating. A manifest can declare: - `tools` — custom tools (`command`, `args`, `inputSchema`, and a `permission` of `prompt` or `deny`; `allow` is honored only when manifest tool diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md index 6e85c552..10a1c654 100644 --- a/docs/EXTENDING.md +++ b/docs/EXTENDING.md @@ -278,15 +278,27 @@ Install and manage: ```bash zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/ +zero plugins marketplace add ./catalog.json --allow-unverified +zero plugins browse review --catalog team +zero plugins install github-pr-review@team --yes zero plugins list +zero plugins disable github-pr-review +zero plugins enable github-pr-review +zero plugins pin github-pr-review --version 1.0.0 +zero plugins unpin github-pr-review zero plugins remove github-pr-review # alias: rm ``` -A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today. +Marketplace catalogs are local-first. User catalogs are registered in +`~/.config/zero/marketplaces.json`; project catalogs are registered in +`./.zero/marketplaces.json`. Unsigned catalogs can be browsed, but adding one +requires `--allow-unverified`, and installs require `--yes`. -Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time. +A disabled managed plugin is moved to `/.disabled/` and its +`plugins.lock` entry records `enabled:false`. This keeps older Zero versions +from activating disabled plugin contents by directory presence alone. -> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above. +Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time. ## 7. Configuration locations diff --git a/docs/HOW_ZERO_WORKS.md b/docs/HOW_ZERO_WORKS.md index 4c84667e..af4b0bb8 100644 --- a/docs/HOW_ZERO_WORKS.md +++ b/docs/HOW_ZERO_WORKS.md @@ -130,7 +130,7 @@ reading the deeper flows below. | `internal/mcp` | MCP server config, client runtime, permission store, and MCP tool registration. | | `internal/specialist` / `internal/swarm` | Sub-agent manifests and team/member orchestration exposed as tools. | | `internal/localcontrol` / `internal/browser` | Runtime helpers used by config-gated local-control tool wrappers. | -| `internal/plugins` / `internal/skills` / `internal/hooks` | Extension surfaces loaded into the agent context, tool registry, or tool lifecycle. | +| `internal/plugins` / `internal/marketplace` / `internal/skills` / `internal/hooks` | Extension surfaces loaded into the agent context, tool registry, or tool lifecycle. The marketplace package validates local-first plugin catalogs and feeds managed installs into the existing plugin runtime. | | `internal/streamjson` | Machine-readable protocol for `zero exec --input-format/--output-format stream-json`. | ## Startup Flow diff --git a/docs/PLUGIN_MARKETPLACE.md b/docs/PLUGIN_MARKETPLACE.md new file mode 100644 index 00000000..b8448c3e --- /dev/null +++ b/docs/PLUGIN_MARKETPLACE.md @@ -0,0 +1,73 @@ +# Plugin Marketplace + +Zero's plugin marketplace is local-first. Catalogs are JSON files that describe +plugin releases, signed by an optional detached Ed25519 `catalog.sig`. + +## Catalogs + +Register catalogs with: + +```bash +zero plugins marketplace validate ./catalog.json +zero plugins marketplace add ./catalog.json --allow-unverified +zero plugins marketplace list +``` + +User catalogs are stored in `~/.config/zero/marketplaces.json`. Project catalogs +are stored in `/.zero/marketplaces.json`. + +## Catalog Format + +`catalog.json` has schema version `1`, a catalog id, owner, and a `plugins` +array. Each plugin has curated review metadata and one or more immutable +releases: + +```json +{ + "schemaVersion": 1, + "id": "team", + "owner": "Platform", + "plugins": [ + { + "id": "zero.demo", + "name": "Zero Demo", + "author": {"name": "Platform"}, + "license": "MIT", + "review": {"status": "community"}, + "releases": [ + { + "version": "0.1.0", + "repository": "https://github.com/Gitlawb/zero-demo-plugin.git", + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "treeHash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "components": { + "tools": [{"name": "lookup", "permission": "prompt"}], + "hooks": [{"name": "preflight", "event": "beforeTool"}] + } + } + ] + } + ] +} +``` + +Supported hook events are exactly `beforeTool`, `afterTool`, `sessionStart`, +and `sessionEnd`. Specialist hook events are rejected by catalog validation. + +## Install Safety + +Marketplace installs require `--yes` and verify the fetched plugin against +catalog metadata before publishing: + +```bash +zero plugins browse lookup --catalog team +zero plugins install zero.demo@team --yes +``` + +Install checks include plugin id, manifest version, tree hash, component names, +tool permissions, and hook events. Managed plugins are recorded in `plugins.lock` +with catalog, version, commit, subdir, hash, pinned, and enabled state. + +Disabled managed plugins move to `/.disabled/`. The loader +lists quarantined plugins but never activates them; a disabled project plugin +still shadows a same-id user plugin. From 4c22e609d36466ffcbd56432ed0e173222a4f594 Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Sat, 11 Jul 2026 03:49:09 +0530 Subject: [PATCH 05/10] feat: add local-first plugin marketplace --- README.md | 2 +- docs/EXTENDING.md | 5 +- docs/PLUGIN_MARKETPLACE.md | 13 +- internal/cli/app.go | 154 ++++ internal/cli/extensions.go | 9 + internal/cli/hook_dispatch.go | 6 +- internal/cli/marketplace_cli_test.go | 409 ++++++++- internal/cli/marketplace_install_cli_test.go | 366 +++++++- internal/cli/plugin_marketplace.go | 886 +++++++++++++++++- internal/marketplace/catalog.go | 65 +- internal/marketplace/catalog_test.go | 60 +- internal/marketplace/registry.go | 183 ++++ internal/plugins/install.go | 178 +++- internal/plugins/install_test.go | 41 + internal/plugins/integrity_test.go | 73 ++ internal/plugins/plugins.go | 28 +- internal/plugins/root_lock_unix.go | 59 ++ internal/plugins/root_lock_unix_test.go | 26 + internal/plugins/root_lock_windows.go | 71 ++ internal/tui/autocomplete.go | 4 +- internal/tui/clipboard.go | 2 +- internal/tui/commands.go | 8 + internal/tui/commands_test.go | 327 +++++++ internal/tui/composer.go | 2 +- internal/tui/files_panel.go | 2 +- internal/tui/model.go | 74 +- internal/tui/mouse.go | 14 +- internal/tui/options.go | 54 ++ internal/tui/plan_step_detail.go | 2 +- internal/tui/plugin_manager.go | 906 +++++++++++++++++++ internal/tui/scroll_test.go | 2 +- internal/tui/sidebar.go | 4 +- internal/tui/transcript_selection.go | 2 +- 33 files changed, 3945 insertions(+), 92 deletions(-) create mode 100644 internal/plugins/root_lock_unix.go create mode 100644 internal/plugins/root_lock_unix_test.go create mode 100644 internal/plugins/root_lock_windows.go create mode 100644 internal/tui/plugin_manager.go diff --git a/README.md b/README.md index 4e8ad117..a37b3743 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ zero plugins marketplace validate ./catalog.json zero plugins marketplace add ./catalog.json --allow-unverified zero plugins marketplace list zero plugins browse lookup --catalog team -zero plugins install zero.demo@team --yes +zero plugins install zero.demo@team --yes --allow-unverified zero plugins disable zero.demo zero plugins enable zero.demo ``` diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md index 10a1c654..1e5c37a4 100644 --- a/docs/EXTENDING.md +++ b/docs/EXTENDING.md @@ -280,7 +280,7 @@ Install and manage: zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/ zero plugins marketplace add ./catalog.json --allow-unverified zero plugins browse review --catalog team -zero plugins install github-pr-review@team --yes +zero plugins install github-pr-review@team --yes --allow-unverified zero plugins list zero plugins disable github-pr-review zero plugins enable github-pr-review @@ -292,7 +292,8 @@ zero plugins remove github-pr-review # alias: rm Marketplace catalogs are local-first. User catalogs are registered in `~/.config/zero/marketplaces.json`; project catalogs are registered in `./.zero/marketplaces.json`. Unsigned catalogs can be browsed, but adding one -requires `--allow-unverified`, and installs require `--yes`. +requires `--allow-unverified`, and installs require both `--yes` and +`--allow-unverified`. A disabled managed plugin is moved to `/.disabled/` and its `plugins.lock` entry records `enabled:false`. This keeps older Zero versions diff --git a/docs/PLUGIN_MARKETPLACE.md b/docs/PLUGIN_MARKETPLACE.md index b8448c3e..c1ee013f 100644 --- a/docs/PLUGIN_MARKETPLACE.md +++ b/docs/PLUGIN_MARKETPLACE.md @@ -33,7 +33,12 @@ releases: "name": "Zero Demo", "author": {"name": "Platform"}, "license": "MIT", - "review": {"status": "community"}, + "review": { + "status": "community", + "date": "2026-07-10", + "reviewer": "Zero Security", + "url": "https://github.com/Gitlawb/zero-plugins/pull/1" + }, "releases": [ { "version": "0.1.0", @@ -61,13 +66,17 @@ catalog metadata before publishing: ```bash zero plugins browse lookup --catalog team -zero plugins install zero.demo@team --yes +zero plugins install zero.demo@team --yes --allow-unverified ``` Install checks include plugin id, manifest version, tree hash, component names, tool permissions, and hook events. Managed plugins are recorded in `plugins.lock` with catalog, version, commit, subdir, hash, pinned, and enabled state. +Install/update/remove operations take a plugin-root lock. On Unix this uses an +OS file lock, so crash-left lock files do not block later operations. On Windows +stale lock files are recovered after a short age threshold. + Disabled managed plugins move to `/.disabled/`. The loader lists quarantined plugins but never activates them; a disabled project plugin still shadows a same-id user plugin. diff --git a/internal/cli/app.go b/internal/cli/app.go index a138f014..8bc1d4d8 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -11,6 +11,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "sync" "time" @@ -19,6 +20,7 @@ import ( "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/hooks" "github.com/Gitlawb/zero/internal/localcontrol" + "github.com/Gitlawb/zero/internal/marketplace" "github.com/Gitlawb/zero/internal/mcp" "github.com/Gitlawb/zero/internal/modelregistry" "github.com/Gitlawb/zero/internal/observability" @@ -828,6 +830,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a ExitCode: exitCode, } }, + PluginCommand: tuiPluginCommand(workspaceRoot, deps), SandboxSetupCommand: tuiSandboxSetupCommand(sandboxBackend, deps), AgentOptions: agent.Options{ MaxTurns: resolved.MaxTurns, @@ -885,6 +888,157 @@ func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context. } } +func tuiPluginCommand(workspaceRoot string, deps appDeps) func(context.Context, []string) tui.PluginCommandResult { + return func(ctx context.Context, args []string) tui.PluginCommandResult { + if ctx == nil { + ctx = context.Background() + } + var stdout, stderr bytes.Buffer + exitCode := runPlugins(args, &stdout, &stderr, deps) + return tui.PluginCommandResult{ + Snapshot: tuiPluginSnapshot(workspaceRoot, deps), + Output: strings.TrimSpace(stdout.String()), + Error: strings.TrimSpace(stderr.String()), + ExitCode: exitCode, + RestartRequired: pluginCommandNeedsRestart(args) && exitCode == exitSuccess, + } + } +} + +func tuiPluginSnapshot(workspaceRoot string, deps appDeps) tui.PluginSnapshot { + excludeProject, trustErr := resolveTrust(workspaceRoot) + result, err := deps.loadPlugins(plugins.LoadOptions{Cwd: workspaceRoot, ExcludeProject: excludeProject}) + snapshot := tui.PluginSnapshot{ProjectPluginsShown: !excludeProject} + if err != nil { + snapshot.LoadError = err.Error() + } else { + snapshot.Plugins = result.Plugins + snapshot.Diagnostics = result.Diagnostics + } + snapshot.Installed = tuiPluginInstalledSnapshot(deps, !excludeProject) + installed := map[string]tui.PluginInstalledSnapshot{} + for _, item := range snapshot.Installed { + if item.ID == "" { + continue + } + installed[item.ID+"|"+string(item.Source)] = item + if item.Catalog != "" { + installed[item.ID+"@"+item.Catalog] = item + } + } + catalogs, catalogErr := registeredCatalogs(workspaceRoot, !excludeProject) + if catalogErr != nil { + if snapshot.LoadError == "" { + snapshot.LoadError = catalogErr.Error() + } + } else { + for _, entry := range catalogs { + catalogSnapshot := tui.PluginCatalogSnapshot{ + ID: entry.ID, + Source: entry.Source, + Scope: entry.Scope, + Verification: marketplace.Verification{ + Status: entry.VerificationStatus, + }, + } + catalog, verification, err := loadCatalogEntryCachedOnly(entry) + if err != nil { + catalogSnapshot.LoadError = err.Error() + } else { + catalogSnapshot.Owner = catalog.Owner + catalogSnapshot.Description = catalog.Description + catalogSnapshot.Verification = verification + for _, plugin := range catalog.Plugins { + release, ok := selectRelease(plugin, "") + if !ok { + continue + } + state, ok := installed[plugin.ID+"@"+catalog.ID] + snapshot.MarketplacePlugins = append(snapshot.MarketplacePlugins, tui.PluginMarketplaceSnapshot{ + CatalogID: catalog.ID, + CatalogScope: entry.Scope, + Verification: verification, + Plugin: plugin, + Release: release, + Risk: marketplace.RiskForRelease(release), + Installed: ok, + Pinned: ok && state.Pinned, + }) + } + } + snapshot.Catalogs = append(snapshot.Catalogs, catalogSnapshot) + } + } + sort.SliceStable(snapshot.Installed, func(left int, right int) bool { + if snapshot.Installed[left].Source != snapshot.Installed[right].Source { + return snapshot.Installed[left].Source < snapshot.Installed[right].Source + } + return snapshot.Installed[left].ID < snapshot.Installed[right].ID + }) + sort.SliceStable(snapshot.Catalogs, func(left int, right int) bool { + if snapshot.Catalogs[left].Scope != snapshot.Catalogs[right].Scope { + return snapshot.Catalogs[left].Scope < snapshot.Catalogs[right].Scope + } + return snapshot.Catalogs[left].ID < snapshot.Catalogs[right].ID + }) + sort.SliceStable(snapshot.MarketplacePlugins, func(left int, right int) bool { + if snapshot.MarketplacePlugins[left].CatalogID != snapshot.MarketplacePlugins[right].CatalogID { + return snapshot.MarketplacePlugins[left].CatalogID < snapshot.MarketplacePlugins[right].CatalogID + } + return snapshot.MarketplacePlugins[left].Plugin.ID < snapshot.MarketplacePlugins[right].Plugin.ID + }) + if trustErr { + snapshot.TrustNotice = "project plugins hidden: trust store error" + } else if excludeProject { + snapshot.TrustNotice = "project plugins hidden until workspace trusted" + } + return snapshot +} + +func tuiPluginInstalledSnapshot(deps appDeps, includeProject bool) []tui.PluginInstalledSnapshot { + scopes := []plugins.Source{plugins.SourceUser} + if includeProject { + scopes = append(scopes, plugins.SourceProject) + } + items := []tui.PluginInstalledSnapshot{} + for _, scope := range scopes { + dir, err := pluginDirForScope(deps, scope) + if err != nil { + items = append(items, tui.PluginInstalledSnapshot{Source: scope, Error: err.Error()}) + continue + } + lock, err := plugins.ReadLock(dir) + if err != nil { + items = append(items, tui.PluginInstalledSnapshot{Source: scope, Error: err.Error()}) + continue + } + for id, entry := range lock { + items = append(items, tui.PluginInstalledSnapshot{ + ID: id, + Source: scope, + Catalog: entry.Catalog, + Version: entry.Version, + Commit: entry.Commit, + Enabled: entry.Enabled, + Pinned: entry.Pinned, + }) + } + } + return items +} + +func pluginCommandNeedsRestart(args []string) bool { + if len(args) == 0 { + return false + } + switch strings.TrimSpace(args[0]) { + case "install", "add", "remove", "rm", "enable", "disable", "update": + return true + default: + return false + } +} + // buildProvider constructs the run's provider at STARTUP — it is called only from // the two launch paths (interactive TUI and headless exec), never from mid-run // rebuilds (escalation, wizard, ACP go through deps.newProvider directly). diff --git a/internal/cli/extensions.go b/internal/cli/extensions.go index ae0e80fb..2d355fef 100644 --- a/internal/cli/extensions.go +++ b/internal/cli/extensions.go @@ -80,6 +80,12 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) return runPluginBrowse(args[1:], stdout, stderr, deps) case "install": return runPluginMarketplaceInstall(args[1:], stdout, stderr, deps) + case "info": + return runPluginMarketplaceInfo(args[1:], stdout, stderr, deps) + case "update": + return runPluginMarketplaceUpdate(args[1:], stdout, stderr, deps) + case "verify": + return runPluginVerify(args[1:], stdout, stderr, deps) case "marketplace": return runPluginMarketplace(args[1:], stdout, stderr, deps) case "add": @@ -577,6 +583,9 @@ Commands: list List local Zero plugins browse [query] Browse marketplace plugins install Install a marketplace plugin + info Show marketplace plugin info + update [id] Update marketplace-managed plugins + verify Verify installed plugin integrity add Install a plugin (manifest-validated, pinned in plugins.lock) remove Remove an installed plugin and its lockfile entry enable Enable a quarantined plugin diff --git a/internal/cli/hook_dispatch.go b/internal/cli/hook_dispatch.go index 5d122c90..c7cbf259 100644 --- a/internal/cli/hook_dispatch.go +++ b/internal/cli/hook_dispatch.go @@ -124,7 +124,7 @@ func projectHooksFileExists(workspaceRoot string) bool { } // emitTrustNotice writes at most one stderr line summarizing that project-scoped -// hooks, plugins, and/or MCP servers were skipped in an untrusted workspace. It +// hooks, plugins, MCP servers, and/or marketplaces were skipped in an untrusted workspace. It // takes the skip report from each trust-gated surface (hooks, plugins, MCP) and ORs // them, so a workspace that only drops one surface still gets the notice. It is // computed once per session by the caller (each session-setup site runs once), so it @@ -144,8 +144,8 @@ func emitTrustNotice(stderr io.Writer, skips ...trustSkip) { return } if storeErrored { - _, _ = fmt.Fprintln(stderr, "zero: the workspace-trust store could not be read; ignoring project hooks/plugins/MCP servers (fail-closed). Run 'zero trust' to enable.") + _, _ = fmt.Fprintln(stderr, "zero: the workspace-trust store could not be read; ignoring project hooks/plugins/MCP servers/marketplaces (fail-closed). Run 'zero trust' to enable.") return } - _, _ = fmt.Fprintln(stderr, "zero: ignoring project hooks/plugins/MCP servers in an untrusted workspace. Run 'zero trust' to enable.") + _, _ = fmt.Fprintln(stderr, "zero: ignoring project hooks/plugins/MCP servers/marketplaces in an untrusted workspace. Run 'zero trust' to enable.") } diff --git a/internal/cli/marketplace_cli_test.go b/internal/cli/marketplace_cli_test.go index 2ca8ac1b..f8554690 100644 --- a/internal/cli/marketplace_cli_test.go +++ b/internal/cli/marketplace_cli_test.go @@ -2,11 +2,20 @@ package cli import ( "bytes" + "crypto/ed25519" + "encoding/base64" "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" + "sync/atomic" "testing" + + "github.com/Gitlawb/zero/internal/marketplace" + "github.com/Gitlawb/zero/internal/plugins" + "github.com/Gitlawb/zero/internal/workspacetrust" ) func TestRunPluginsMarketplaceValidateJSON(t *testing.T) { @@ -82,6 +91,23 @@ func TestRunPluginsMarketplaceAddListAndBrowse(t *testing.T) { } } +func TestRegisteredOfficialCatalogUsesEmbeddedPublicKey(t *testing.T) { + catalogs, err := registeredCatalogs(t.TempDir(), false) + if err != nil { + t.Fatal(err) + } + if len(catalogs) == 0 || catalogs[0].ID != marketplace.OfficialCatalogID { + t.Fatalf("official catalog missing: %#v", catalogs) + } + official := catalogs[0] + if len(official.PublicKey) != ed25519.PublicKeySize { + t.Fatalf("official catalog public key length = %d, want %d", len(official.PublicKey), ed25519.PublicKeySize) + } + if official.VerificationStatus != marketplace.VerificationSigned { + t.Fatalf("official catalog verification = %q, want signed", official.VerificationStatus) + } +} + func TestRunPluginsMarketplaceAddRequiresAllowUnverified(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) catalogPath := writeMarketplaceTestCatalog(t) @@ -97,6 +123,382 @@ func TestRunPluginsMarketplaceAddRequiresAllowUnverified(t *testing.T) { } } +func TestRunPluginsMarketplaceAddRejectsInvalidSignatureEvenWhenUnverifiedAllowed(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) + catalogPath := writeMarketplaceTestCatalog(t) + if err := os.WriteFile(filepath.Join(filepath.Dir(catalogPath), "catalog.sig"), []byte("not-a-valid-signature"), 0o644); err != nil { + t.Fatal(err) + } + publicKey, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + keyPath := filepath.Join(t.TempDir(), "catalog.pub") + if err := os.WriteFile(keyPath, []byte(base64.StdEncoding.EncodeToString(publicKey)), 0o600); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"plugins", "marketplace", "add", catalogPath, "--public-key", keyPath, "--allow-unverified"}, &stdout, &stderr, appDeps{}) + if exitCode != exitUsage { + t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "invalid") { + t.Fatalf("expected invalid signature error, got %s", stderr.String()) + } +} + +func TestRunPluginsMarketplaceListGatesProjectCatalogsBehindTrust(t *testing.T) { + setTrustConfigRoot(t) + cwd := t.TempDir() + catalogPath := writeMarketplaceTestCatalog(t) + projectPath := filepath.Join(cwd, ".zero", "marketplaces.json") + if err := marketplace.SaveRegistry(projectPath, marketplace.Registry{Catalogs: []marketplace.RegisteredCatalog{{ + ID: "team", + Source: catalogPath, + VerificationStatus: marketplace.VerificationUnsigned, + }}}); err != nil { + t.Fatal(err) + } + deps := appDeps{getwd: func() (string, error) { return cwd, nil }} + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"plugins", "marketplace", "list", "--json"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("list exitCode = %d stderr=%s", exitCode, stderr.String()) + } + if strings.Contains(stdout.String(), `"id": "team"`) { + t.Fatalf("untrusted project catalog leaked into list: %s", stdout.String()) + } + if !strings.Contains(stderr.String(), "zero trust") { + t.Fatalf("missing trust notice: %s", stderr.String()) + } + + if err := workspacetrust.Trust(cwd); err != nil { + t.Fatal(err) + } + stdout.Reset() + stderr.Reset() + exitCode = runWithDeps([]string{"plugins", "marketplace", "list", "--json"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("trusted list exitCode = %d stderr=%s", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), `"id": "team"`) { + t.Fatalf("trusted project catalog missing: %s", stdout.String()) + } +} + +func TestRunPluginsMarketplaceRemoteAddUpdateBrowse(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) + catalogBytes, err := os.ReadFile(writeMarketplaceTestCatalog(t)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(catalogBytes) + })) + defer server.Close() + cwd := t.TempDir() + deps := appDeps{getwd: func() (string, error) { return cwd, nil }} + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"plugins", "marketplace", "add", server.URL + "/catalog.json", "--allow-unverified", "--json"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("remote add exitCode = %d stderr=%s", exitCode, stderr.String()) + } + + stdout.Reset() + stderr.Reset() + exitCode = runWithDeps([]string{"plugins", "marketplace", "update", "team", "--json"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("remote update exitCode = %d stderr=%s", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), `"cached": true`) { + t.Fatalf("update should report cache write: %s", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + exitCode = runWithDeps([]string{"plugins", "browse", "lookup", "--catalog", "team", "--json"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("browse cached remote exitCode = %d stderr=%s", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), `"id": "zero.demo"`) { + t.Fatalf("cached browse missing plugin: %s", stdout.String()) + } +} + +func TestRunPluginsMarketplaceRemoteCatalogRejectsLocalReleaseRepository(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) + source, hash := marketplaceTestPluginSource(t, "zero.demo", "0.1.0") + catalogPath := writeMarketplaceUpdateCatalog(t, map[string]marketplaceTestRelease{ + "zero.demo": {Source: source, Version: "0.1.0", Hash: hash}, + }) + catalogBytes, err := os.ReadFile(catalogPath) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(catalogBytes) + })) + defer server.Close() + cwd := t.TempDir() + deps := appDeps{getwd: func() (string, error) { return cwd, nil }} + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"plugins", "marketplace", "add", server.URL + "/catalog.json", "--allow-unverified"}, &stdout, &stderr, deps) + if exitCode != exitUsage { + t.Fatalf("remote add exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "absolute git repository source") { + t.Fatalf("expected remote local repository rejection, got %s", stderr.String()) + } +} + +func TestTUIPluginSnapshotDoesNotRefreshMissingRemoteCatalogCache(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) + cwd := t.TempDir() + var serverCalled atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + serverCalled.Store(true) + http.Error(w, "unexpected refresh", http.StatusInternalServerError) + })) + defer server.Close() + registryPath, err := marketplace.RegistryPathForScope(marketplace.ScopeUser, cwd, nil) + if err != nil { + t.Fatal(err) + } + if err := marketplace.SaveRegistry(registryPath, marketplace.Registry{Catalogs: []marketplace.RegisteredCatalog{{ + ID: "team", + Source: server.URL + "/catalog.json", + VerificationStatus: marketplace.VerificationUnsigned, + }}}); err != nil { + t.Fatal(err) + } + pluginRoot := filepath.Join(t.TempDir(), "plugins") + + snapshot := tuiPluginSnapshot(cwd, appDeps{ + pluginsDir: func() string { return pluginRoot }, + loadPlugins: func(plugins.LoadOptions) (plugins.LoadResult, error) { + return plugins.LoadResult{}, nil + }, + }) + + if serverCalled.Load() { + t.Fatal("TUI snapshot should not refresh missing remote catalog cache") + } + if len(snapshot.MarketplacePlugins) != 0 { + t.Fatalf("missing cache should not populate marketplace plugins: %#v", snapshot.MarketplacePlugins) + } + foundTeam := false + for _, catalog := range snapshot.Catalogs { + if catalog.ID == "team" { + foundTeam = true + if !strings.Contains(catalog.LoadError, "no local cache") { + t.Fatalf("expected no-cache load error, got %q", catalog.LoadError) + } + } + } + if !foundTeam { + t.Fatalf("team catalog missing from snapshot: %#v", snapshot.Catalogs) + } +} + +func TestPluginCommandNeedsRestartSkipsPinMetadataOnly(t *testing.T) { + for _, args := range [][]string{ + {"pin", "zero.demo"}, + {"unpin", "zero.demo"}, + } { + if pluginCommandNeedsRestart(args) { + t.Fatalf("pluginCommandNeedsRestart(%v) = true, want false", args) + } + } + for _, args := range [][]string{ + {"install", "zero.demo@team", "--yes"}, + {"disable", "zero.demo"}, + {"update", "zero.demo", "--yes"}, + } { + if !pluginCommandNeedsRestart(args) { + t.Fatalf("pluginCommandNeedsRestart(%v) = false, want true", args) + } + } +} + +func TestRunPluginsMarketplaceUpdateKeepsStaleCacheOnRefreshFailure(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) + catalogBytes, err := os.ReadFile(writeMarketplaceTestCatalog(t)) + if err != nil { + t.Fatal(err) + } + failRefresh := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if failRefresh { + http.Error(w, "offline", http.StatusInternalServerError) + return + } + _, _ = w.Write(catalogBytes) + })) + defer server.Close() + cwd := t.TempDir() + deps := appDeps{getwd: func() (string, error) { return cwd, nil }} + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"plugins", "marketplace", "add", server.URL + "/catalog.json", "--allow-unverified"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("remote add exitCode = %d stderr=%s", exitCode, stderr.String()) + } + failRefresh = true + + stdout.Reset() + stderr.Reset() + exitCode = runWithDeps([]string{"plugins", "marketplace", "update", "team", "--json"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("stale update exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } + var update struct { + Verification marketplace.Verification `json:"verification"` + Cached bool `json:"cached"` + } + if err := json.Unmarshal(stdout.Bytes(), &update); err != nil { + t.Fatalf("update JSON: %v\n%s", err, stdout.String()) + } + if update.Cached || update.Verification.Status != marketplace.VerificationStale { + t.Fatalf("expected stale cached fallback, got %#v", update) + } + + stdout.Reset() + stderr.Reset() + exitCode = runWithDeps([]string{"plugins", "browse", "lookup", "--catalog", "team", "--json"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("browse stale cache exitCode = %d stderr=%s", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), `"status": "stale"`) || !strings.Contains(stdout.String(), `"id": "zero.demo"`) { + t.Fatalf("browse should use stale cache with stale marker: %s", stdout.String()) + } +} + +func TestRunPluginsBrowseRefreshPersistsStaleStatus(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) + catalogBytes, err := os.ReadFile(writeMarketplaceTestCatalog(t)) + if err != nil { + t.Fatal(err) + } + failRefresh := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if failRefresh { + http.Error(w, "offline", http.StatusInternalServerError) + return + } + _, _ = w.Write(catalogBytes) + })) + defer server.Close() + cwd := t.TempDir() + deps := appDeps{getwd: func() (string, error) { return cwd, nil }} + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"plugins", "marketplace", "add", server.URL + "/catalog.json", "--allow-unverified"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("remote add exitCode = %d stderr=%s", exitCode, stderr.String()) + } + failRefresh = true + + stdout.Reset() + stderr.Reset() + exitCode = runWithDeps([]string{"plugins", "browse", "lookup", "--catalog", "team", "--refresh", "--json"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("browse refresh exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), `"status": "stale"`) || !strings.Contains(stdout.String(), `"id": "zero.demo"`) { + t.Fatalf("browse refresh should use stale cache with stale marker: %s", stdout.String()) + } + + registryPath, err := marketplace.RegistryPathForScope(marketplace.ScopeUser, cwd, nil) + if err != nil { + t.Fatal(err) + } + registry, err := marketplace.LoadRegistry(registryPath) + if err != nil { + t.Fatal(err) + } + if len(registry.Catalogs) != 1 || registry.Catalogs[0].VerificationStatus != marketplace.VerificationStale { + t.Fatalf("browse refresh did not persist stale verification: %#v", registry.Catalogs) + } +} + +func TestRunPluginsMarketplaceSignWritesDetachedSignature(t *testing.T) { + catalogPath := writeMarketplaceTestCatalog(t) + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + privateKeyPath := filepath.Join(t.TempDir(), "catalog.key") + if err := os.WriteFile(privateKeyPath, []byte(base64.StdEncoding.EncodeToString(privateKey)), 0o600); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"plugins", "marketplace", "sign", catalogPath, "--private-key", privateKeyPath, "--json"}, &stdout, &stderr, appDeps{}) + if exitCode != exitSuccess { + t.Fatalf("sign exitCode = %d stderr=%s", exitCode, stderr.String()) + } + data, signature, err := marketplace.ReadLocalCatalog(catalogPath) + if err != nil { + t.Fatal(err) + } + verification := marketplace.VerifyCatalogSignature(data, signature, publicKey) + if verification.Status != marketplace.VerificationSigned { + t.Fatalf("signature did not verify: %#v", verification) + } +} + +func TestRunPluginsMarketplaceUpdateRejectsTamperedSignedLocalCatalog(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) + catalogPath := writeMarketplaceTestCatalog(t) + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + privateKeyPath := filepath.Join(t.TempDir(), "catalog.key") + if err := os.WriteFile(privateKeyPath, []byte(base64.StdEncoding.EncodeToString(privateKey)), 0o600); err != nil { + t.Fatal(err) + } + publicKeyPath := filepath.Join(t.TempDir(), "catalog.pub") + if err := os.WriteFile(publicKeyPath, []byte(base64.StdEncoding.EncodeToString(publicKey)), 0o600); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + if exitCode := runWithDeps([]string{"plugins", "marketplace", "sign", catalogPath, "--private-key", privateKeyPath}, &stdout, &stderr, appDeps{}); exitCode != exitSuccess { + t.Fatalf("sign exitCode = %d stderr=%s", exitCode, stderr.String()) + } + stdout.Reset() + stderr.Reset() + if exitCode := runWithDeps([]string{"plugins", "marketplace", "add", catalogPath, "--public-key", publicKeyPath}, &stdout, &stderr, appDeps{}); exitCode != exitSuccess { + t.Fatalf("add exitCode = %d stderr=%s", exitCode, stderr.String()) + } + if err := os.WriteFile(filepath.Join(filepath.Dir(catalogPath), "catalog.sig"), []byte("tampered"), 0o644); err != nil { + t.Fatal(err) + } + + stdout.Reset() + stderr.Reset() + exitCode := runWithDeps([]string{"plugins", "marketplace", "update", "team", "--json"}, &stdout, &stderr, appDeps{}) + if exitCode != exitUsage { + t.Fatalf("tampered update exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "invalid marketplace catalog signature") { + t.Fatalf("expected invalid signature error, got %s", stderr.String()) + } +} + func writeMarketplaceTestCatalog(t *testing.T) string { t.Helper() dir := t.TempDir() @@ -115,7 +517,12 @@ func writeMarketplaceTestCatalog(t *testing.T) string { "license": "MIT", "tags": ["lookup"], "category": "productivity", - "review": {"status": "community"}, + "review": { + "status": "community", + "date": "2026-07-10", + "reviewer": "Zero Security", + "url": "https://github.com/Gitlawb/zero-plugins/pull/1" + }, "releases": [ { "version": "0.1.0", diff --git a/internal/cli/marketplace_install_cli_test.go b/internal/cli/marketplace_install_cli_test.go index a8e79127..2613d2b5 100644 --- a/internal/cli/marketplace_install_cli_test.go +++ b/internal/cli/marketplace_install_cli_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "os" "path/filepath" + "sort" "strings" "testing" @@ -46,7 +47,7 @@ func TestRunPluginsInstallFromMarketplaceCatalog(t *testing.T) { stdout.Reset() stderr.Reset() - if exit := runWithDeps([]string{"plugins", "install", "zero.demo@team", "--yes", "--json"}, &stdout, &stderr, deps); exit != exitSuccess { + if exit := runWithDeps([]string{"plugins", "install", "zero.demo@team", "--version", "0.1.0", "--yes", "--allow-unverified", "--json"}, &stdout, &stderr, deps); exit != exitSuccess { t.Fatalf("plugins install exit = %d stderr=%s", exit, stderr.String()) } var payload struct { @@ -72,6 +73,152 @@ func TestRunPluginsInstallFromMarketplaceCatalog(t *testing.T) { if entry.Catalog != "team" || entry.Version != "0.1.0" || entry.Commit != strings.Repeat("a", 40) { t.Fatalf("marketplace lock metadata missing: %#v", entry) } + if !entry.Pinned || entry.Enabled == nil || !*entry.Enabled { + t.Fatalf("marketplace version install should pin and record enabled:true: %#v", entry) + } +} + +func TestRunPluginsUpdateFromMarketplaceHonorsProjectScope(t *testing.T) { + configHome := filepath.Join(t.TempDir(), "config") + t.Setenv("XDG_CONFIG_HOME", configHome) + userPluginsDir := t.TempDir() + source := writeSourcePluginDir(t, filepath.Join(t.TempDir(), "src"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Zero Demo", + "version": "0.1.0", + "tools": []any{map[string]any{ + "name": "lookup", + "command": "node", + "permission": "prompt", + }}, + }) + hashRoot := t.TempDir() + hashResult, err := plugins.Install(context.Background(), plugins.InstallOptions{Source: source, Dir: hashRoot}) + if err != nil { + t.Fatalf("seed hash install: %v", err) + } + catalogPath := writeMarketplaceInstallCatalog(t, source, hashResult.Hash) + cwd := t.TempDir() + deps := appDeps{ + getwd: func() (string, error) { return cwd, nil }, + pluginsDir: func() string { return userPluginsDir }, + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + if exit := runWithDeps([]string{"plugins", "marketplace", "add", catalogPath, "--allow-unverified"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("marketplace add exit = %d stderr=%s", exit, stderr.String()) + } + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "install", "zero.demo@team", "--version", "0.1.0", "--scope", "project", "--yes", "--allow-unverified"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("project install exit = %d stderr=%s", exit, stderr.String()) + } + + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "update", "zero.demo", "--scope", "project", "--yes", "--allow-unverified", "--json"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("project update exit = %d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) + } + projectPluginsDir := filepath.Join(cwd, ".zero", "plugins") + if _, err := os.Stat(filepath.Join(projectPluginsDir, "zero.demo", "plugin.json")); err != nil { + t.Fatalf("project plugin missing after update: %v", err) + } + if _, err := os.Stat(filepath.Join(userPluginsDir, "zero.demo")); err == nil { + t.Fatalf("project-scoped update wrote to user plugin root") + } else if !os.IsNotExist(err) { + t.Fatalf("stat user plugin root: %v", err) + } + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "verify", "zero.demo", "--scope", "project", "--json"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("project verify exit = %d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) + } +} + +func TestRunPluginsUpdateCheckAllSkipsPinnedAndDoesNotMutate(t *testing.T) { + configHome := filepath.Join(t.TempDir(), "config") + t.Setenv("XDG_CONFIG_HOME", configHome) + pluginsDir := t.TempDir() + cwd := t.TempDir() + deps := appDeps{ + getwd: func() (string, error) { return cwd, nil }, + pluginsDir: func() string { return pluginsDir }, + } + + demoV1, demoHashV1 := marketplaceTestPluginSource(t, "zero.demo", "0.1.0") + pinnedV1, pinnedHashV1 := marketplaceTestPluginSource(t, "zero.pinned", "0.1.0") + catalogPath := writeMarketplaceUpdateCatalog(t, map[string]marketplaceTestRelease{ + "zero.demo": {Source: demoV1, Version: "0.1.0", Hash: demoHashV1}, + "zero.pinned": {Source: pinnedV1, Version: "0.1.0", Hash: pinnedHashV1}, + }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + if exit := runWithDeps([]string{"plugins", "marketplace", "add", catalogPath, "--allow-unverified"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("marketplace add exit = %d stderr=%s", exit, stderr.String()) + } + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "install", "zero.demo@team", "--yes", "--allow-unverified"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("demo install exit = %d stderr=%s", exit, stderr.String()) + } + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "install", "zero.pinned@team", "--version", "0.1.0", "--yes", "--allow-unverified"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("pinned install exit = %d stderr=%s", exit, stderr.String()) + } + + demoV2, demoHashV2 := marketplaceTestPluginSource(t, "zero.demo", "0.2.0") + pinnedV2, pinnedHashV2 := marketplaceTestPluginSource(t, "zero.pinned", "0.2.0") + writeMarketplaceUpdateCatalogAt(t, catalogPath, map[string]marketplaceTestRelease{ + "zero.demo": {Source: demoV2, Version: "0.2.0", Hash: demoHashV2}, + "zero.pinned": {Source: pinnedV2, Version: "0.2.0", Hash: pinnedHashV2}, + }) + + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "update", "--scope", "user", "--check", "--allow-unverified", "--json"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("update check exit = %d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) + } + check := decodePluginUpdateResults(t, stdout.Bytes()) + if got := check["zero.demo"]; !got.UpdateAvailable || got.Status != "available" || got.Version != "0.2.0" { + t.Fatalf("zero.demo check result = %#v", got) + } + if got := check["zero.pinned"]; got.Status != "pinned" || got.UpdateAvailable { + t.Fatalf("zero.pinned check result = %#v", got) + } + lock, err := plugins.ReadLock(pluginsDir) + if err != nil { + t.Fatalf("ReadLock: %v", err) + } + if lock["zero.demo"].Version != "0.1.0" || lock["zero.demo"].Hash != demoHashV1 { + t.Fatalf("check mutated zero.demo lock: %#v", lock["zero.demo"]) + } + + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "update", "--scope", "user", "--yes", "--allow-unverified", "--json"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("update all exit = %d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) + } + updated := decodePluginUpdateResults(t, stdout.Bytes()) + if got := updated["zero.demo"]; got.Status != "updated" || got.Version != "0.2.0" { + t.Fatalf("zero.demo update result = %#v", got) + } + if got := updated["zero.pinned"]; got.Status != "pinned" { + t.Fatalf("zero.pinned update result = %#v", got) + } + lock, err = plugins.ReadLock(pluginsDir) + if err != nil { + t.Fatalf("ReadLock: %v", err) + } + if lock["zero.demo"].Version != "0.2.0" || lock["zero.demo"].Hash != demoHashV2 { + t.Fatalf("update did not mutate zero.demo lock: %#v", lock["zero.demo"]) + } + if lock["zero.pinned"].Version != "0.1.0" || lock["zero.pinned"].Hash != pinnedHashV1 { + t.Fatalf("pinned plugin mutated: %#v", lock["zero.pinned"]) + } } func TestRunPluginsInstallFromMarketplaceRequiresYes(t *testing.T) { @@ -82,6 +229,11 @@ func TestRunPluginsInstallFromMarketplaceRequiresYes(t *testing.T) { "id": "zero.demo", "name": "Zero Demo", "version": "0.1.0", + "tools": []any{map[string]any{ + "name": "lookup", + "command": "node", + "permission": "prompt", + }}, }) hashRoot := t.TempDir() hashResult, err := plugins.Install(context.Background(), plugins.InstallOptions{Source: source, Dir: hashRoot}) @@ -110,6 +262,211 @@ func TestRunPluginsInstallFromMarketplaceRequiresYes(t *testing.T) { } } +func TestRunPluginsInstallFromUnsignedMarketplaceRequiresAllowUnverified(t *testing.T) { + configHome := filepath.Join(t.TempDir(), "config") + t.Setenv("XDG_CONFIG_HOME", configHome) + source := writeSourcePluginDir(t, filepath.Join(t.TempDir(), "src"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Zero Demo", + "version": "0.1.0", + }) + hashRoot := t.TempDir() + hashResult, err := plugins.Install(context.Background(), plugins.InstallOptions{Source: source, Dir: hashRoot}) + if err != nil { + t.Fatalf("seed hash install: %v", err) + } + catalogPath := writeMarketplaceInstallCatalog(t, source, hashResult.Hash) + cwd := t.TempDir() + deps := appDeps{ + getwd: func() (string, error) { return cwd, nil }, + pluginsDir: func() string { return t.TempDir() }, + } + var stdout bytes.Buffer + var stderr bytes.Buffer + if exit := runWithDeps([]string{"plugins", "marketplace", "add", catalogPath, "--allow-unverified"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("marketplace add exit = %d stderr=%s", exit, stderr.String()) + } + + stdout.Reset() + stderr.Reset() + exit := runWithDeps([]string{"plugins", "install", "zero.demo@team", "--yes"}, &stdout, &stderr, deps) + if exit != exitUsage { + t.Fatalf("install unsigned without allow-unverified exit = %d stderr=%s", exit, stderr.String()) + } + if !strings.Contains(stderr.String(), "--allow-unverified") { + t.Fatalf("expected allow-unverified guidance, got %s", stderr.String()) + } +} + +func TestRunPluginsInfoShowsMarketplaceRelease(t *testing.T) { + configHome := filepath.Join(t.TempDir(), "config") + t.Setenv("XDG_CONFIG_HOME", configHome) + source := writeSourcePluginDir(t, filepath.Join(t.TempDir(), "src"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Zero Demo", + "version": "0.1.0", + "tools": []any{map[string]any{ + "name": "lookup", + "command": "node", + "permission": "prompt", + }}, + }) + hashRoot := t.TempDir() + hashResult, err := plugins.Install(context.Background(), plugins.InstallOptions{Source: source, Dir: hashRoot}) + if err != nil { + t.Fatalf("seed hash install: %v", err) + } + catalogPath := writeMarketplaceInstallCatalog(t, source, hashResult.Hash) + cwd := t.TempDir() + deps := appDeps{getwd: func() (string, error) { return cwd, nil }} + + var stdout bytes.Buffer + var stderr bytes.Buffer + if exit := runWithDeps([]string{"plugins", "marketplace", "add", catalogPath, "--allow-unverified"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("marketplace add exit = %d stderr=%s", exit, stderr.String()) + } + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "info", "zero.demo@team", "--allow-unverified", "--json"}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("plugins info exit = %d stderr=%s", exit, stderr.String()) + } + if !strings.Contains(stdout.String(), `"id": "zero.demo"`) || !strings.Contains(stdout.String(), `"version": "0.1.0"`) { + t.Fatalf("unexpected info output: %s", stdout.String()) + } +} + +func TestRunPluginsVerifyDetectsTamperedManagedPlugin(t *testing.T) { + pluginsDir := t.TempDir() + source := writeSourcePluginDir(t, filepath.Join(t.TempDir(), "src"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Zero Demo", + "version": "0.1.0", + }) + deps := appDeps{pluginsDir: func() string { return pluginsDir }} + var stdout bytes.Buffer + var stderr bytes.Buffer + if exit := runWithDeps([]string{"plugin", "add", source}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("plugin add exit = %d stderr=%s", exit, stderr.String()) + } + if err := os.WriteFile(filepath.Join(pluginsDir, "zero.demo", "plugin.json"), []byte(`{"schemaVersion":1,"id":"zero.demo","name":"Tampered","version":"0.1.0"}`), 0o644); err != nil { + t.Fatal(err) + } + + stdout.Reset() + stderr.Reset() + exit := runWithDeps([]string{"plugins", "verify", "zero.demo", "--json"}, &stdout, &stderr, deps) + if exit != exitUsage { + t.Fatalf("verify tampered exit = %d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "integrity") && !strings.Contains(stderr.String(), "mismatch") { + t.Fatalf("expected integrity mismatch, got %s", stderr.String()) + } +} + +type marketplaceTestRelease struct { + Source string + Version string + Hash string +} + +func marketplaceTestPluginSource(t *testing.T, id string, version string) (string, string) { + t.Helper() + source := writeSourcePluginDir(t, filepath.Join(t.TempDir(), "src"), map[string]any{ + "schemaVersion": 1, + "id": id, + "name": "Zero " + id, + "version": version, + "tools": []any{map[string]any{ + "name": "lookup", + "command": "node", + "permission": "prompt", + }}, + }) + hashRoot := t.TempDir() + hashResult, err := plugins.Install(context.Background(), plugins.InstallOptions{Source: source, Dir: hashRoot}) + if err != nil { + t.Fatalf("seed hash install: %v", err) + } + return source, hashResult.Hash +} + +func writeMarketplaceUpdateCatalog(t *testing.T, releases map[string]marketplaceTestRelease) string { + t.Helper() + path := filepath.Join(t.TempDir(), "catalog.json") + writeMarketplaceUpdateCatalogAt(t, path, releases) + return path +} + +func writeMarketplaceUpdateCatalogAt(t *testing.T, path string, releases map[string]marketplaceTestRelease) { + t.Helper() + ids := make([]string, 0, len(releases)) + for id := range releases { + ids = append(ids, id) + } + sort.Strings(ids) + pluginsJSON := make([]string, 0, len(ids)) + for _, id := range ids { + release := releases[id] + pluginsJSON = append(pluginsJSON, `{ + "id": "`+id+`", + "name": "`+id+`", + "author": {"name": "Platform"}, + "license": "MIT", + "review": { + "status": "community", + "date": "2026-07-10", + "reviewer": "Zero Security", + "url": "https://github.com/Gitlawb/zero-plugins/pull/1" + }, + "releases": [ + { + "version": "`+release.Version+`", + "repository": "`+filepath.ToSlash(release.Source)+`", + "commit": "`+strings.Repeat("a", 40)+`", + "treeHash": "`+release.Hash+`", + "components": {"tools": [{"name": "lookup", "permission": "prompt"}]} + } + ] + }`) + } + body := `{ + "schemaVersion": 1, + "id": "team", + "owner": "Platform", + "plugins": [ + ` + strings.Join(pluginsJSON, ",\n ") + ` + ] +}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +type pluginUpdateResultForTest struct { + ID string `json:"id"` + Status string `json:"status"` + Version string `json:"version"` + UpdateAvailable bool `json:"updateAvailable"` +} + +func decodePluginUpdateResults(t *testing.T, data []byte) map[string]pluginUpdateResultForTest { + t.Helper() + var payload struct { + Results []pluginUpdateResultForTest `json:"results"` + } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("update JSON: %v\n%s", err, string(data)) + } + out := map[string]pluginUpdateResultForTest{} + for _, result := range payload.Results { + out[result.ID] = result + } + return out +} + func writeMarketplaceInstallCatalog(t *testing.T, source string, hash string) string { t.Helper() dir := t.TempDir() @@ -124,7 +481,12 @@ func writeMarketplaceInstallCatalog(t *testing.T, source string, hash string) st "name": "Zero Demo", "author": {"name": "Platform"}, "license": "MIT", - "review": {"status": "community"}, + "review": { + "status": "community", + "date": "2026-07-10", + "reviewer": "Zero Security", + "url": "https://github.com/Gitlawb/zero-plugins/pull/1" + }, "releases": [ { "version": "0.1.0", diff --git a/internal/cli/plugin_marketplace.go b/internal/cli/plugin_marketplace.go index dfe903a8..2c5e22dd 100644 --- a/internal/cli/plugin_marketplace.go +++ b/internal/cli/plugin_marketplace.go @@ -8,6 +8,8 @@ import ( "fmt" "io" "os" + "sort" + "strconv" "strings" "github.com/Gitlawb/zero/internal/marketplace" @@ -20,9 +22,12 @@ type marketplaceCommandOptions struct { scope marketplace.Scope catalogID string publicKeyPath string + privateKeyPath string allowUnverified bool version string yes bool + check bool + refresh bool } func runPluginMarketplace(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { @@ -41,6 +46,10 @@ func runPluginMarketplace(args []string, stdout io.Writer, stderr io.Writer, dep return runMarketplaceList(args[1:], stdout, stderr, deps) case "remove", "rm": return runMarketplaceRemove(args[1:], stdout, stderr, deps) + case "update", "refresh": + return runMarketplaceUpdate(args[1:], stdout, stderr, deps) + case "sign": + return runMarketplaceSign(args[1:], stdout, stderr) case "validate": return runMarketplaceValidate(args[1:], stdout, stderr) default: @@ -66,16 +75,28 @@ func runPluginBrowse(args []string, stdout io.Writer, stderr io.Writer, deps app if err != nil { return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) } - catalogEntry, ok, err := findRegisteredCatalog(options.catalogID, cwd) + catalogEntry, ok, err := findRegisteredCatalog(options.catalogID, cwd, stderr) if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) } if !ok { return writeExecUsageError(stderr, fmt.Sprintf("marketplace catalog %q is not registered", options.catalogID)) } - catalog, verification, err := loadLocalCatalogEntry(catalogEntry) - if err != nil { - return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + var catalog marketplace.Catalog + var verification marketplace.Verification + if options.refresh { + catalog, verification, _, err = updateCatalogCache(context.Background(), catalogEntry) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } + if err := updateCatalogVerificationStatus(catalogEntry, verification.Status, cwd); err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + } else { + catalog, verification, err = loadCatalogEntry(context.Background(), catalogEntry) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } } matches := marketplace.Search(catalog, query) if options.json { @@ -107,7 +128,7 @@ func runPluginMarketplaceInstall(args []string, stdout io.Writer, stderr io.Writ return exitSuccess } if target == "" { - return writeExecUsageError(stderr, "usage: zero plugins install [--version ] [--scope user|project] [--yes] [--json]") + return writeExecUsageError(stderr, "usage: zero plugins install [--version ] [--scope user|project] [--yes] [--allow-unverified] [--json]") } if !options.yes { return writeExecUsageError(stderr, "marketplace installs require --yes") @@ -117,10 +138,13 @@ func runPluginMarketplaceInstall(args []string, stdout io.Writer, stderr io.Writ if err != nil { return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) } - resolved, err := resolveMarketplacePlugin(cwd, pluginID, catalogID) + resolved, err := resolveMarketplacePlugin(cwd, pluginID, catalogID, stderr) if err != nil { return writeExecUsageError(stderr, err.Error()) } + if err := requireCatalogVerification(resolved.entry.ID, resolved.verification, options.allowUnverified); err != nil { + return writeExecUsageError(stderr, err.Error()) + } release, ok := selectRelease(resolved.plugin, options.version) if !ok { if options.version == "" { @@ -149,6 +173,7 @@ func runPluginMarketplaceInstall(args []string, stdout io.Writer, stderr io.Writ Commit: release.Commit, Subdir: release.Subdir, Catalog: resolved.catalog.ID, + Pinned: strings.TrimSpace(options.version) != "", }) if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) @@ -188,6 +213,286 @@ func runPluginMarketplaceInstall(args []string, stdout io.Writer, stderr io.Writ return exitSuccess } +func runPluginMarketplaceInfo(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + target, options, help, err := parseMarketplaceInstallArgs(args) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if _, err := fmt.Fprintln(stdout, "Usage:\n zero plugins info [--version ] [--allow-unverified] [--json]"); err != nil { + return exitCrash + } + return exitSuccess + } + if target == "" { + return writeExecUsageError(stderr, "usage: zero plugins info [--version ] [--allow-unverified] [--json]") + } + pluginID, catalogID := splitMarketplaceTarget(target) + cwd, err := deps.getwd() + if err != nil { + return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) + } + resolved, err := resolveMarketplacePlugin(cwd, pluginID, catalogID, stderr) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if err := requireCatalogVerification(resolved.entry.ID, resolved.verification, options.allowUnverified); err != nil { + return writeExecUsageError(stderr, err.Error()) + } + release, ok := selectRelease(resolved.plugin, options.version) + if !ok { + return writeExecUsageError(stderr, fmt.Sprintf("marketplace plugin %q has no version %q", pluginID, options.version)) + } + payload := struct { + Catalog string `json:"catalog"` + Verification marketplace.Verification `json:"verification"` + Plugin marketplace.CatalogPlugin `json:"plugin"` + Release marketplace.Release `json:"release"` + Risk marketplace.RiskReport `json:"risk"` + }{Catalog: resolved.catalog.ID, Verification: resolved.verification, Plugin: resolved.plugin, Release: release, Risk: marketplace.RiskForRelease(release)} + if options.json { + if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintf(stdout, "%s@%s from %s (%s)\n", resolved.plugin.ID, release.Version, resolved.catalog.ID, resolved.verification.Status); err != nil { + return exitCrash + } + return exitSuccess +} + +func runPluginMarketplaceUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + target, options, help, err := parseMarketplaceUpdateArgs(args) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if _, err := fmt.Fprintln(stdout, "Usage:\n zero plugins update [id[@catalog]] [--scope user|project] [--check] [--yes] [--allow-unverified] [--json]"); err != nil { + return exitCrash + } + return exitSuccess + } + if !options.check && !options.yes { + return writeExecUsageError(stderr, "marketplace updates require --yes") + } + if options.scope == "" { + options.scope = marketplace.ScopeUser + } + pluginScope := plugins.SourceUser + if options.scope == marketplace.ScopeProject { + pluginScope = plugins.SourceProject + } + dir, err := pluginDirForScope(deps, pluginScope) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + lock, err := plugins.ReadLock(dir) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } + cwd, err := deps.getwd() + if err != nil { + return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) + } + + targets, err := marketplaceUpdateTargets(target, lock) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + results := make([]pluginUpdateResult, 0, len(targets)) + for _, updateTarget := range targets { + result, err := runMarketplaceUpdateTarget(cwd, dir, updateTarget, lock[updateTarget.pluginID], options, stderr) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + results = append(results, result) + } + + if options.json { + payload := struct { + Results []pluginUpdateResult `json:"results"` + }{Results: results} + if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintln(stdout, formatPluginUpdateResults(results, options.check)); err != nil { + return exitCrash + } + return exitSuccess +} + +type marketplaceUpdateTarget struct { + pluginID string + catalogID string +} + +type pluginUpdateResult struct { + ID string `json:"id"` + Catalog string `json:"catalog,omitempty"` + CurrentVersion string `json:"currentVersion,omitempty"` + Version string `json:"version,omitempty"` + CurrentHash string `json:"currentHash,omitempty"` + Hash string `json:"hash,omitempty"` + Status string `json:"status"` + UpdateAvailable bool `json:"updateAvailable"` + Pinned bool `json:"pinned,omitempty"` +} + +func marketplaceUpdateTargets(target string, lock map[string]plugins.LockEntry) ([]marketplaceUpdateTarget, error) { + if strings.TrimSpace(target) != "" { + pluginID, catalogID := splitMarketplaceTarget(target) + if strings.TrimSpace(pluginID) == "" { + return nil, fmt.Errorf("plugin id is required") + } + entry, ok := lock[pluginID] + if !ok || strings.TrimSpace(entry.Catalog) == "" { + return nil, fmt.Errorf("plugin %q has no marketplace catalog metadata", pluginID) + } + if catalogID == "" { + catalogID = entry.Catalog + } + return []marketplaceUpdateTarget{{pluginID: pluginID, catalogID: catalogID}}, nil + } + ids := make([]string, 0, len(lock)) + for id, entry := range lock { + if strings.TrimSpace(entry.Catalog) != "" { + ids = append(ids, id) + } + } + sort.Strings(ids) + targets := make([]marketplaceUpdateTarget, 0, len(ids)) + for _, id := range ids { + targets = append(targets, marketplaceUpdateTarget{pluginID: id, catalogID: lock[id].Catalog}) + } + return targets, nil +} + +func runMarketplaceUpdateTarget(cwd string, dir string, target marketplaceUpdateTarget, entry plugins.LockEntry, options marketplaceCommandOptions, stderr io.Writer) (pluginUpdateResult, error) { + result := pluginUpdateResult{ + ID: target.pluginID, + Catalog: target.catalogID, + CurrentVersion: entry.Version, + CurrentHash: entry.Hash, + Pinned: entry.Pinned, + } + if entry.Pinned && strings.TrimSpace(options.version) == "" { + result.Version = entry.Version + result.Hash = entry.Hash + result.Status = "pinned" + return result, nil + } + resolved, err := resolveMarketplacePlugin(cwd, target.pluginID, target.catalogID, stderr) + if err != nil { + return pluginUpdateResult{}, err + } + if err := requireCatalogVerification(resolved.entry.ID, resolved.verification, options.allowUnverified); err != nil { + return pluginUpdateResult{}, err + } + release, ok := selectRelease(resolved.plugin, options.version) + if !ok { + return pluginUpdateResult{}, fmt.Errorf("marketplace plugin %q has no version %q", target.pluginID, options.version) + } + result.Catalog = resolved.catalog.ID + result.Version = release.Version + result.Hash = release.TreeHash + result.UpdateAvailable = release.Version != entry.Version || release.TreeHash != entry.Hash + if options.check { + if result.UpdateAvailable { + result.Status = "available" + } else { + result.Status = "current" + } + return result, nil + } + if !result.UpdateAvailable { + result.Status = "current" + return result, nil + } + install, err := plugins.Install(context.Background(), plugins.InstallOptions{ + Source: release.Repository, + Dir: dir, + Force: true, + ExpectedID: resolved.plugin.ID, + ExpectedVersion: release.Version, + ExpectedComponents: marketplaceInstallInventory(release.Components), + ExpectedHash: release.TreeHash, + Commit: release.Commit, + Subdir: release.Subdir, + Catalog: resolved.catalog.ID, + Pinned: strings.TrimSpace(options.version) != "", + }) + if err != nil { + return pluginUpdateResult{}, err + } + result.Hash = install.Hash + result.Status = "updated" + return result, nil +} + +func formatPluginUpdateResults(results []pluginUpdateResult, check bool) string { + if len(results) == 0 { + return "No marketplace-managed plugins found." + } + lines := []string{} + if check { + lines = append(lines, "Marketplace plugin update check:") + } else { + lines = append(lines, "Marketplace plugin updates:") + } + for _, result := range results { + line := fmt.Sprintf(" %s: %s", result.ID, result.Status) + if result.Version != "" { + line += " -> " + result.Version + } + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + +func runPluginVerify(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + id, options, help, err := parseMarketplaceIDArgs(args, "plugins verify") + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if _, err := fmt.Fprintln(stdout, "Usage:\n zero plugins verify [--json]"); err != nil { + return exitCrash + } + return exitSuccess + } + if id == "" { + return writeExecUsageError(stderr, "usage: zero plugins verify [--json]") + } + if options.scope == "" { + options.scope = marketplace.ScopeUser + } + pluginScope := plugins.SourceUser + if options.scope == marketplace.ScopeProject { + pluginScope = plugins.SourceProject + } + dir, err := pluginDirForScope(deps, pluginScope) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + result, err := plugins.VerifyInstalled(dir, id) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } + if options.json { + if err := writePrettyJSON(stdout, result); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintf(stdout, "Verified plugin %s (%s).\n", result.ID, result.Hash); err != nil { + return exitCrash + } + return exitSuccess +} + func marketplaceInstallInventory(components marketplace.ComponentInventory) *plugins.InstallComponentInventory { inventory := plugins.InstallComponentInventory{ Tools: make([]plugins.InstallToolComponent, 0, len(components.Tools)), @@ -269,15 +574,19 @@ func runMarketplaceAdd(args []string, stdout io.Writer, stderr io.Writer, deps a if err != nil { return writeExecUsageError(stderr, err.Error()) } - if parsedSource.Kind != marketplace.CatalogSourceLocal { - return writeExecUsageError(stderr, "only local catalog files can be added before a marketplace cache is created") + if options.scope == marketplace.ScopeProject { + excludeProject, trustCheckErrored := resolveTrust(cwd) + if excludeProject { + emitTrustNotice(stderr, trustSkip{excludedProjectConfig: true, trustCheckErrored: trustCheckErrored}) + return writeExecUsageError(stderr, "project marketplace catalogs require a trusted workspace; run `zero trust`") + } } - catalog, verification, err := validateCatalogPath(source, options.publicKeyPath) + catalog, verification, cachePath, err := loadCatalogForRegistration(context.Background(), source, parsedSource, options.scope, cwd, options.publicKeyPath) if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) } - if verification.Status != marketplace.VerificationSigned && !options.allowUnverified { - return writeExecUsageError(stderr, "unsigned marketplace catalogs require --allow-unverified") + if err := requireCatalogVerification(catalog.ID, verification, options.allowUnverified); err != nil { + return writeExecUsageError(stderr, err.Error()) } path, err := marketplace.RegistryPathForScope(options.scope, cwd, nil) if err != nil { @@ -292,6 +601,8 @@ func runMarketplaceAdd(args []string, stdout io.Writer, stderr io.Writer, deps a Source: source, PublicKeyPath: options.publicKeyPath, VerificationStatus: verification.Status, + Scope: options.scope, + CachePath: cachePath, } if err := registry.Add(entry); err != nil { return writeExecUsageError(stderr, err.Error()) @@ -326,7 +637,7 @@ func runMarketplaceList(args []string, stdout io.Writer, stderr io.Writer, deps if err != nil { return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) } - catalogs, err := registeredCatalogs(cwd) + catalogs, err := registeredCatalogsForCLI(cwd, stderr) if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) } @@ -405,43 +716,354 @@ func runMarketplaceRemove(args []string, stdout io.Writer, stderr io.Writer, dep return exitSuccess } +func runMarketplaceUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + id, options, help, err := parseMarketplaceIDArgs(args, "marketplace update") + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writeMarketplaceUpdateHelp(stdout); err != nil { + return exitCrash + } + return exitSuccess + } + if id == "" { + return writeExecUsageError(stderr, "usage: zero plugins marketplace update [--json]") + } + cwd, err := deps.getwd() + if err != nil { + return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) + } + entry, ok, err := findRegisteredCatalog(id, cwd, stderr) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + if !ok { + return writeExecUsageError(stderr, fmt.Sprintf("marketplace catalog %q is not registered", id)) + } + catalog, verification, cached, err := updateCatalogCache(context.Background(), entry) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } + if err := updateCatalogVerificationStatus(entry, verification.Status, cwd); err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + if options.json { + payload := struct { + ID string `json:"id"` + Cached bool `json:"cached"` + Verification marketplace.Verification `json:"verification"` + }{ID: catalog.ID, Cached: cached, Verification: verification} + if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil { + return exitCrash + } + return exitSuccess + } + if cached { + if _, err := fmt.Fprintf(stdout, "Updated marketplace catalog %s (%s).\n", catalog.ID, verification.Status); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintf(stdout, "Marketplace catalog %s is local (%s).\n", catalog.ID, verification.Status); err != nil { + return exitCrash + } + return exitSuccess +} + +func runMarketplaceSign(args []string, stdout io.Writer, stderr io.Writer) int { + path, options, help, err := parseMarketplaceSignArgs(args) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writeMarketplaceSignHelp(stdout); err != nil { + return exitCrash + } + return exitSuccess + } + if path == "" || options.privateKeyPath == "" { + return writeExecUsageError(stderr, "usage: zero plugins marketplace sign --private-key [--json]") + } + data, _, err := marketplace.ReadLocalCatalog(path) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } + if _, err := marketplace.ParseCatalog(data); err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } + privateKey, err := readEd25519PrivateKey(options.privateKeyPath) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) + } + signature := ed25519.Sign(privateKey, data) + signaturePath := marketplace.SignaturePathForCatalog(path) + if err := os.WriteFile(signaturePath, signature, 0o644); err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + if options.json { + if err := writePrettyJSON(stdout, map[string]any{"path": signaturePath, "signed": true}); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintf(stdout, "Signed marketplace catalog %s.\n", path); err != nil { + return exitCrash + } + return exitSuccess +} + func validateCatalogPath(path string, publicKeyPath string) (marketplace.Catalog, marketplace.Verification, error) { + return validateCatalogPathWithPublicKey(path, publicKeyPath, nil) +} + +func validateCatalogPathWithPublicKey(path string, publicKeyPath string, publicKey ed25519.PublicKey) (marketplace.Catalog, marketplace.Verification, error) { data, signature, err := marketplace.ReadLocalCatalog(path) if err != nil { return marketplace.Catalog{}, marketplace.Verification{}, err } + return validateCatalogBytesWithPublicKey(data, signature, publicKeyPath, publicKey) +} + +func validateCatalogBytes(data []byte, signature []byte, publicKeyPath string) (marketplace.Catalog, marketplace.Verification, error) { + return validateCatalogBytesWithPublicKey(data, signature, publicKeyPath, nil) +} + +func validateCatalogBytesWithPublicKey(data []byte, signature []byte, publicKeyPath string, publicKey ed25519.PublicKey) (marketplace.Catalog, marketplace.Verification, error) { catalog, err := marketplace.ParseCatalog(data) if err != nil { return marketplace.Catalog{}, marketplace.Verification{}, err } + if len(publicKey) > 0 { + return catalog, marketplace.VerifyCatalogSignature(data, signature, publicKey), nil + } if publicKeyPath == "" { return catalog, marketplace.Verification{Status: marketplace.VerificationUnsigned}, nil } - publicKey, err := readEd25519PublicKey(publicKeyPath) + filePublicKey, err := readEd25519PublicKey(publicKeyPath) if err != nil { return marketplace.Catalog{}, marketplace.Verification{}, err } - return catalog, marketplace.VerifyCatalogSignature(data, signature, publicKey), nil + return catalog, marketplace.VerifyCatalogSignature(data, signature, filePublicKey), nil +} + +func catalogExpectsSignature(publicKeyPath string, publicKey ed25519.PublicKey) bool { + return strings.TrimSpace(publicKeyPath) != "" || len(publicKey) > 0 +} + +func validateCatalogSourceRepositories(catalog marketplace.Catalog, source marketplace.CatalogSource) error { + if source.Kind == marketplace.CatalogSourceLocal { + return nil + } + return marketplace.ValidateRemoteCatalogReleaseSources(catalog) +} + +func loadCatalogForRegistration(ctx context.Context, source string, parsedSource marketplace.CatalogSource, scope marketplace.Scope, cwd string, publicKeyPath string) (marketplace.Catalog, marketplace.Verification, string, error) { + if parsedSource.Kind == marketplace.CatalogSourceLocal { + catalog, verification, err := validateCatalogPath(source, publicKeyPath) + return catalog, verification, "", err + } + data, signature, err := marketplace.FetchCatalog(ctx, source) + if err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, "", err + } + catalog, verification, err := validateCatalogBytes(data, signature, publicKeyPath) + if err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, "", err + } + if err := validateCatalogSourceRepositories(catalog, parsedSource); err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, "", err + } + if verification.Status == marketplace.VerificationInvalid { + return marketplace.Catalog{}, marketplace.Verification{}, "", fmt.Errorf("invalid marketplace catalog signature: %s", verification.Error) + } + cachePath, err := marketplace.CachePathForScope(scope, cwd, catalog.ID, nil) + if err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, "", err + } + if err := marketplace.SaveCachedCatalog(cachePath, data, signature); err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, "", err + } + return catalog, verification, cachePath, nil +} + +func loadCatalogEntry(ctx context.Context, entry marketplace.RegisteredCatalog) (marketplace.Catalog, marketplace.Verification, error) { + return loadCatalogEntryWithRefresh(ctx, entry, true) } -func loadLocalCatalogEntry(entry marketplace.RegisteredCatalog) (marketplace.Catalog, marketplace.Verification, error) { +func loadCatalogEntryCachedOnly(entry marketplace.RegisteredCatalog) (marketplace.Catalog, marketplace.Verification, error) { + return loadCatalogEntryWithRefresh(context.Background(), entry, false) +} + +func loadCatalogEntryWithRefresh(ctx context.Context, entry marketplace.RegisteredCatalog, refreshMissingCache bool) (marketplace.Catalog, marketplace.Verification, error) { source, err := marketplace.ParseCatalogSource(entry.Source) if err != nil { return marketplace.Catalog{}, marketplace.Verification{}, err } - if source.Kind != marketplace.CatalogSourceLocal { + if source.Kind == marketplace.CatalogSourceLocal { + catalog, verification, err := validateCatalogPathWithPublicKey(entry.Source, entry.PublicKeyPath, entry.PublicKey) + if err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, err + } + if verification.Status == marketplace.VerificationInvalid { + return marketplace.Catalog{}, marketplace.Verification{}, fmt.Errorf("invalid marketplace catalog signature: %s", verification.Error) + } + if catalogExpectsSignature(entry.PublicKeyPath, entry.PublicKey) && verification.Status != marketplace.VerificationSigned { + return marketplace.Catalog{}, marketplace.Verification{}, fmt.Errorf("signed marketplace catalog %q is missing a valid signature", entry.ID) + } + return catalog, verification, nil + } + cachePath := entry.CachePath + if cachePath == "" { return marketplace.Catalog{}, marketplace.Verification{}, fmt.Errorf("catalog %q has no local cache; run `zero plugins marketplace update %s`", entry.ID, entry.ID) } - return validateCatalogPath(entry.Source, entry.PublicKeyPath) + if _, _, err := marketplace.ReadLocalCatalog(cachePath); err != nil { + if !refreshMissingCache { + return marketplace.Catalog{}, marketplace.Verification{}, fmt.Errorf("catalog %q has no local cache; run `zero plugins marketplace update %s`: %w", entry.ID, entry.ID, err) + } + if catalog, verification, _, updateErr := updateCatalogCache(ctx, entry); updateErr != nil { + return marketplace.Catalog{}, marketplace.Verification{}, fmt.Errorf("catalog %q has no local cache; update failed: %w", entry.ID, updateErr) + } else { + return catalog, verification, nil + } + } + catalog, verification, err := validateCatalogPathWithPublicKey(cachePath, entry.PublicKeyPath, entry.PublicKey) + if err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, err + } + if err := validateCatalogSourceRepositories(catalog, source); err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, err + } + if catalog.ID != entry.ID { + return marketplace.Catalog{}, marketplace.Verification{}, fmt.Errorf("cached catalog id %q does not match registry id %q", catalog.ID, entry.ID) + } + if verification.Status == marketplace.VerificationInvalid { + return marketplace.Catalog{}, marketplace.Verification{}, fmt.Errorf("invalid marketplace catalog signature: %s", verification.Error) + } + if catalogExpectsSignature(entry.PublicKeyPath, entry.PublicKey) && verification.Status != marketplace.VerificationSigned { + return marketplace.Catalog{}, marketplace.Verification{}, fmt.Errorf("signed marketplace catalog %q is missing a valid signature", entry.ID) + } + if entry.VerificationStatus == marketplace.VerificationStale { + verification.Status = marketplace.VerificationStale + } + return catalog, verification, nil } -func registeredCatalogs(cwd string) ([]marketplace.RegisteredCatalog, error) { +func updateCatalogCache(ctx context.Context, entry marketplace.RegisteredCatalog) (marketplace.Catalog, marketplace.Verification, bool, error) { + source, err := marketplace.ParseCatalogSource(entry.Source) + if err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, false, err + } + if source.Kind == marketplace.CatalogSourceLocal { + catalog, verification, err := validateCatalogPathWithPublicKey(entry.Source, entry.PublicKeyPath, entry.PublicKey) + if err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, false, err + } + if catalog.ID != entry.ID { + return marketplace.Catalog{}, marketplace.Verification{}, false, fmt.Errorf("local catalog id %q does not match registry id %q", catalog.ID, entry.ID) + } + if verification.Status == marketplace.VerificationInvalid { + return marketplace.Catalog{}, marketplace.Verification{}, false, fmt.Errorf("invalid marketplace catalog signature: %s", verification.Error) + } + if catalogExpectsSignature(entry.PublicKeyPath, entry.PublicKey) && verification.Status != marketplace.VerificationSigned { + return marketplace.Catalog{}, marketplace.Verification{}, false, fmt.Errorf("signed marketplace catalog %q is missing a valid signature", entry.ID) + } + return catalog, verification, false, nil + } + data, signature, err := marketplace.FetchCatalog(ctx, entry.Source) + if err != nil { + return loadStaleCachedCatalog(entry, err) + } + catalog, verification, err := validateCatalogBytesWithPublicKey(data, signature, entry.PublicKeyPath, entry.PublicKey) + if err != nil { + return loadStaleCachedCatalog(entry, err) + } + if err := validateCatalogSourceRepositories(catalog, source); err != nil { + return loadStaleCachedCatalog(entry, err) + } + if catalog.ID != entry.ID { + return loadStaleCachedCatalog(entry, fmt.Errorf("fetched catalog id %q does not match registry id %q", catalog.ID, entry.ID)) + } + if verification.Status == marketplace.VerificationInvalid { + return loadStaleCachedCatalog(entry, fmt.Errorf("invalid marketplace catalog signature: %s", verification.Error)) + } + if catalogExpectsSignature(entry.PublicKeyPath, entry.PublicKey) && verification.Status != marketplace.VerificationSigned { + return loadStaleCachedCatalog(entry, fmt.Errorf("signed marketplace catalog %q is missing a valid signature", entry.ID)) + } + if entry.CachePath == "" { + return marketplace.Catalog{}, marketplace.Verification{}, false, fmt.Errorf("catalog %q has no cache path", entry.ID) + } + if err := marketplace.SaveCachedCatalog(entry.CachePath, data, signature); err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, false, err + } + return catalog, verification, true, nil +} + +func loadStaleCachedCatalog(entry marketplace.RegisteredCatalog, cause error) (marketplace.Catalog, marketplace.Verification, bool, error) { + if entry.CachePath == "" { + return marketplace.Catalog{}, marketplace.Verification{}, false, cause + } + catalog, verification, err := validateCatalogPathWithPublicKey(entry.CachePath, entry.PublicKeyPath, entry.PublicKey) + if err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, false, cause + } + source, sourceErr := marketplace.ParseCatalogSource(entry.Source) + if sourceErr != nil { + return marketplace.Catalog{}, marketplace.Verification{}, false, cause + } + if err := validateCatalogSourceRepositories(catalog, source); err != nil { + return marketplace.Catalog{}, marketplace.Verification{}, false, cause + } + if catalog.ID != entry.ID { + return marketplace.Catalog{}, marketplace.Verification{}, false, cause + } + if verification.Status == marketplace.VerificationInvalid { + return marketplace.Catalog{}, marketplace.Verification{}, false, cause + } + if catalogExpectsSignature(entry.PublicKeyPath, entry.PublicKey) && verification.Status != marketplace.VerificationSigned { + return marketplace.Catalog{}, marketplace.Verification{}, false, cause + } + verification.Status = marketplace.VerificationStale + if cause != nil { + verification.Error = cause.Error() + } + return catalog, verification, false, nil +} + +func requireCatalogVerification(catalogID string, verification marketplace.Verification, allowUnverified bool) error { + switch verification.Status { + case marketplace.VerificationSigned: + return nil + case marketplace.VerificationInvalid: + if verification.Error != "" { + return fmt.Errorf("invalid marketplace catalog %q signature: %s", catalogID, verification.Error) + } + return fmt.Errorf("invalid marketplace catalog %q signature", catalogID) + case marketplace.VerificationUnsigned, marketplace.VerificationStale, "": + if allowUnverified { + return nil + } + return fmt.Errorf("unsigned marketplace catalog %q requires --allow-unverified", catalogID) + default: + return fmt.Errorf("unsupported marketplace catalog %q verification status %q", catalogID, verification.Status) + } +} + +func registeredCatalogs(cwd string, includeProject bool) ([]marketplace.RegisteredCatalog, error) { + officialCache, _ := marketplace.CachePathForScope(marketplace.ScopeUser, cwd, marketplace.OfficialCatalogID, nil) catalogs := []marketplace.RegisteredCatalog{{ ID: marketplace.OfficialCatalogID, Source: marketplace.OfficialCatalogSource, - VerificationStatus: marketplace.VerificationUnsigned, + PublicKey: marketplace.OfficialCatalogPublicKey, + VerificationStatus: marketplace.VerificationSigned, + Scope: marketplace.ScopeUser, + CachePath: officialCache, }} - for _, scope := range []marketplace.Scope{marketplace.ScopeUser, marketplace.ScopeProject} { + scopes := []marketplace.Scope{marketplace.ScopeUser} + if includeProject { + scopes = append(scopes, marketplace.ScopeProject) + } + for _, scope := range scopes { path, err := marketplace.RegistryPathForScope(scope, cwd, nil) if err != nil { return nil, err @@ -450,13 +1072,33 @@ func registeredCatalogs(cwd string) ([]marketplace.RegisteredCatalog, error) { if err != nil { return nil, err } - catalogs = append(catalogs, registry.Catalogs...) + for _, entry := range registry.Catalogs { + entry.Scope = scope + if entry.VerificationStatus == "" { + entry.VerificationStatus = marketplace.VerificationUnsigned + } + cachePath, err := marketplace.CachePathForScope(scope, cwd, entry.ID, nil) + if err != nil { + return nil, err + } + entry.CachePath = cachePath + catalogs = append(catalogs, entry) + } } return catalogs, nil } -func findRegisteredCatalog(id string, cwd string) (marketplace.RegisteredCatalog, bool, error) { - catalogs, err := registeredCatalogs(cwd) +func registeredCatalogsForCLI(cwd string, stderr io.Writer) ([]marketplace.RegisteredCatalog, error) { + excludeProject, trustCheckErrored := resolveTrust(cwd) + emitTrustNotice(stderr, trustSkip{ + excludedProjectConfig: excludeProject && projectMarketplaceRegistryExists(cwd), + trustCheckErrored: trustCheckErrored, + }) + return registeredCatalogs(cwd, !excludeProject) +} + +func findRegisteredCatalog(id string, cwd string, stderr io.Writer) (marketplace.RegisteredCatalog, bool, error) { + catalogs, err := registeredCatalogsForCLI(cwd, stderr) if err != nil { return marketplace.RegisteredCatalog{}, false, err } @@ -469,16 +1111,17 @@ func findRegisteredCatalog(id string, cwd string) (marketplace.RegisteredCatalog } type resolvedMarketplacePlugin struct { - entry marketplace.RegisteredCatalog - catalog marketplace.Catalog - plugin marketplace.CatalogPlugin + entry marketplace.RegisteredCatalog + catalog marketplace.Catalog + verification marketplace.Verification + plugin marketplace.CatalogPlugin } -func resolveMarketplacePlugin(cwd string, pluginID string, catalogID string) (resolvedMarketplacePlugin, error) { +func resolveMarketplacePlugin(cwd string, pluginID string, catalogID string, stderr io.Writer) (resolvedMarketplacePlugin, error) { if strings.TrimSpace(pluginID) == "" { return resolvedMarketplacePlugin{}, fmt.Errorf("plugin id is required") } - catalogs, err := registeredCatalogs(cwd) + catalogs, err := registeredCatalogsForCLI(cwd, stderr) if err != nil { return resolvedMarketplacePlugin{}, err } @@ -487,7 +1130,7 @@ func resolveMarketplacePlugin(cwd string, pluginID string, catalogID string) (re if catalogID != "" && entry.ID != catalogID { continue } - catalog, _, err := loadLocalCatalogEntry(entry) + catalog, verification, err := loadCatalogEntry(context.Background(), entry) if err != nil { if catalogID != "" { return resolvedMarketplacePlugin{}, err @@ -496,7 +1139,7 @@ func resolveMarketplacePlugin(cwd string, pluginID string, catalogID string) (re } for _, plugin := range catalog.Plugins { if plugin.ID == pluginID { - matches = append(matches, resolvedMarketplacePlugin{entry: entry, catalog: catalog, plugin: plugin}) + matches = append(matches, resolvedMarketplacePlugin{entry: entry, catalog: catalog, verification: verification, plugin: plugin}) } } } @@ -538,7 +1181,42 @@ func selectRelease(plugin marketplace.CatalogPlugin, version string) (marketplac } return marketplace.Release{}, false } - return plugin.Releases[0], true + releases := append([]marketplace.Release{}, plugin.Releases...) + sort.SliceStable(releases, func(left int, right int) bool { + return compareSemver(releases[left].Version, releases[right].Version) > 0 + }) + return releases[0], true +} + +func compareSemver(left string, right string) int { + leftParts := semverCore(left) + rightParts := semverCore(right) + for index := 0; index < 3; index++ { + if leftParts[index] > rightParts[index] { + return 1 + } + if leftParts[index] < rightParts[index] { + return -1 + } + } + return strings.Compare(left, right) +} + +func semverCore(version string) [3]int { + version = strings.TrimPrefix(strings.TrimSpace(version), "v") + if before, _, ok := strings.Cut(version, "-"); ok { + version = before + } + if before, _, ok := strings.Cut(version, "+"); ok { + version = before + } + parts := strings.Split(version, ".") + var result [3]int + for index := 0; index < len(parts) && index < 3; index++ { + value, _ := strconv.Atoi(parts[index]) + result[index] = value + } + return result } func readEd25519PublicKey(path string) (ed25519.PublicKey, error) { @@ -559,6 +1237,57 @@ func readEd25519PublicKey(path string) (ed25519.PublicKey, error) { return nil, fmt.Errorf("public key must be raw, hex, or base64 Ed25519 public key bytes") } +func readEd25519PrivateKey(path string) (ed25519.PrivateKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read private key: %w", err) + } + trimmed := strings.TrimSpace(string(data)) + if decoded, err := hex.DecodeString(strings.TrimPrefix(trimmed, "0x")); err == nil && len(decoded) == ed25519.PrivateKeySize { + return ed25519.PrivateKey(decoded), nil + } + if decoded, err := base64.StdEncoding.DecodeString(trimmed); err == nil && len(decoded) == ed25519.PrivateKeySize { + return ed25519.PrivateKey(decoded), nil + } + if len(data) == ed25519.PrivateKeySize { + return ed25519.PrivateKey(data), nil + } + return nil, fmt.Errorf("private key must be raw, hex, or base64 Ed25519 private key bytes") +} + +func updateCatalogVerificationStatus(entry marketplace.RegisteredCatalog, status marketplace.VerificationStatus, cwd string) error { + if entry.ID == marketplace.OfficialCatalogID { + return nil + } + path, err := marketplace.RegistryPathForScope(entry.Scope, cwd, nil) + if err != nil { + return err + } + registry, err := marketplace.LoadRegistry(path) + if err != nil { + return err + } + for index := range registry.Catalogs { + if registry.Catalogs[index].ID == entry.ID { + registry.Catalogs[index].VerificationStatus = status + return marketplace.SaveRegistry(path, registry) + } + } + return nil +} + +func projectMarketplaceRegistryExists(workspaceRoot string) bool { + if workspaceRoot == "" { + return false + } + path, err := marketplace.RegistryPathForScope(marketplace.ScopeProject, workspaceRoot, nil) + if err != nil { + return false + } + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + func parseBrowseArgs(args []string) (string, marketplaceCommandOptions, bool, error) { options := marketplaceCommandOptions{} query := "" @@ -576,7 +1305,7 @@ func parseBrowseArgs(args []string) (string, marketplaceCommandOptions, bool, er } options.catalogID = args[index] case "--refresh": - return query, options, false, execUsageError{"--refresh is not available before marketplace cache update support"} + options.refresh = true default: if strings.HasPrefix(arg, "-") { return query, options, false, execUsageError{fmt.Sprintf("unknown plugins browse flag %q", arg)} @@ -643,6 +1372,8 @@ func parseMarketplaceInstallArgs(args []string) (string, marketplaceCommandOptio options.json = true case "--yes", "-y": options.yes = true + case "--allow-unverified": + options.allowUnverified = true case "--version": index++ if index >= len(args) || strings.TrimSpace(args[index]) == "" { @@ -672,6 +1403,51 @@ func parseMarketplaceInstallArgs(args []string) (string, marketplaceCommandOptio return target, options, false, nil } +func parseMarketplaceUpdateArgs(args []string) (string, marketplaceCommandOptions, bool, error) { + options := marketplaceCommandOptions{} + target := "" + for index := 0; index < len(args); index++ { + arg := args[index] + switch arg { + case "-h", "--help", "help": + return target, options, true, nil + case "--json": + options.json = true + case "--yes", "-y": + options.yes = true + case "--check": + options.check = true + case "--allow-unverified": + options.allowUnverified = true + case "--version": + index++ + if index >= len(args) || strings.TrimSpace(args[index]) == "" { + return target, options, false, execUsageError{"--version requires a value"} + } + options.version = args[index] + case "--scope": + index++ + if index >= len(args) { + return target, options, false, execUsageError{"--scope requires user or project"} + } + scope, err := parseMarketplaceScope(args[index]) + if err != nil { + return target, options, false, err + } + options.scope = scope + default: + if strings.HasPrefix(arg, "-") { + return target, options, false, execUsageError{fmt.Sprintf("unknown plugins update flag %q", arg)} + } + if target != "" { + return target, options, false, execUsageError{"plugins update accepts at most one plugin id"} + } + target = arg + } + } + return target, options, false, nil +} + func parseMarketplacePathArgs(args []string, label string) (string, marketplaceCommandOptions, bool, error) { options := marketplaceCommandOptions{} path := "" @@ -701,6 +1477,35 @@ func parseMarketplacePathArgs(args []string, label string) (string, marketplaceC return path, options, false, nil } +func parseMarketplaceSignArgs(args []string) (string, marketplaceCommandOptions, bool, error) { + options := marketplaceCommandOptions{} + path := "" + for index := 0; index < len(args); index++ { + arg := args[index] + switch arg { + case "-h", "--help", "help": + return path, options, true, nil + case "--json": + options.json = true + case "--private-key": + index++ + if index >= len(args) || strings.TrimSpace(args[index]) == "" { + return path, options, false, execUsageError{"--private-key requires a file"} + } + options.privateKeyPath = args[index] + default: + if strings.HasPrefix(arg, "-") { + return path, options, false, execUsageError{fmt.Sprintf("unknown marketplace sign flag %q", arg)} + } + if path != "" { + return path, options, false, execUsageError{"marketplace sign accepts a single path"} + } + path = arg + } + } + return path, options, false, nil +} + func parseMarketplaceListArgs(args []string) (marketplaceCommandOptions, bool, error) { options := marketplaceCommandOptions{} for _, arg := range args { @@ -793,6 +1598,8 @@ Commands: add Add a plugin catalog list List configured plugin catalogs remove Remove a custom plugin catalog + update Refresh a remote catalog cache + sign Write detached catalog.sig validate Validate a catalog.json file `) return err @@ -813,6 +1620,7 @@ Flags: --version Install exact release and pin lock metadata --scope user|project Install scope (default user) --yes Confirm install from catalog metadata + --allow-unverified Allow unsigned catalog install --json Print JSON `) return err @@ -845,6 +1653,20 @@ func writeMarketplaceRemoveHelp(w io.Writer) error { return err } +func writeMarketplaceUpdateHelp(w io.Writer) error { + _, err := fmt.Fprint(w, `Usage: + zero plugins marketplace update [--json] +`) + return err +} + +func writeMarketplaceSignHelp(w io.Writer) error { + _, err := fmt.Fprint(w, `Usage: + zero plugins marketplace sign --private-key [--json] +`) + return err +} + func writeMarketplaceValidateHelp(w io.Writer) error { _, err := fmt.Fprint(w, `Usage: zero plugins marketplace validate [--json] [--public-key ] diff --git a/internal/marketplace/catalog.go b/internal/marketplace/catalog.go index 9c8fc3e7..6e63dd23 100644 --- a/internal/marketplace/catalog.go +++ b/internal/marketplace/catalog.go @@ -11,6 +11,7 @@ import ( "regexp" "sort" "strings" + "time" "github.com/Gitlawb/zero/internal/plugins" ) @@ -205,18 +206,38 @@ func validatePlugin(field string, plugin CatalogPlugin) error { func validateReview(field string, review ReviewRecord) error { switch review.Status { case ReviewStatusReviewed, ReviewStatusCommunity, ReviewStatusUnreviewed: - return nil default: return fmt.Errorf("%s.status: expected reviewed, community, or unreviewed", field) } + if strings.TrimSpace(review.Date) == "" { + return fmt.Errorf("%s.date: required", field) + } + if _, err := time.Parse("2006-01-02", review.Date); err != nil { + return fmt.Errorf("%s.date: expected YYYY-MM-DD", field) + } + if strings.TrimSpace(review.Reviewer) == "" { + return fmt.Errorf("%s.reviewer: required", field) + } + reviewURL := strings.TrimSpace(review.URL) + if reviewURL == "" { + return fmt.Errorf("%s.url: required", field) + } + parsed, err := url.Parse(reviewURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("%s.url: expected absolute URL", field) + } + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return fmt.Errorf("%s.url: expected http or https URL", field) + } + return nil } func validateRelease(field string, release Release) error { if !semverPattern.MatchString(release.Version) { return fmt.Errorf("%s.version: expected semantic version", field) } - if _, err := ParseCatalogSource(release.Repository); err != nil { - return fmt.Errorf("%s.repository: %w", field, err) + if err := validateReleaseRepository(field+".repository", release.Repository); err != nil { + return err } if !commitPattern.MatchString(release.Commit) { return fmt.Errorf("%s.commit: expected 40-character git commit SHA", field) @@ -230,6 +251,39 @@ func validateRelease(field string, release Release) error { return validateComponents(field+".components", release.Components) } +func validateReleaseRepository(field string, repository string) error { + source, err := ParseCatalogSource(repository) + if err != nil { + return fmt.Errorf("%s: %w", field, err) + } + switch source.Kind { + case CatalogSourceGitHub, CatalogSourceGit: + return nil + case CatalogSourceHTTPS: + return fmt.Errorf("%s: expected a git repository source, got HTTPS catalog/document URL", field) + case CatalogSourceLocal: + return nil + default: + return fmt.Errorf("%s: unsupported source kind %q", field, source.Kind) + } +} + +func ValidateRemoteCatalogReleaseSources(catalog Catalog) error { + for pluginIndex, plugin := range catalog.Plugins { + for releaseIndex, release := range plugin.Releases { + field := fmt.Sprintf("plugins.%d.releases.%d.repository", pluginIndex, releaseIndex) + source, err := ParseCatalogSource(release.Repository) + if err != nil { + return fmt.Errorf("%s: %w", field, err) + } + if source.Kind == CatalogSourceLocal { + return fmt.Errorf("%s: expected an absolute git repository source, got local path", field) + } + } + } + return nil +} + func validateComponents(field string, components ComponentInventory) error { for index, tool := range components.Tools { item := fmt.Sprintf("%s.tools.%d", field, index) @@ -424,6 +478,11 @@ func ParseCatalogSource(raw string) (CatalogSource, error) { kind = CatalogSourceGit } return CatalogSource{Kind: kind, Raw: raw, Canonical: source}, nil + case "http": + if !isLoopbackHost(parsed.Hostname()) { + return CatalogSource{}, fmt.Errorf("unsupported source scheme %q", parsed.Scheme) + } + return CatalogSource{Kind: CatalogSourceHTTPS, Raw: raw, Canonical: source}, nil case "git", "ssh", "git+ssh", "file": return CatalogSource{Kind: CatalogSourceGit, Raw: raw, Canonical: source}, nil default: diff --git a/internal/marketplace/catalog_test.go b/internal/marketplace/catalog_test.go index a5d21bc4..887c4e03 100644 --- a/internal/marketplace/catalog_test.go +++ b/internal/marketplace/catalog_test.go @@ -103,6 +103,59 @@ func TestParseCatalogRejectsInvalidReleaseAndSpecialistHookEvents(t *testing.T) } } +func TestParseCatalogRejectsHTTPSDocumentReleaseRepository(t *testing.T) { + body := strings.Replace(testCatalogJSON(), `"repository": "https://github.com/Gitlawb/zero-demo-plugin.git"`, `"repository": "https://example.com/catalog.json"`, 1) + _, err := ParseCatalog([]byte(body)) + if err == nil || !strings.Contains(err.Error(), "git repository source") { + t.Fatalf("expected git repository source error, got %v", err) + } +} + +func TestRemoteCatalogReleaseSourcesRejectLocalPaths(t *testing.T) { + body := strings.Replace(testCatalogJSON(), `"repository": "https://github.com/Gitlawb/zero-demo-plugin.git"`, `"repository": "./plugins/demo"`, 1) + catalog, err := ParseCatalog([]byte(body)) + if err != nil { + t.Fatalf("ParseCatalog returned error: %v", err) + } + err = ValidateRemoteCatalogReleaseSources(catalog) + if err == nil || !strings.Contains(err.Error(), "absolute git repository source") { + t.Fatalf("expected absolute git source error, got %v", err) + } +} + +func TestParseCatalogRejectsIncompleteReviewMetadata(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + { + name: "missing review date", + body: strings.Replace(testCatalogJSON(), `"date": "2026-07-10",`, ``, 1), + want: "review.date", + }, + { + name: "missing reviewer", + body: strings.Replace(testCatalogJSON(), `"reviewer": "Zero Security",`, ``, 1), + want: "review.reviewer", + }, + { + name: "missing review URL", + body: strings.Replace(testCatalogJSON(), `"url": "https://github.com/Gitlawb/zero-plugins/pull/1"`, `"url": ""`, 1), + want: "review.url", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ParseCatalog([]byte(tc.body)) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected %q error, got %v", tc.want, err) + } + }) + } +} + func TestVerifyCatalogSignature(t *testing.T) { publicKey, privateKey, err := ed25519.GenerateKey(nil) if err != nil { @@ -210,7 +263,12 @@ func testCatalogJSON() string { "name": "Second", "author": {"name": "Zero"}, "license": "MIT", - "review": {"status": "community"}, + "review": { + "status": "community", + "date": "2026-07-10", + "reviewer": "Zero Security", + "url": "https://github.com/Gitlawb/zero-plugins/pull/2" + }, "releases": [ { "version": "0.1.0", diff --git a/internal/marketplace/registry.go b/internal/marketplace/registry.go index 4ab86699..a2ffe94d 100644 --- a/internal/marketplace/registry.go +++ b/internal/marketplace/registry.go @@ -1,12 +1,19 @@ package marketplace import ( + "context" + "crypto/ed25519" "encoding/json" "errors" "fmt" + "io" + "net" + "net/http" "os" + "os/exec" "path/filepath" "strings" + "time" ) const ( @@ -14,6 +21,15 @@ const ( OfficialCatalogSource = "Gitlawb/zero-plugins" ) +// OfficialCatalogPublicKey verifies Gitlawb/zero-plugins catalog.sig. The +// matching private key is held by the official catalog signing workflow. +var OfficialCatalogPublicKey = ed25519.PublicKey{ + 0x58, 0x13, 0x61, 0x1f, 0xf4, 0xb4, 0x5f, 0xf5, + 0x26, 0x8f, 0x57, 0x26, 0x61, 0xe0, 0x6f, 0x91, + 0xa7, 0x2b, 0x2a, 0x2e, 0x0b, 0xf6, 0x90, 0x57, + 0x76, 0x25, 0x52, 0x1f, 0xa8, 0x23, 0x6e, 0x86, +} + type Scope string const ( @@ -29,7 +45,10 @@ type RegisteredCatalog struct { ID string `json:"id"` Source string `json:"source"` PublicKeyPath string `json:"publicKeyPath,omitempty"` + PublicKey ed25519.PublicKey `json:"-"` VerificationStatus VerificationStatus `json:"verificationStatus,omitempty"` + Scope Scope `json:"-"` + CachePath string `json:"-"` } func (registry *Registry) Add(catalog RegisteredCatalog) error { @@ -128,6 +147,17 @@ func RegistryPathForScope(scope Scope, cwd string, env map[string]string) (strin } } +func CachePathForScope(scope Scope, cwd string, catalogID string, env map[string]string) (string, error) { + if err := validateID("catalog id", catalogID); err != nil { + return "", err + } + registryPath, err := RegistryPathForScope(scope, cwd, env) + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(registryPath), "marketplace-cache", catalogID, "catalog.json"), nil +} + func ReadLocalCatalog(path string) ([]byte, []byte, error) { data, err := os.ReadFile(path) if err != nil { @@ -143,6 +173,137 @@ func ReadLocalCatalog(path string) ([]byte, []byte, error) { return data, signature, nil } +func SaveCachedCatalog(path string, data []byte, signature []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create marketplace cache dir: %w", err) + } + if err := writeFileAtomic(path, data, 0o644); err != nil { + return fmt.Errorf("write cached catalog: %w", err) + } + signaturePath := signaturePathForCatalog(path) + if len(signature) == 0 { + if err := os.Remove(signaturePath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove stale catalog signature: %w", err) + } + return nil + } + if err := writeFileAtomic(signaturePath, signature, 0o644); err != nil { + return fmt.Errorf("write cached catalog signature: %w", err) + } + return nil +} + +func writeFileAtomic(path string, data []byte, mode os.FileMode) error { + temp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*.tmp") + if err != nil { + return err + } + tempName := temp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tempName) + } + }() + if _, err := temp.Write(data); err != nil { + _ = temp.Close() + return err + } + if err := temp.Chmod(mode); err != nil { + _ = temp.Close() + return err + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Rename(tempName, path); err != nil { + return err + } + cleanup = false + _ = syncDirectory(filepath.Dir(path)) + return nil +} + +func syncDirectory(dir string) error { + file, err := os.Open(dir) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + return file.Sync() +} + +func FetchCatalog(ctx context.Context, rawSource string) ([]byte, []byte, error) { + source, err := ParseCatalogSource(rawSource) + if err != nil { + return nil, nil, err + } + switch source.Kind { + case CatalogSourceLocal: + return ReadLocalCatalog(source.Canonical) + case CatalogSourceHTTPS: + return fetchHTTPCatalog(ctx, source.Canonical) + case CatalogSourceGitHub, CatalogSourceGit: + return fetchGitCatalog(ctx, source.Canonical) + default: + return nil, nil, fmt.Errorf("unsupported catalog source kind %q", source.Kind) + } +} + +func fetchHTTPCatalog(ctx context.Context, source string) ([]byte, []byte, error) { + client := &http.Client{Timeout: 30 * time.Second} + data, err := fetchURL(ctx, client, source, true) + if err != nil { + return nil, nil, err + } + signature, err := fetchURL(ctx, client, signatureURLForCatalog(source), false) + if err != nil { + return nil, nil, err + } + return data, signature, nil +} + +func fetchURL(ctx context.Context, client *http.Client, source string, required bool) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch catalog: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusNotFound && !required { + return nil, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("fetch catalog: HTTP %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return nil, fmt.Errorf("read catalog response: %w", err) + } + return data, nil +} + +func fetchGitCatalog(ctx context.Context, source string) ([]byte, []byte, error) { + temp, err := os.MkdirTemp("", "zero-marketplace-catalog-") + if err != nil { + return nil, nil, fmt.Errorf("create catalog fetch temp: %w", err) + } + defer func() { _ = os.RemoveAll(temp) }() + command := exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--", source, temp) + command.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + if output, err := command.CombinedOutput(); err != nil { + return nil, nil, fmt.Errorf("git clone catalog failed: %v: %s", err, strings.TrimSpace(string(output))) + } + return ReadLocalCatalog(filepath.Join(temp, "catalog.json")) +} + func signaturePathForCatalog(catalogPath string) string { if strings.EqualFold(filepath.Base(catalogPath), "catalog.json") { return filepath.Join(filepath.Dir(catalogPath), "catalog.sig") @@ -150,6 +311,28 @@ func signaturePathForCatalog(catalogPath string) string { return catalogPath + ".sig" } +func SignaturePathForCatalog(catalogPath string) string { + return signaturePathForCatalog(catalogPath) +} + +func signatureURLForCatalog(catalogURL string) string { + if strings.HasSuffix(strings.ToLower(catalogURL), "/catalog.json") { + return catalogURL[:len(catalogURL)-len("catalog.json")] + "catalog.sig" + } + return catalogURL + ".sig" +} + +func isLoopbackHost(host string) bool { + name := strings.Trim(host, "[]") + if name == "localhost" { + return true + } + if ip := net.ParseIP(name); ip != nil && ip.IsLoopback() { + return true + } + return false +} + func envValue(env map[string]string, key string) string { if env == nil { return os.Getenv(key) diff --git a/internal/plugins/install.go b/internal/plugins/install.go index d54217a2..3bf0b52f 100644 --- a/internal/plugins/install.go +++ b/internal/plugins/install.go @@ -103,6 +103,13 @@ type InstallResult struct { PreviousHash string `json:"previousHash,omitempty"` } +type VerifyResult struct { + ID string `json:"id"` + Hash string `json:"hash"` + ExpectedHash string `json:"expectedHash"` + Enabled bool `json:"enabled"` +} + // LockEntry records the source and content hash for one installed plugin. type LockEntry struct { Source string `json:"source,omitempty"` @@ -130,7 +137,21 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) // Canonicalize a local source so clash detection keys off the resolved path, // not the spelling the user typed (relative vs absolute, symlinked vs not). source = canonicalSource(source) + options.Source = source + options.Dir = dir + var result InstallResult + err := withPluginRootLock(dir, func() error { + var installErr error + result, installErr = installLocked(ctx, options) + return installErr + }) + return result, err +} + +func installLocked(ctx context.Context, options InstallOptions) (InstallResult, error) { + source := options.Source + dir := options.Dir fetchDir, cleanup, err := fetchSource(ctx, source, options.GitRunner) if err != nil { return InstallResult{}, err @@ -275,6 +296,10 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) } stagePublished = true + enabled := true + if previous.Enabled != nil { + enabled = *previous.Enabled + } lock[id] = LockEntry{ Source: source, Hash: hash, @@ -282,7 +307,7 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) Version: strings.TrimSpace(options.ExpectedVersion), Commit: strings.TrimSpace(options.Commit), Subdir: strings.TrimSpace(options.Subdir), - Enabled: previous.Enabled, + Enabled: &enabled, Pinned: options.Pinned, } if err := writeLock(dir, lock); err != nil { @@ -320,29 +345,86 @@ func Remove(dir string, id string) error { return fmt.Errorf("invalid plugin id %q", id) } + return withPluginRootLock(dir, func() error { + lock, err := ReadLock(dir) + if err != nil { + return err + } + _, locked := lock[id] + target := filepath.Join(dir, id) + disabledTarget := filepath.Join(dir, disabledDirName, id) + present, err := dirExists(target) + if err != nil { + return fmt.Errorf("stat plugin dir: %w", err) + } + disabledPresent, err := dirExists(disabledTarget) + if err != nil { + return fmt.Errorf("stat disabled plugin dir: %w", err) + } + if !locked && !present && !disabledPresent { + return fmt.Errorf("plugin %q is not installed", id) + } + if present { + if err := os.RemoveAll(target); err != nil { + return fmt.Errorf("remove plugin dir: %w", err) + } + } + if disabledPresent { + if err := os.RemoveAll(disabledTarget); err != nil { + return fmt.Errorf("remove disabled plugin dir: %w", err) + } + } + if locked { + delete(lock, id) + if err := writeLock(dir, lock); err != nil { + return err + } + } + return nil + }) +} + +func VerifyInstalled(dir string, id string) (VerifyResult, error) { + dir = strings.TrimSpace(dir) + id = strings.TrimSpace(id) + if dir == "" || id == "" { + return VerifyResult{}, errors.New("a plugins directory and plugin id are required") + } + if !validInstallID(id) { + return VerifyResult{}, fmt.Errorf("invalid plugin id %q", id) + } lock, err := ReadLock(dir) if err != nil { - return err + return VerifyResult{}, err + } + entry, ok := lock[id] + if !ok { + return VerifyResult{}, fmt.Errorf("plugin %q is not managed by %s", id, LockFileName) + } + pluginDir := filepath.Join(dir, id) + enabled := true + if present, err := dirExists(pluginDir); err != nil { + return VerifyResult{}, fmt.Errorf("stat plugin dir: %w", err) + } else if !present { + pluginDir = filepath.Join(dir, disabledDirName, id) + enabled = false + if present, err := dirExists(pluginDir); err != nil { + return VerifyResult{}, fmt.Errorf("stat disabled plugin dir: %w", err) + } else if !present { + return VerifyResult{}, fmt.Errorf("plugin %q is not installed", id) + } } - _, locked := lock[id] - target := filepath.Join(dir, id) - _, statErr := os.Stat(target) - present := statErr == nil - if !locked && !present { - return fmt.Errorf("plugin %q is not installed", id) + hash, err := hashTree(pluginDir) + if err != nil { + return VerifyResult{}, fmt.Errorf("hash plugin: %w", err) } - if present { - if err := os.RemoveAll(target); err != nil { - return fmt.Errorf("remove plugin dir: %w", err) - } + if strings.TrimSpace(entry.Hash) == "" { + return VerifyResult{}, fmt.Errorf("plugin %q has no integrity hash", id) } - if locked { - delete(lock, id) - if err := writeLock(dir, lock); err != nil { - return err - } + if hash != entry.Hash { + return VerifyResult{}, fmt.Errorf("plugin integrity mismatch: lock has %s, filesystem has %s", entry.Hash, hash) } - return nil + return VerifyResult{ID: id, Hash: hash, ExpectedHash: entry.Hash, Enabled: enabled}, nil } func Disable(dir string, id string) error { @@ -526,6 +608,17 @@ func ReadLock(dir string) (map[string]LockEntry, error) { return entries, nil } +func dirExists(path string) (bool, error) { + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + return info.IsDir(), nil +} + func verifyLockedHash(pluginDir string, entry LockEntry) error { if strings.TrimSpace(entry.Hash) == "" { return nil @@ -607,16 +700,11 @@ func withPluginRootLock(dir string, fn func() error) error { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create plugins dir: %w", err) } - lockPath := filepath.Join(dir, ".plugins-root.lock") - file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + lock, err := acquirePluginRootLock(dir) if err != nil { - if errors.Is(err, os.ErrExist) { - return fmt.Errorf("plugins root is locked") - } - return fmt.Errorf("acquire plugins root lock: %w", err) + return err } - _ = file.Close() - defer func() { _ = os.Remove(lockPath) }() + defer lock.release() return fn() } @@ -628,12 +716,46 @@ func writeLock(dir string, entries map[string]LockEntry) error { if err != nil { return fmt.Errorf("encode %s: %w", LockFileName, err) } - if err := os.WriteFile(filepath.Join(dir, LockFileName), append(data, '\n'), 0o644); err != nil { - return fmt.Errorf("write %s: %w", LockFileName, err) + lockPath := filepath.Join(dir, LockFileName) + temp, err := os.CreateTemp(dir, "."+LockFileName+"-*.tmp") + if err != nil { + return fmt.Errorf("create %s temp: %w", LockFileName, err) + } + tempName := temp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tempName) + } + }() + if _, err := temp.Write(append(data, '\n')); err != nil { + _ = temp.Close() + return fmt.Errorf("write %s temp: %w", LockFileName, err) + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return fmt.Errorf("sync %s temp: %w", LockFileName, err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close %s temp: %w", LockFileName, err) } + if err := os.Rename(tempName, lockPath); err != nil { + return fmt.Errorf("replace %s: %w", LockFileName, err) + } + cleanup = false + _ = syncDir(dir) return nil } +func syncDir(dir string) error { + file, err := os.Open(dir) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + return file.Sync() +} + // fetchSource resolves a source into a local directory. A local path is used in // place; a git URL is shallow-cloned into a temp dir via the runner. func fetchSource(ctx context.Context, source string, runner GitRunner) (string, func(), error) { diff --git a/internal/plugins/install_test.go b/internal/plugins/install_test.go index c0f71767..7c68b124 100644 --- a/internal/plugins/install_test.go +++ b/internal/plugins/install_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" ) @@ -114,6 +115,24 @@ func TestInstallCopiesLocalPluginAndRecordsHash(t *testing.T) { if entries["zero.demo"].Hash != result.Hash || entries["zero.demo"].Source != canonicalSource(src) { t.Fatalf("lockfile entry unexpected: %#v", entries["zero.demo"]) } + if entries["zero.demo"].Enabled == nil || !*entries["zero.demo"].Enabled { + t.Fatalf("new install must record enabled:true: %#v", entries["zero.demo"]) + } +} + +func TestInstallFailsWhenRootLocked(t *testing.T) { + destDir := t.TempDir() + src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest()) + lock, err := acquirePluginRootLock(destDir) + if err != nil { + t.Fatal(err) + } + defer lock.release() + + _, err = Install(context.Background(), InstallOptions{Source: src, Dir: destDir}) + if err == nil || !strings.Contains(err.Error(), "plugins root is locked") { + t.Fatalf("expected root lock error, got %v", err) + } } func TestInstallRejectsInvalidManifest(t *testing.T) { @@ -331,6 +350,28 @@ func TestRemoveDeletesPluginAndLockEntry(t *testing.T) { } } +func TestRemoveDeletesDisabledPluginAndLockEntry(t *testing.T) { + destDir := t.TempDir() + src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest()) + if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir}); err != nil { + t.Fatalf("install: %v", err) + } + if err := Disable(destDir, "zero.demo"); err != nil { + t.Fatalf("Disable: %v", err) + } + + if err := Remove(destDir, "zero.demo"); err != nil { + t.Fatalf("Remove disabled: %v", err) + } + if _, err := os.Stat(filepath.Join(destDir, disabledDirName, "zero.demo")); !os.IsNotExist(err) { + t.Fatalf("disabled dir survived Remove, err=%v", err) + } + entries, _ := ReadLock(destDir) + if _, ok := entries["zero.demo"]; ok { + t.Fatalf("lockfile entry survived Remove") + } +} + func TestRemoveUnknownPluginErrors(t *testing.T) { if err := Remove(t.TempDir(), "missing.plugin"); err == nil { t.Fatalf("expected an error removing an unknown plugin") diff --git a/internal/plugins/integrity_test.go b/internal/plugins/integrity_test.go index e680f7b4..f3e4d470 100644 --- a/internal/plugins/integrity_test.go +++ b/internal/plugins/integrity_test.go @@ -38,6 +38,79 @@ func TestLoadBlocksTamperedManagedPlugin(t *testing.T) { } } +func TestLoadBlocksRootWhenLockfileIsCorrupt(t *testing.T) { + root := t.TempDir() + src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest()) + if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: root}); err != nil { + t.Fatalf("Install: %v", err) + } + if err := os.WriteFile(filepath.Join(root, LockFileName), []byte(`{"zero.demo":`), 0o644); err != nil { + t.Fatal(err) + } + + result, err := Load(LoadOptions{Roots: []Root{{Source: SourceUser, Path: root}}}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(result.Plugins) != 0 { + t.Fatalf("corrupt lock must fail closed for root: %#v", result.Plugins) + } + if !hasPluginDiagnostic(result.Diagnostics, DiagnosticIntegrity, "") { + t.Fatalf("missing integrity diagnostic: %#v", result.Diagnostics) + } +} + +func TestLoadBlocksActivePluginWhenLockfileMarksDisabled(t *testing.T) { + root := t.TempDir() + src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest()) + if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: root}); err != nil { + t.Fatalf("Install: %v", err) + } + if err := Disable(root, "zero.demo"); err != nil { + t.Fatalf("Disable: %v", err) + } + if err := os.Rename(filepath.Join(root, disabledDirName, "zero.demo"), filepath.Join(root, "zero.demo")); err != nil { + t.Fatalf("move disabled plugin to active dir: %v", err) + } + + result, err := Load(LoadOptions{Roots: []Root{{Source: SourceUser, Path: root}}}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(result.Plugins) != 0 { + t.Fatalf("lock disabled plugin must not activate from active dir: %#v", result.Plugins) + } + if !hasPluginDiagnostic(result.Diagnostics, DiagnosticIntegrity, "zero.demo") { + t.Fatalf("missing integrity diagnostic: %#v", result.Diagnostics) + } +} + +func TestLoadBlocksDisabledPluginWhenLockfileMarksEnabled(t *testing.T) { + root := t.TempDir() + src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest()) + if _, err := Install(context.Background(), InstallOptions{Source: src, Dir: root}); err != nil { + t.Fatalf("Install: %v", err) + } + quarantineRoot := filepath.Join(root, disabledDirName) + if err := os.MkdirAll(quarantineRoot, 0o755); err != nil { + t.Fatalf("create quarantine root: %v", err) + } + if err := os.Rename(filepath.Join(root, "zero.demo"), filepath.Join(quarantineRoot, "zero.demo")); err != nil { + t.Fatalf("move active plugin to disabled dir: %v", err) + } + + result, err := Load(LoadOptions{Roots: []Root{{Source: SourceUser, Path: root}}}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(result.Plugins) != 0 { + t.Fatalf("lock enabled plugin must not load from disabled dir: %#v", result.Plugins) + } + if !hasPluginDiagnostic(result.Diagnostics, DiagnosticIntegrity, "zero.demo") { + t.Fatalf("missing integrity diagnostic: %#v", result.Diagnostics) + } +} + func TestLoadAllowsUnmanagedPluginWithoutHash(t *testing.T) { root := t.TempDir() writePluginManifest(t, filepath.Join(root, "demo"), map[string]any{ diff --git a/internal/plugins/plugins.go b/internal/plugins/plugins.go index f9bf05eb..d6b6c2dd 100644 --- a/internal/plugins/plugins.go +++ b/internal/plugins/plugins.go @@ -249,12 +249,12 @@ func Load(options LoadOptions) (LoadResult, error) { lock, lockErr := ReadLock(rootPath) if lockErr != nil { diagnostics = append(diagnostics, Diagnostic{ - Kind: DiagnosticIO, + Kind: DiagnosticIntegrity, Source: root.Source, Root: rootPath, Message: lockErr.Error(), }) - lock = map[string]LockEntry{} + continue } for _, entry := range entries { @@ -333,10 +333,32 @@ func loadPluginDir(root Root, rootPath string, pluginDir string, disabled bool, *diagnostics = append(*diagnostics, toDiagnostic(err, root, rootPath, pluginDir, manifestPath)) return LoadedPlugin{}, false } + entry, locked := lock[plugin.ID] + if locked && entry.Enabled != nil { + lockDisabled := !*entry.Enabled + if lockDisabled != disabled { + expected := "active" + actual := "disabled" + if lockDisabled { + expected = "disabled" + actual = "active" + } + *diagnostics = append(*diagnostics, Diagnostic{ + Kind: DiagnosticIntegrity, + Source: root.Source, + Root: rootPath, + PluginPath: pluginDir, + ManifestPath: manifestPath, + PluginID: plugin.ID, + Message: fmt.Sprintf("plugin state mismatch: lockfile marks plugin %s but filesystem has %s content", expected, actual), + }) + return LoadedPlugin{}, false + } + } if disabled { plugin.Enabled = false } - if entry, ok := lock[plugin.ID]; ok && strings.TrimSpace(entry.Hash) != "" { + if locked && strings.TrimSpace(entry.Hash) != "" { hash, err := hashTree(pluginDir) if err != nil { *diagnostics = append(*diagnostics, Diagnostic{ diff --git a/internal/plugins/root_lock_unix.go b/internal/plugins/root_lock_unix.go new file mode 100644 index 00000000..bcc60d2e --- /dev/null +++ b/internal/plugins/root_lock_unix.go @@ -0,0 +1,59 @@ +//go:build !windows + +package plugins + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "syscall" +) + +const pluginRootLockFileName = ".plugins-root.lock" + +type pluginRootLock struct { + path string + file *os.File +} + +func acquirePluginRootLock(dir string) (*pluginRootLock, error) { + lockPath := filepath.Join(dir, pluginRootLockFileName) + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("acquire plugins root lock: %w", err) + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = file.Close() + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return nil, fmt.Errorf("plugins root is locked") + } + return nil, fmt.Errorf("acquire plugins root lock: %w", err) + } + if err := file.Truncate(0); err != nil { + _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + _ = file.Close() + return nil, fmt.Errorf("write plugins root lock: %w", err) + } + if _, err := file.Seek(0, 0); err != nil { + _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + _ = file.Close() + return nil, fmt.Errorf("write plugins root lock: %w", err) + } + if _, err := file.WriteString(strconv.Itoa(os.Getpid()) + "\n"); err != nil { + _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + _ = file.Close() + return nil, fmt.Errorf("write plugins root lock: %w", err) + } + return &pluginRootLock{path: lockPath, file: file}, nil +} + +func (lock *pluginRootLock) release() { + if lock == nil || lock.file == nil { + return + } + _ = syscall.Flock(int(lock.file.Fd()), syscall.LOCK_UN) + _ = lock.file.Close() + _ = os.Remove(lock.path) +} diff --git a/internal/plugins/root_lock_unix_test.go b/internal/plugins/root_lock_unix_test.go new file mode 100644 index 00000000..59da3448 --- /dev/null +++ b/internal/plugins/root_lock_unix_test.go @@ -0,0 +1,26 @@ +//go:build !windows + +package plugins + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestInstallIgnoresCrashLeftUnlockedRootLockFile(t *testing.T) { + destDir := t.TempDir() + src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest()) + if err := os.WriteFile(filepath.Join(destDir, pluginRootLockFileName), []byte("dead-pid\n"), 0o600); err != nil { + t.Fatal(err) + } + + result, err := Install(context.Background(), InstallOptions{Source: src, Dir: destDir}) + if err != nil { + t.Fatalf("Install with stale root lock file: %v", err) + } + if result.ID != "zero.demo" { + t.Fatalf("unexpected install result: %#v", result) + } +} diff --git a/internal/plugins/root_lock_windows.go b/internal/plugins/root_lock_windows.go new file mode 100644 index 00000000..733fb503 --- /dev/null +++ b/internal/plugins/root_lock_windows.go @@ -0,0 +1,71 @@ +//go:build windows + +package plugins + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + pluginRootLockFileName = ".plugins-root.lock" + pluginRootLockStaleAge = 30 * time.Minute +) + +type pluginRootLock struct { + path string + file *os.File +} + +func acquirePluginRootLock(dir string) (*pluginRootLock, error) { + lockPath := filepath.Join(dir, pluginRootLockFileName) + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + if errors.Is(err, os.ErrExist) { + if recoverStalePluginRootLock(lockPath) { + return acquirePluginRootLock(dir) + } + return nil, fmt.Errorf("plugins root is locked") + } + return nil, fmt.Errorf("acquire plugins root lock: %w", err) + } + if _, err := file.WriteString(strconv.Itoa(os.Getpid()) + "\n"); err != nil { + _ = file.Close() + _ = os.Remove(lockPath) + return nil, fmt.Errorf("write plugins root lock: %w", err) + } + return &pluginRootLock{path: lockPath, file: file}, nil +} + +func recoverStalePluginRootLock(lockPath string) bool { + info, err := os.Stat(lockPath) + if err != nil { + return errors.Is(err, os.ErrNotExist) + } + if time.Since(info.ModTime()) < pluginRootLockStaleAge { + return false + } + data, err := os.ReadFile(lockPath) + if err == nil { + pid, _ := strconv.Atoi(strings.TrimSpace(string(data))) + if pid > 0 { + if process, err := os.FindProcess(pid); err == nil { + _ = process.Release() + } + } + } + return os.Remove(lockPath) == nil +} + +func (lock *pluginRootLock) release() { + if lock == nil || lock.file == nil { + return + } + _ = lock.file.Close() + _ = os.Remove(lock.path) +} diff --git a/internal/tui/autocomplete.go b/internal/tui/autocomplete.go index 56a443d9..8bffe230 100644 --- a/internal/tui/autocomplete.go +++ b/internal/tui/autocomplete.go @@ -54,7 +54,7 @@ var fileSuggestionIndexCache = struct { // handling. A slash-command palette stays active even with zero matches so the // query remains in the palette instead of leaking back into the composer. func (m model) suggestionsActive() bool { - if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil || m.providerWizard != nil || m.mcpManager != nil { + if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil || m.providerWizard != nil || m.mcpManager != nil || m.pluginManager != nil { return false } if len(m.suggestions) > 0 { @@ -77,7 +77,7 @@ func (m *model) clearSuggestions() { // disappear once the user starts typing arguments. Modals suppress matching // entirely. The selected index is preserved when still in range, otherwise reset. func (m *model) recomputeSuggestions() { - if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil || m.providerWizard != nil || m.mcpManager != nil { + if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil || m.providerWizard != nil || m.mcpManager != nil || m.pluginManager != nil { m.clearSuggestions() return } diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go index e36d088b..3f42ca35 100644 --- a/internal/tui/clipboard.go +++ b/internal/tui/clipboard.go @@ -80,7 +80,7 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) { if m.providerWizard != nil { return m.handleProviderWizardPaste(content) } - if m.transcriptDetailed || m.pendingSpecReview != nil || m.pendingPermission != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil { + if m.transcriptDetailed || m.pendingSpecReview != nil || m.pendingPermission != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.pluginManager != nil || m.picker != nil { return m, nil } // A drag-dropped image/PDF arrives as a (backslash-escaped) file path. Attach diff --git a/internal/tui/commands.go b/internal/tui/commands.go index db1fad80..7008539c 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -14,6 +14,7 @@ const ( commandExit commandTools commandMCP + commandPlugins commandPermissions commandPS commandStop @@ -207,6 +208,13 @@ var commandDefinitions = []commandDefinition{ description: "Show MCP server status.", kind: commandMCP, }, + { + name: "/plugins", + usage: "/plugins", + group: commandGroupTools, + description: "Manage installed plugins and marketplace actions.", + kind: commandPlugins, + }, { name: "/resume", aliases: []string{"/sessions"}, diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index 2d937aad..8b6a446a 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -12,7 +12,9 @@ import ( "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/marketplace" internalmcp "github.com/Gitlawb/zero/internal/mcp" + "github.com/Gitlawb/zero/internal/plugins" "github.com/Gitlawb/zero/internal/tools" ) @@ -99,6 +101,331 @@ func TestMCPCommandMetadataAndAutocomplete(t *testing.T) { } } +func TestPluginsCommandMetadataAndAutocomplete(t *testing.T) { + command, ok := resolveCommand("/plugins") + if !ok { + t.Fatal("expected /plugins to resolve") + } + if command.kind != commandPlugins { + t.Fatalf("expected /plugins to resolve to commandPlugins, got %v", command.kind) + } + if command.group != commandGroupTools { + t.Fatalf("expected /plugins in tools group, got %q", command.group) + } + if commandSelectionRequiresInput("/plugins") { + t.Fatal("/plugins should run without required input") + } + + names := listCommandNames() + if !commandTestStringSliceContains(names, "/plugins") { + t.Fatalf("expected command names to contain /plugins, got %#v", names) + } + if !commandSuggestionNamesContain(matchCommandSuggestions("/plu"), "/plugins") { + t.Fatal("expected autocomplete for /plu to surface /plugins") + } +} + +func TestPluginsCommandOpensManagerWithoutAgentRun(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + m := newModel(context.Background(), Options{Cwd: t.TempDir(), Registry: tools.NewRegistry()}) + m.width = 120 + m.height = 36 + m.input.SetValue("/plugins") + + updated, cmd := m.Update(testKey(tea.KeyEnter)) + next := updated.(model) + + if cmd != nil { + t.Fatal("expected /plugins to be handled without starting an agent run") + } + if next.pending || next.activeRunID != 0 || next.runID != 0 { + t.Fatalf("expected /plugins not to mutate agent run state, pending=%v activeRunID=%d runID=%d", next.pending, next.activeRunID, next.runID) + } + if next.pluginManager == nil { + t.Fatal("expected /plugins to open the selectable plugin manager") + } + if len(next.transcript) != len(m.transcript) { + t.Fatalf("/plugins should open a manager overlay without appending transcript rows; before=%d after=%d", len(m.transcript), len(next.transcript)) + } + text := plainRender(t, next.View()) + for _, want := range []string{ + "Manage plugins", + "No local Zero plugins loaded.", + "Browse marketplaces", + "Install plugin", + "Verify installed", + "Esc close", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected /plugins view to contain %q, got:\n%s", want, text) + } + } +} + +func TestPluginManagerRunsBridgeActionAndShowsRestartNotice(t *testing.T) { + var called []string + m := newModel(context.Background(), Options{ + Cwd: t.TempDir(), + Registry: tools.NewRegistry(), + PluginCommand: func(_ context.Context, args []string) PluginCommandResult { + called = append([]string{}, args...) + return PluginCommandResult{ + ExitCode: 0, + Output: "Verified plugin zero.demo.", + RestartRequired: true, + Snapshot: PluginSnapshot{ + Plugins: []plugins.LoadedPlugin{{ + ID: "zero.demo", + Name: "Zero Demo", + Version: "0.1.0", + Enabled: true, + Source: plugins.SourceUser, + }}, + ProjectPluginsShown: true, + }, + } + }, + }) + m.width = 120 + m.height = 36 + m.input.SetValue("/plugins") + + updated, cmd := m.Update(testKey(tea.KeyEnter)) + next := updated.(model) + if cmd == nil { + t.Fatal("expected /plugins to load through PluginCommand") + } + next = applyCommandResult(t, next, cmd) + if !reflect.DeepEqual(called, []string{"list"}) { + t.Fatalf("initial PluginCommand args = %#v, want list", called) + } + if next.pluginManager == nil { + t.Fatal("expected plugin manager to stay open") + } + + updated, cmd = next.Update(testKeyAltText("v")) + next = updated.(model) + if cmd == nil { + t.Fatal("expected plugin verify action to run asynchronously") + } + if !reflect.DeepEqual(called, []string{"list"}) { + t.Fatalf("PluginCommand ran during Update; called=%#v", called) + } + next = applyCommandResult(t, next, cmd) + if !reflect.DeepEqual(called, []string{"verify", "zero.demo"}) { + t.Fatalf("PluginCommand args = %#v, want verify zero.demo", called) + } + text := transcriptText(next.transcript) + for _, want := range []string{ + "Plugin action complete", + "Verified plugin zero.demo.", + "Restart Zero to apply plugin changes.", + "zero.demo", + } { + if !strings.Contains(text, want) { + t.Fatalf("plugin manager action output missing %q:\n%s", want, text) + } + } +} + +func TestPluginManagerIgnoresStaleCommandResult(t *testing.T) { + m := newModel(context.Background(), Options{ + Cwd: t.TempDir(), + PluginCommand: func(_ context.Context, _ []string) PluginCommandResult { + return PluginCommandResult{ExitCode: 0} + }, + }) + m.pluginCommandSeq = 2 + m.pluginManager = &pluginManagerState{selected: 2} + + stale := pluginCommandResultMsg{ + request: pluginCommandRequest{id: 1, origin: pluginCommandOriginManager, args: []string{"verify", "zero.demo"}}, + result: PluginCommandResult{ExitCode: 0, Output: "stale"}, + } + next := m.applyPluginCommandResultMessage(stale) + if transcriptContains(next.transcript, "stale") { + t.Fatal("stale plugin command result should be ignored") + } +} + +func TestPluginManagerRendersMarketplaceCatalogAndRiskState(t *testing.T) { + enabled := true + m := newModel(context.Background(), Options{Cwd: t.TempDir(), Registry: tools.NewRegistry()}) + m.width = 180 + m.height = 40 + m.pluginManager = &pluginManagerState{selected: 2} + m.pluginSnapshotReady = true + m.pluginSnapshot = PluginSnapshot{ + Plugins: []plugins.LoadedPlugin{{ + ID: "zero.demo", + Name: "Zero Demo", + Version: "1.2.3", + Enabled: true, + Source: plugins.SourceUser, + Tools: []plugins.ToolExtension{{Name: "lookup"}}, + }}, + Installed: []PluginInstalledSnapshot{{ + ID: "zero.demo", + Source: plugins.SourceUser, + Catalog: "official", + Version: "1.2.3", + Enabled: &enabled, + Pinned: true, + }}, + Diagnostics: []plugins.Diagnostic{{ + Kind: plugins.DiagnosticIntegrity, + PluginID: "zero.broken", + Message: "hash mismatch", + }}, + Catalogs: []PluginCatalogSnapshot{{ + ID: "official", + Source: "Gitlawb/zero-plugins", + Scope: marketplace.ScopeUser, + Verification: marketplace.Verification{Status: marketplace.VerificationSigned}, + }, { + ID: "team", + Source: "https://example.com/catalog.json", + Scope: marketplace.ScopeProject, + Verification: marketplace.Verification{Status: marketplace.VerificationStale}, + LoadError: "offline", + }}, + MarketplacePlugins: []PluginMarketplaceSnapshot{{ + CatalogID: "official", + CatalogScope: marketplace.ScopeUser, + Verification: marketplace.Verification{Status: marketplace.VerificationSigned}, + Plugin: marketplace.CatalogPlugin{ + ID: "zero.demo", + Name: "Zero Demo", + Description: "Demo marketplace plugin", + License: "MIT", + Review: marketplace.ReviewRecord{ + Status: marketplace.ReviewStatusReviewed, + Date: "2026-07-10", + Reviewer: "Zero Security", + }, + }, + Release: marketplace.Release{ + Version: "1.2.4", + Repository: "https://github.com/Gitlawb/zero-demo-plugin.git", + Components: marketplace.ComponentInventory{ + Tools: []marketplace.ToolComponent{{Name: "lookup", Permission: plugins.PermissionPrompt}}, + }, + }, + Risk: marketplace.RiskReport{Tools: []marketplace.ToolComponent{{Name: "lookup", Permission: plugins.PermissionPrompt}}}, + Pinned: true, + }}, + ProjectPluginsShown: true, + } + + text := plainRender(t, m.pluginManagerOverlay(180)) + for _, want := range []string{ + "Installed", + "zero.demo", + "pinned", + "Installed issues", + "zero.broken", + "broken", + "Discover", + "1.2.4", + "reviewed", + "MIT", + "Catalogs", + "official", + "signed", + "team", + "stale", + "Alt+i install user", + "Alt+p install project", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected plugin manager view to contain %q, got:\n%s", want, text) + } + } +} + +func TestPluginManagerMarketplaceInstallActionsUseBridgeScopes(t *testing.T) { + for _, tc := range []struct { + name string + key tea.KeyMsg + want []string + }{ + { + name: "user", + key: testKeyAltText("i"), + want: []string{"install", "zero.demo@team", "--scope", "user", "--yes", "--allow-unverified"}, + }, + { + name: "project", + key: testKeyAltText("p"), + want: []string{"install", "zero.demo@team", "--scope", "project", "--yes", "--allow-unverified"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var called []string + m := newModel(context.Background(), Options{ + Cwd: t.TempDir(), + Registry: tools.NewRegistry(), + PluginCommand: func(_ context.Context, args []string) PluginCommandResult { + called = append([]string{}, args...) + return PluginCommandResult{ExitCode: 0, Snapshot: pluginManagerMarketplaceActionSnapshot()} + }, + }) + m.pluginSnapshot = pluginManagerMarketplaceActionSnapshot() + m.pluginSnapshotReady = true + m.pluginManager = &pluginManagerState{} + + updated, cmd := m.Update(tc.key) + next := updated.(model) + if cmd != nil { + t.Fatal("expected install action to wait for confirmation") + } + if next.pluginManager == nil || next.pluginManager.confirm == nil { + t.Fatal("expected install action confirmation") + } + if !strings.Contains(next.pluginManager.confirm.message, "Warning: installing from unsigned/stale catalog") { + t.Fatalf("expected unsigned/stale warning, got %q", next.pluginManager.confirm.message) + } + + updated, cmd = next.Update(testKey(tea.KeyEnter)) + next = updated.(model) + if cmd == nil { + t.Fatal("expected confirmed install action to run asynchronously") + } + next = applyCommandResult(t, next, cmd) + if !reflect.DeepEqual(called, tc.want) { + t.Fatalf("PluginCommand args = %#v, want %#v", called, tc.want) + } + if next.pluginManager == nil { + t.Fatal("expected plugin manager to stay open after install action") + } + }) + } +} + +func pluginManagerMarketplaceActionSnapshot() PluginSnapshot { + return PluginSnapshot{ + MarketplacePlugins: []PluginMarketplaceSnapshot{{ + CatalogID: "team", + CatalogScope: marketplace.ScopeProject, + Verification: marketplace.Verification{Status: marketplace.VerificationStale}, + Plugin: marketplace.CatalogPlugin{ + ID: "zero.demo", + Name: "Zero Demo", + License: "MIT", + Review: marketplace.ReviewRecord{Status: marketplace.ReviewStatusReviewed}, + }, + Release: marketplace.Release{Version: "1.2.3"}, + }}, + Catalogs: []PluginCatalogSnapshot{{ + ID: "team", + Scope: marketplace.ScopeProject, + Verification: marketplace.Verification{Status: marketplace.VerificationStale}, + }}, + ProjectPluginsShown: true, + } +} + func TestMCPCommandRendersConfiguredStateWithoutAgentRun(t *testing.T) { registry := tools.NewRegistry() registry.Register(commandTestMCPTool{ diff --git a/internal/tui/composer.go b/internal/tui/composer.go index 510d96cc..14ed0a93 100644 --- a/internal/tui/composer.go +++ b/internal/tui/composer.go @@ -281,7 +281,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) { func (m model) composerMouseSelectionBlocked() bool { return m.transcriptDetailed || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || - m.mcpManager != nil || m.picker != nil || m.suggestionsActive() + m.mcpManager != nil || m.pluginManager != nil || m.picker != nil || m.suggestionsActive() } func (m model) composerPositionAtVisualCell(x int, y int, width int) (int, bool) { diff --git a/internal/tui/files_panel.go b/internal/tui/files_panel.go index 4844a30b..052ec370 100644 --- a/internal/tui/files_panel.go +++ b/internal/tui/files_panel.go @@ -297,7 +297,7 @@ func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) { if !m.sidebarActive() { return "", false } - if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() { + if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.pluginManager != nil || m.picker != nil || m.suggestionsActive() { return "", false } sidebarW := sidebarWidth(m.width) diff --git a/internal/tui/model.go b/internal/tui/model.go index eceebfc2..822653bc 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -91,11 +91,16 @@ type model struct { mcpPermissionStore *internalmcp.PermissionStore mcpTokenStore *internalmcp.TokenStore mcpCommand func(context.Context, []string) MCPCommandResult + pluginCommand func(context.Context, []string) PluginCommandResult sandboxSetupCommand func(context.Context) SandboxSetupCommandResult mcpViewStateCache MCPViewState mcpViewStateReady bool mcpCommandSeq int mcpCommandCancel context.CancelFunc + pluginSnapshot PluginSnapshot + pluginSnapshotReady bool + pluginCommandSeq int + pluginCommandCancel context.CancelFunc sandboxSetupSeq int sandboxSetupInFlight bool doctorCommandSeq int @@ -412,6 +417,7 @@ type model struct { providerWizard *providerWizardState mcpManager *mcpManagerState mcpAddWizard *mcpAddWizardState + pluginManager *pluginManagerState favoriteModels map[string]bool // recentModels is the automatic history of provider+model switches, newest // first, capped to config.MaxRecentModels. Unlike favoriteModels (manual @@ -605,6 +611,27 @@ type mcpCommandResultMsg struct { result MCPCommandResult } +type pluginCommandOrigin int + +const ( + pluginCommandOriginManager pluginCommandOrigin = iota + pluginCommandOriginTranscript +) + +type pluginCommandRequest struct { + id int + origin pluginCommandOrigin + args []string + raw string + managerSelected int + managerQuery string +} + +type pluginCommandResultMsg struct { + request pluginCommandRequest + result PluginCommandResult +} + type doctorCommandResultMsg struct { id int text string @@ -794,6 +821,7 @@ func newModel(ctx context.Context, options Options) model { mcpPermissionStore: options.MCPPermissionStore, mcpTokenStore: options.MCPTokenStore, mcpCommand: options.MCPCommand, + pluginCommand: options.PluginCommand, sandboxSetupCommand: options.SandboxSetupCommand, agentOptions: options.AgentOptions, sessionCompactor: options.SessionCompactor, @@ -1340,6 +1368,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.mcpManager = nil return m, nil } + if m.pluginManager != nil { + m.pluginManager = nil + return m, nil + } // An open picker cancels first; then an active suggestion overlay is // dismissed. Neither cancels the run or clears the input. if m.picker != nil { @@ -1420,6 +1452,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.burstCount = 0 return m.handleMCPManagerKey(msg) } + if m.pluginManager != nil { + m.burstCount = 0 + return m.handlePluginManagerKey(msg) + } if m.picker != nil { m.burstCount = 0 return m.choosePicker() @@ -1578,6 +1614,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.burstCount = 0 return m.handleMCPManagerKey(msg) } + if m.pluginManager != nil { + m.burstCount = 0 + return m.handlePluginManagerKey(msg) + } if m.picker == nil && m.suggestionsActive() { m.moveSuggestion(1) return m, nil @@ -1612,6 +1652,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.burstCount = 0 return m.handleMCPManagerKey(msg) } + if m.pluginManager != nil { + m.burstCount = 0 + return m.handlePluginManagerKey(msg) + } if m.picker != nil { if m.modelPickerIsLoading() { return m, nil @@ -1651,6 +1695,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.burstCount = 0 return m.handleMCPManagerKey(msg) } + if m.pluginManager != nil { + m.burstCount = 0 + return m.handlePluginManagerKey(msg) + } if m.picker != nil { if m.modelPickerIsLoading() { return m, nil @@ -1689,6 +1737,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.burstCount = 0 return m.handleMCPManagerKey(msg) } + if m.pluginManager != nil { + m.burstCount = 0 + return m.handlePluginManagerKey(msg) + } if m.picker != nil { if m.modelPickerIsLoading() { return m, nil @@ -1735,6 +1787,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.burstCount = 0 return m.handleMCPManagerKey(msg) } + if m.pluginManager != nil { + m.burstCount = 0 + return m.handlePluginManagerKey(msg) + } if m.picker != nil { if m.modelPickerIsLoading() { return m, nil @@ -1770,7 +1826,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.pendingAskUser != nil { return m.moveAskUserCursor(-1), nil } - if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil { + if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.pluginManager != nil || m.picker != nil || m.pendingSpecReview != nil { break } if m.composerValue() != "" { @@ -1791,7 +1847,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.pendingAskUser != nil { return m.moveAskUserCursor(1), nil } - if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil { + if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.pluginManager != nil || m.picker != nil || m.pendingSpecReview != nil { break } if m.composerValue() != "" { @@ -1842,6 +1898,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.burstCount = 0 return m.handleMCPManagerKey(msg) } + if m.pluginManager != nil { + m.burstCount = 0 + return m.handlePluginManagerKey(msg) + } // An open picker is modal over the input: swallow remaining keys so they // don't type into the field. ↑/↓/Enter/Esc were already handled above. if m.picker != nil { @@ -2508,6 +2568,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case mcpCommandResultMsg: return m.applyMCPCommandResultMessage(msg), nil + case pluginCommandResultMsg: + return m.applyPluginCommandResultMessage(msg), nil } var cmd tea.Cmd @@ -2618,6 +2680,7 @@ func (m model) transcriptView() string { providerOverlay := m.providerWizardOverlay(width) mcpAddOverlay := m.mcpAddWizardOverlay(width) mcpOverlay := m.mcpManagerOverlay(width) + pluginOverlay := m.pluginManagerOverlay(width) pickerOverlay := m.pickerOverlay(width) sttKeyOverlay := m.sttKeyPromptOverlay(width) viewportOverlay := "" @@ -2632,6 +2695,8 @@ func (m model) transcriptView() string { viewportOverlay = mcpAddOverlay case mcpOverlay != "": viewportOverlay = mcpOverlay + case pluginOverlay != "": + viewportOverlay = pluginOverlay case pickerOverlay != "": viewportOverlay = pickerOverlay case suggestionOverlay != "": @@ -4144,6 +4209,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) { return m.openMCPManager(), nil } return m.startMCPTranscriptCommand(command.text) + case commandPlugins: + if strings.TrimSpace(command.text) == "" { + return m.openPluginManager() + } + return m.startPluginTranscriptCommand(command.text) case commandPermissions: m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.permissionsText()}) return m, nil diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 47e5b445..7436f027 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -116,6 +116,8 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { m.lastMouseSelection = target return m, nil } + case m.pluginManager != nil: + return m, nil case m.picker != nil: if target, ok := m.selectPickerAtMouse(msg); ok { if m.repeatMouseSelection(target) { @@ -165,6 +167,10 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { m.moveMCPManager(-1) return m, nil } + if m.pluginManager != nil { + m.movePluginManager(-1) + return m, nil + } if m.picker != nil { if m.modelPickerIsLoading() { return m, nil @@ -197,6 +203,10 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { m.moveMCPManager(1) return m, nil } + if m.pluginManager != nil { + m.movePluginManager(1) + return m, nil + } if m.picker != nil { if m.modelPickerIsLoading() { return m, nil @@ -231,7 +241,7 @@ func (m model) sidebarLineAtMouse(msg tea.MouseMsg) (sidebarAgentHit, bool) { if !m.sidebarActive() { return sidebarAgentHit{}, false } - if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() { + if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.pluginManager != nil || m.picker != nil || m.suggestionsActive() { return sidebarAgentHit{}, false } sidebarW := sidebarWidth(m.width) @@ -263,7 +273,7 @@ func (m model) wantsMouseCapture() bool { if m.mouseReleased { return false // user released the mouse for native text selection/copy } - return m.altScreen && (m.setupWantsMouseCapture() || m.chatWantsMouseCapture() || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive()) + return m.altScreen && (m.setupWantsMouseCapture() || m.chatWantsMouseCapture() || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.pluginManager != nil || m.picker != nil || m.suggestionsActive()) } func (m model) setupWantsMouseCapture() bool { diff --git a/internal/tui/options.go b/internal/tui/options.go index 40911070..113b4073 100644 --- a/internal/tui/options.go +++ b/internal/tui/options.go @@ -7,8 +7,10 @@ import ( "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/marketplace" "github.com/Gitlawb/zero/internal/mcp" "github.com/Gitlawb/zero/internal/modelregistry" + "github.com/Gitlawb/zero/internal/plugins" "github.com/Gitlawb/zero/internal/providerhealth" "github.com/Gitlawb/zero/internal/providermodeldiscovery" "github.com/Gitlawb/zero/internal/sandbox" @@ -48,6 +50,7 @@ type Options struct { MCPPermissionStore *mcp.PermissionStore MCPTokenStore *mcp.TokenStore MCPCommand func(context.Context, []string) MCPCommandResult + PluginCommand func(context.Context, []string) PluginCommandResult SandboxSetupCommand func(context.Context) SandboxSetupCommandResult UsageTracker *usage.Tracker SessionCompactor SessionCompactor @@ -127,6 +130,57 @@ type MCPCommandResult struct { ExitCode int } +type PluginSnapshot struct { + Plugins []plugins.LoadedPlugin + Diagnostics []plugins.Diagnostic + Installed []PluginInstalledSnapshot + Catalogs []PluginCatalogSnapshot + MarketplacePlugins []PluginMarketplaceSnapshot + TrustNotice string + LoadError string + ProjectPluginsShown bool +} + +type PluginInstalledSnapshot struct { + ID string + Source plugins.Source + Catalog string + Version string + Commit string + Enabled *bool + Pinned bool + Error string +} + +type PluginCatalogSnapshot struct { + ID string + Owner string + Description string + Source string + Scope marketplace.Scope + Verification marketplace.Verification + LoadError string +} + +type PluginMarketplaceSnapshot struct { + CatalogID string + CatalogScope marketplace.Scope + Verification marketplace.Verification + Plugin marketplace.CatalogPlugin + Release marketplace.Release + Risk marketplace.RiskReport + Installed bool + Pinned bool +} + +type PluginCommandResult struct { + Snapshot PluginSnapshot + Output string + Error string + ExitCode int + RestartRequired bool +} + type SandboxSetupCommandResult struct { Output string Error string diff --git a/internal/tui/plan_step_detail.go b/internal/tui/plan_step_detail.go index 9b8c1903..b73c437d 100644 --- a/internal/tui/plan_step_detail.go +++ b/internal/tui/plan_step_detail.go @@ -126,7 +126,7 @@ func (m model) planStepAtMouse(msg tea.MouseMsg) (int, bool) { if !m.sidebarActive() { return 0, false } - if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() { + if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.pluginManager != nil || m.picker != nil || m.suggestionsActive() { return 0, false } sidebarW := sidebarWidth(m.width) diff --git a/internal/tui/plugin_manager.go b/internal/tui/plugin_manager.go new file mode 100644 index 00000000..11cbb4d8 --- /dev/null +++ b/internal/tui/plugin_manager.go @@ -0,0 +1,906 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "unicode" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/Gitlawb/zero/internal/marketplace" + "github.com/Gitlawb/zero/internal/plugins" +) + +const ( + pluginManagerOverlayMaxWidth = 142 + pluginManagerOverlayMinWidth = 58 + pluginManagerMaxVisible = 12 +) + +type pluginManagerState struct { + selected int + query string + confirm *pluginCommandConfirmation +} + +type pluginCommandConfirmation struct { + args []string + message string +} + +type pluginManagerItemKind int + +const ( + pluginManagerItemInstalled pluginManagerItemKind = iota + pluginManagerItemDiagnostic + pluginManagerItemMarketplacePlugin + pluginManagerItemCatalog + pluginManagerItemBrowse + pluginManagerItemInstall + pluginManagerItemAddMarketplace + pluginManagerItemRemoveMarketplace + pluginManagerItemVerify + pluginManagerItemListMarketplaces + pluginManagerItemUpdateMarketplaces + pluginManagerItemSignCatalog +) + +type pluginManagerItem struct { + Kind pluginManagerItemKind + Name string + Label string + Meta string + Detail string + Input string + CatalogID string + Scope string + Version string + Pinned bool + Verified marketplace.VerificationStatus +} + +type pluginManagerSnapshot = PluginSnapshot + +func (m model) openPluginManager() (model, tea.Cmd) { + m.pluginManager = &pluginManagerState{} + m.clearSuggestions() + if m.pluginCommand == nil { + return m, nil + } + return m.startPluginCommand(pluginCommandRequest{origin: pluginCommandOriginManager, args: []string{"list"}}) +} + +func (m model) handlePluginManagerKey(msg tea.KeyMsg) (model, tea.Cmd) { + if m.pluginManager == nil { + return m, nil + } + if m.pluginManager.confirm != nil { + switch { + case keyIs(msg, tea.KeyEsc): + m.pluginManager.confirm = nil + return m, nil + case keyIs(msg, tea.KeyEnter): + confirm := m.pluginManager.confirm + m.pluginManager.confirm = nil + return m.runPluginManagerCommand(confirm.args, false) + default: + return m, nil + } + } + switch { + case keyIs(msg, tea.KeyEsc): + m.pluginManager = nil + case keyIs(msg, tea.KeyUp): + m.movePluginManager(-1) + case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab): + m.movePluginManager(1) + case keyIs(msg, tea.KeyEnter): + return m.choosePluginManagerItem() + case keyBackspace(msg) || keyIs(msg, tea.KeyDelete): + m.deletePluginManagerQueryRune() + case keyCtrl(msg, 'u'): + m.pluginManager.query = "" + m.pluginManager.selected = 0 + case len(keyRunes(msg)) > 0 && !keyAlt(msg): + m.appendPluginManagerQuery(keyRunes(msg)...) + case keyText(msg) != "": + switch strings.ToLower(keyText(msg)) { + case "v": + if item, ok := m.currentPluginManagerItem(); ok && item.Kind == pluginManagerItemInstalled { + return m.runPluginManagerCommand([]string{"verify", item.Name}, false) + } + case "i": + if item, ok := m.currentPluginManagerItem(); ok && item.Kind == pluginManagerItemMarketplacePlugin { + return m.runPluginManagerCommand(pluginManagerMarketplaceInstallArgs(item, marketplace.ScopeUser), true) + } + case "p": + if item, ok := m.currentPluginManagerItem(); ok { + switch item.Kind { + case pluginManagerItemInstalled: + args := []string{"pin", item.Name, "--scope", item.Scope} + if item.Pinned { + args = []string{"unpin", item.Name, "--scope", item.Scope} + } else if item.Version != "" { + args = append(args, "--version", item.Version) + } + return m.runPluginManagerCommand(args, true) + case pluginManagerItemMarketplacePlugin: + return m.runPluginManagerCommand(pluginManagerMarketplaceInstallArgs(item, marketplace.ScopeProject), true) + } + } + case "e": + if item, ok := m.currentPluginManagerItem(); ok && item.Kind == pluginManagerItemInstalled { + return m.runPluginManagerCommand([]string{"enable", item.Name, "--scope", item.Scope}, true) + } + case "d": + if item, ok := m.currentPluginManagerItem(); ok && item.Kind == pluginManagerItemInstalled { + return m.runPluginManagerCommand([]string{"disable", item.Name, "--scope", item.Scope}, true) + } + case "u": + if item, ok := m.currentPluginManagerItem(); ok { + switch item.Kind { + case pluginManagerItemInstalled: + args := []string{"update", item.Name, "--scope", item.Scope, "--yes"} + if item.Verified != marketplace.VerificationSigned { + args = append(args, "--allow-unverified") + } + return m.runPluginManagerCommand(args, true) + case pluginManagerItemCatalog: + return m.runPluginManagerCommand([]string{"marketplace", "update", item.Name, "--scope", item.Scope}, true) + } + } + case "r": + if item, ok := m.currentPluginManagerItem(); ok { + switch item.Kind { + case pluginManagerItemInstalled: + return m.runPluginManagerCommand([]string{"remove", item.Name, "--scope", item.Scope}, true) + case pluginManagerItemCatalog: + if item.Name != marketplace.OfficialCatalogID { + return m.runPluginManagerCommand([]string{"marketplace", "remove", item.Name, "--scope", item.Scope}, true) + } + } + } + } + } + return m, nil +} + +func (m model) movePluginManager(delta int) model { + if m.pluginManager == nil { + return m + } + count := len(m.pluginManagerItems()) + if count == 0 { + m.pluginManager.selected = 0 + return m + } + m.pluginManager.selected = ((m.pluginManager.selected+delta)%count + count) % count + return m +} + +func (m model) appendPluginManagerQuery(runes ...rune) { + if m.pluginManager == nil { + return + } + for _, r := range runes { + if unicode.IsControl(r) { + continue + } + m.pluginManager.query += string(r) + } + m.pluginManager.selected = 0 +} + +func (m model) deletePluginManagerQueryRune() { + if m.pluginManager == nil || m.pluginManager.query == "" { + return + } + runes := []rune(m.pluginManager.query) + m.pluginManager.query = string(runes[:len(runes)-1]) + m.pluginManager.selected = 0 +} + +func (m model) choosePluginManagerItem() (model, tea.Cmd) { + item, ok := m.currentPluginManagerItem() + if !ok || strings.TrimSpace(item.Input) == "" { + return m, nil + } + switch item.Kind { + case pluginManagerItemInstalled: + return m.runPluginManagerCommand([]string{"info", item.Name}, false) + case pluginManagerItemMarketplacePlugin: + args := []string{"info", item.Name + "@" + item.CatalogID} + if item.Verified != marketplace.VerificationSigned { + args = append(args, "--allow-unverified") + } + return m.runPluginManagerCommand(args, false) + case pluginManagerItemCatalog: + return m.runPluginManagerCommand([]string{"browse", "--catalog", item.Name}, false) + } + return m.prefillPluginManagerCommand(item.Input), nil +} + +func (m model) prefillPluginManagerCommand(input string) model { + m.pluginManager = nil + m.input.SetValue(input) + m.input.SetCursor(len([]rune(input))) + m.resetComposerFromInput() + m.clearSuggestions() + return m +} + +func (m model) runPluginManagerCommand(args []string, confirm bool) (model, tea.Cmd) { + if confirm { + if m.pluginManager != nil { + m.pluginManager.confirm = &pluginCommandConfirmation{ + args: append([]string{}, args...), + message: pluginManagerConfirmationMessage(args), + } + } + return m, nil + } + request := pluginCommandRequest{origin: pluginCommandOriginManager, args: append([]string{}, args...)} + if m.pluginManager != nil { + request.managerSelected = m.pluginManager.selected + request.managerQuery = m.pluginManager.query + } + return m.startPluginCommand(request) +} + +func pluginManagerConfirmationMessage(args []string) string { + command := "zero plugins " + strings.Join(args, " ") + if len(args) > 0 && args[0] == "install" && pluginManagerArgContains(args, "--allow-unverified") { + return "Warning: installing from unsigned/stale catalog with --allow-unverified. Confirm: " + command + } + return "Confirm plugin action: " + command +} + +func pluginManagerArgContains(args []string, target string) bool { + for _, arg := range args { + if arg == target { + return true + } + } + return false +} + +func (m model) currentPluginManagerItem() (pluginManagerItem, bool) { + if m.pluginManager == nil { + return pluginManagerItem{}, false + } + items := m.pluginManagerItems() + if len(items) == 0 { + return pluginManagerItem{}, false + } + m.pluginManager.selected = clampInt(m.pluginManager.selected, 0, len(items)-1) + return items[m.pluginManager.selected], true +} + +func (m model) pluginManagerItems() []pluginManagerItem { + query := "" + if m.pluginManager != nil { + query = strings.ToLower(strings.TrimSpace(m.pluginManager.query)) + } + snapshot := m.pluginManagerSnapshot() + items := make([]pluginManagerItem, 0, len(snapshot.Plugins)+len(snapshot.Diagnostics)+len(snapshot.MarketplacePlugins)+len(snapshot.Catalogs)+8) + installed := pluginManagerInstalledIndex(snapshot) + loaded := map[string]bool{} + for _, plugin := range snapshot.Plugins { + loaded[plugin.ID+"|"+string(plugin.Source)] = true + state := pluginManagerInstalledState(snapshot, plugin, installed) + item := pluginManagerItem{ + Kind: pluginManagerItemInstalled, + Name: plugin.ID, + Label: plugin.ID, + Meta: pluginManagerInstalledMeta(plugin, state), + Detail: pluginManagerPluginDetail(plugin, state), + Input: "/plugins info " + plugin.ID, + Scope: string(plugin.Source), + Version: state.Version, + Pinned: state.Pinned, + Verified: state.Verification, + } + if pluginManagerItemMatches(item, query) { + items = append(items, item) + } + } + for _, lock := range snapshot.Installed { + key := lock.ID + "|" + string(lock.Source) + if lock.Error == "" && (lock.ID == "" || loaded[key]) { + continue + } + label := lock.ID + if label == "" { + label = string(lock.Source) + } + meta := compactJoin([]string{"broken", string(lock.Source), lock.Catalog, lock.Version}, " · ") + detail := lock.Error + if detail == "" { + detail = "plugins.lock entry exists but the plugin did not load" + } + item := pluginManagerItem{ + Kind: pluginManagerItemDiagnostic, + Name: label, + Label: label, + Meta: meta, + Detail: detail, + Scope: string(lock.Source), + Version: lock.Version, + Pinned: lock.Pinned, + } + if pluginManagerItemMatches(item, query) { + items = append(items, item) + } + } + for _, diagnostic := range snapshot.Diagnostics { + label := strings.TrimSpace(diagnostic.PluginID) + if label == "" { + label = strings.TrimSpace(diagnostic.PluginPath) + } + if label == "" { + label = string(diagnostic.Kind) + } + item := pluginManagerItem{ + Kind: pluginManagerItemDiagnostic, + Name: label, + Label: label, + Meta: fmt.Sprintf("broken · %s", diagnostic.Kind), + Detail: diagnostic.Message, + } + if pluginManagerItemMatches(item, query) { + items = append(items, item) + } + } + for _, entry := range snapshot.MarketplacePlugins { + item := pluginManagerMarketplaceItem(entry) + if pluginManagerItemMatches(item, query) { + items = append(items, item) + } + } + for _, catalog := range snapshot.Catalogs { + item := pluginManagerCatalogItem(catalog) + if pluginManagerItemMatches(item, query) { + items = append(items, item) + } + } + for _, item := range []pluginManagerItem{ + {Kind: pluginManagerItemBrowse, Name: "browse", Label: "Browse marketplaces", Meta: "zero plugins browse", Detail: "search registered catalogs before install", Input: "/plugins browse"}, + {Kind: pluginManagerItemInstall, Name: "install", Label: "Install plugin", Meta: "zero plugins install --scope user --yes [--allow-unverified]", Detail: "install reviewed release; unsigned or stale catalogs require explicit allow", Input: "/plugins install "}, + {Kind: pluginManagerItemAddMarketplace, Name: "add", Label: "Add marketplace", Meta: "zero plugins marketplace add ", Detail: "register signed catalog source; unsigned catalogs require explicit allow", Input: "/plugins marketplace add "}, + {Kind: pluginManagerItemRemoveMarketplace, Name: "remove-marketplace", Label: "Remove marketplace", Meta: "zero plugins marketplace remove ", Detail: "remove user or project catalog registration", Input: "/plugins marketplace remove "}, + {Kind: pluginManagerItemVerify, Name: "verify", Label: "Verify installed", Meta: "zero plugins verify ", Detail: "check installed content against plugins.lock", Input: "/plugins verify "}, + {Kind: pluginManagerItemListMarketplaces, Name: "marketplaces", Label: "List marketplaces", Meta: "zero plugins marketplace list", Detail: "show registered catalogs and verification status", Input: "/plugins marketplace list"}, + {Kind: pluginManagerItemUpdateMarketplaces, Name: "update", Label: "Update catalogs", Meta: "zero plugins marketplace update ", Detail: "refresh remote catalog cache and signature status", Input: "/plugins marketplace update "}, + {Kind: pluginManagerItemSignCatalog, Name: "sign", Label: "Sign catalog", Meta: "zero plugins marketplace sign ", Detail: "write detached Ed25519 catalog signature", Input: "/plugins marketplace sign "}, + } { + if pluginManagerItemMatches(item, query) { + items = append(items, item) + } + } + return items +} + +type pluginManagerInstalledSummary struct { + Catalog string + Version string + Commit string + Enabled bool + Pinned bool + Verification marketplace.VerificationStatus +} + +func pluginManagerInstalledIndex(snapshot pluginManagerSnapshot) map[string]PluginInstalledSnapshot { + index := map[string]PluginInstalledSnapshot{} + for _, item := range snapshot.Installed { + if strings.TrimSpace(item.ID) == "" { + continue + } + index[item.ID+"|"+string(item.Source)] = item + } + return index +} + +func pluginManagerInstalledState(snapshot pluginManagerSnapshot, plugin plugins.LoadedPlugin, installed map[string]PluginInstalledSnapshot) pluginManagerInstalledSummary { + state := pluginManagerInstalledSummary{ + Version: plugin.Version, + Enabled: plugin.Enabled, + Verification: marketplace.VerificationSigned, + } + lock, ok := installed[plugin.ID+"|"+string(plugin.Source)] + if ok { + state.Catalog = lock.Catalog + if lock.Version != "" { + state.Version = lock.Version + } + state.Commit = lock.Commit + state.Pinned = lock.Pinned + if lock.Enabled != nil { + state.Enabled = *lock.Enabled + } + } + for _, catalog := range snapshot.Catalogs { + if catalog.ID == state.Catalog { + state.Verification = catalog.Verification.Status + break + } + } + return state +} + +func pluginManagerInstalledMeta(plugin plugins.LoadedPlugin, state pluginManagerInstalledSummary) string { + parts := []string{"enabled"} + if !state.Enabled { + parts[0] = "disabled" + } + parts = append(parts, string(plugin.Source)) + if state.Version != "" { + parts = append(parts, state.Version) + } + if state.Catalog != "" { + parts = append(parts, state.Catalog) + } + if state.Pinned { + parts = append(parts, "pinned") + } + if state.Verification == marketplace.VerificationStale { + parts = append(parts, "stale catalog") + } + return strings.Join(parts, " · ") +} + +func pluginManagerMarketplaceItem(entry PluginMarketplaceSnapshot) pluginManagerItem { + version := entry.Release.Version + review := string(entry.Plugin.Review.Status) + if review == "" { + review = "unreviewed" + } + verification := entry.Verification.Status + if verification == "" { + verification = marketplace.VerificationUnsigned + } + meta := []string{entry.Plugin.ID + "@" + entry.CatalogID, version, review, entry.Plugin.License, string(verification)} + if entry.Installed { + meta = append(meta, "installed") + } + if entry.Pinned { + meta = append(meta, "pinned") + } + name := strings.TrimSpace(entry.Plugin.Name) + if name == "" { + name = entry.Plugin.ID + } + input := "/plugins info " + entry.Plugin.ID + "@" + entry.CatalogID + if verification != marketplace.VerificationSigned { + input += " --allow-unverified" + } + return pluginManagerItem{ + Kind: pluginManagerItemMarketplacePlugin, + Name: entry.Plugin.ID, + Label: name, + Meta: compactJoin(meta, " · "), + Detail: pluginManagerMarketplaceDetail(entry), + Input: input, + CatalogID: entry.CatalogID, + Scope: string(entry.CatalogScope), + Version: version, + Pinned: entry.Pinned, + Verified: verification, + } +} + +func pluginManagerCatalogItem(catalog PluginCatalogSnapshot) pluginManagerItem { + verification := catalog.Verification.Status + if verification == "" { + verification = marketplace.VerificationUnsigned + } + meta := []string{string(catalog.Scope), string(verification), catalog.Source} + if catalog.LoadError != "" { + meta = append([]string{"load error"}, meta...) + } + detail := catalog.Source + if catalog.Description != "" { + detail = catalog.Description + " · " + detail + } + if catalog.LoadError != "" { + detail = catalog.LoadError + } + return pluginManagerItem{ + Kind: pluginManagerItemCatalog, + Name: catalog.ID, + Label: catalog.ID, + Meta: compactJoin(meta, " · "), + Detail: detail, + Input: "/plugins browse --catalog " + catalog.ID, + Scope: string(catalog.Scope), + Verified: verification, + } +} + +func pluginManagerMarketplaceInstallArgs(item pluginManagerItem, scope marketplace.Scope) []string { + args := []string{"install", item.Name + "@" + item.CatalogID, "--scope", string(scope), "--yes"} + if item.Verified != marketplace.VerificationSigned { + args = append(args, "--allow-unverified") + } + return args +} + +func pluginManagerMarketplaceDetail(entry PluginMarketplaceSnapshot) string { + risk := []string{ + pluralCount(len(entry.Risk.Tools), "tool"), + pluralCount(len(entry.Risk.Hooks), "hook"), + pluralCount(len(entry.Risk.Skills), "skill"), + pluralCount(len(entry.Risk.Prompts), "prompt"), + } + parts := []string{} + if entry.Plugin.Description != "" { + parts = append(parts, entry.Plugin.Description) + } + parts = append(parts, "risk "+strings.Join(risk, ", ")) + if entry.Plugin.Review.Reviewer != "" || entry.Plugin.Review.Date != "" { + parts = append(parts, "review "+compactJoin([]string{string(entry.Plugin.Review.Status), entry.Plugin.Review.Date, entry.Plugin.Review.Reviewer}, " ")) + } + if entry.Release.Repository != "" { + parts = append(parts, entry.Release.Repository) + } + return strings.Join(parts, " · ") +} + +func compactJoin(parts []string, sep string) string { + kept := make([]string, 0, len(parts)) + for _, part := range parts { + if strings.TrimSpace(part) != "" { + kept = append(kept, part) + } + } + return strings.Join(kept, sep) +} + +func pluginManagerItemMatches(item pluginManagerItem, query string) bool { + if query == "" { + return true + } + fields := []string{item.Name, item.Label, item.Meta, item.Detail, item.Input, item.CatalogID, item.Scope, item.Version} + for _, field := range fields { + if strings.Contains(strings.ToLower(field), query) { + return true + } + } + return false +} + +func (m model) pluginManagerOverlay(width int) string { + if m.pluginManager == nil { + return "" + } + if width <= 0 { + width = defaultStartupWidth + } + overlayWidth := minInt(width, pluginManagerOverlayMaxWidth) + if overlayWidth < pluginManagerOverlayMinWidth { + overlayWidth = width + } + innerWidth := maxInt(1, overlayWidth-4) + items := m.pluginManagerItems() + if len(items) > 0 { + m.pluginManager.selected = clampInt(m.pluginManager.selected, 0, len(items)-1) + } + + snapshot := m.pluginManagerSnapshot() + lines := []string{ + fillPaletteLine(zeroTheme.ink.Bold(true).Render(pluginManagerSummary(snapshot)), innerWidth, transparentSurface), + fillPaletteLine(renderPluginManagerSearchLine(m.pluginManager.query, innerWidth), innerWidth, transparentSurface), + } + if len(snapshot.Plugins) == 0 { + lines = append(lines, zeroTheme.faint.Render(" No local Zero plugins loaded.")) + } + if snapshot.TrustNotice != "" { + lines = append(lines, zeroTheme.amber.Render(" "+snapshot.TrustNotice)) + } + if snapshot.LoadError != "" { + lines = append(lines, zeroTheme.amber.Render(" "+snapshot.LoadError)) + } + if m.pluginManager.confirm != nil { + lines = append(lines, zeroTheme.amber.Render(" "+m.pluginManager.confirm.message)) + lines = append(lines, zeroTheme.faint.Render(" Enter confirm Esc cancel")) + } + itemLines := m.renderPluginManagerItemLines(innerWidth, items) + lines = append(lines, itemLines...) + if detail := m.pluginManagerSelectionDetail(innerWidth); len(detail) > 0 { + lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth))) + lines = append(lines, detail...) + } + lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth))) + lines = append(lines, fillPaletteLine(zeroTheme.faint.Render("type search up/down navigate Enter info Alt+v verify Alt+d disable Esc close"), innerWidth, transparentSurface)) + return centerRenderedBlock(styledBlockFillTitle(overlayWidth, "Manage plugins", lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width) +} + +func renderPluginManagerSearchLine(query string, width int) string { + query = strings.TrimSpace(query) + prompt := zeroTheme.userPrompt.Render("search > ") + if query == "" { + return fitStyledLine(prompt+zeroTheme.faint.Render("plugins, catalogs, marketplace actions..."), width) + } + return fitStyledLine(prompt+zeroTheme.ink.Render(query), width) +} + +func (m model) renderPluginManagerItemLines(width int, items []pluginManagerItem) []string { + if len(items) == 0 { + return []string{fillPaletteLine(zeroTheme.faint.Render(" no plugin actions"), width, transparentSurface)} + } + maxVisible := minInt(pluginManagerMaxVisible, len(items)) + start := selectableListStart(len(items), maxVisible, m.pluginManager.selected) + visible := items[start : start+maxVisible] + lines := make([]string, 0, len(visible)) + lastGroup := "" + for offset, item := range visible { + index := start + offset + if group := pluginManagerItemGroup(item.Kind); group != "" && group != lastGroup { + lines = append(lines, zeroTheme.accent.Bold(true).Render(group)) + lastGroup = group + } + surface := transparentSurface + marker := surface(zeroTheme.faintest).Render(" ") + if index == m.pluginManager.selected { + surface = zeroTheme.onSel + marker = surface(zeroTheme.accent).Render("› ") + } + left := marker + surface(zeroTheme.ink).Render(item.Label) + right := "" + if item.Meta != "" { + right = surface(zeroTheme.faint).Render(item.Meta) + } + gap := width - lipgloss.Width(left) - lipgloss.Width(right) + line := left + surface(zeroTheme.ink).Render(strings.Repeat(" ", maxInt(1, gap))) + right + lines = append(lines, fillPaletteLine(line, width, surface)) + } + return lines +} + +func pluginManagerItemGroup(kind pluginManagerItemKind) string { + switch kind { + case pluginManagerItemInstalled: + return "Installed" + case pluginManagerItemDiagnostic: + return "Installed issues" + case pluginManagerItemMarketplacePlugin: + return "Discover" + case pluginManagerItemCatalog: + return "Catalogs" + case pluginManagerItemBrowse, pluginManagerItemInstall, pluginManagerItemAddMarketplace, pluginManagerItemRemoveMarketplace, pluginManagerItemVerify, pluginManagerItemListMarketplaces, pluginManagerItemUpdateMarketplaces, pluginManagerItemSignCatalog: + return "Actions" + default: + return "Actions" + } +} + +func (m model) pluginManagerSelectionDetail(width int) []string { + item, ok := m.currentPluginManagerItem() + if !ok { + return nil + } + lines := []string{} + if item.Detail != "" { + lines = append(lines, fillPaletteLine(zeroTheme.faint.Render(item.Detail), width, transparentSurface)) + } + if item.Input != "" { + lines = append(lines, fillPaletteLine(zeroTheme.ink.Render(item.Input), width, transparentSurface)) + } + switch item.Kind { + case pluginManagerItemInstalled: + lines = append(lines, fillPaletteLine(zeroTheme.faint.Render("Enter info Alt+v verify Alt+e enable Alt+d disable Alt+u update Alt+r remove"), width, transparentSurface)) + lines = append(lines, fillPaletteLine(zeroTheme.faint.Render("Alt+p pin/unpin"), width, transparentSurface)) + case pluginManagerItemMarketplacePlugin: + lines = append(lines, fillPaletteLine(zeroTheme.faint.Render("Enter info Alt+i install user Alt+p install project"), width, transparentSurface)) + case pluginManagerItemCatalog: + lines = append(lines, fillPaletteLine(zeroTheme.faint.Render("Enter browse Alt+u refresh Alt+r remove catalog"), width, transparentSurface)) + } + return lines +} + +func (m model) pluginManagerSnapshot() pluginManagerSnapshot { + if m.pluginSnapshotReady { + return m.pluginSnapshot + } + return pluginManagerSnapshot{} +} + +func pluginManagerSummary(snapshot pluginManagerSnapshot) string { + parts := []string{pluralCount(len(snapshot.Plugins), "plugin")} + if len(snapshot.MarketplacePlugins) > 0 { + parts = append(parts, pluralCount(len(snapshot.MarketplacePlugins), "marketplace plugin")) + } + if len(snapshot.Catalogs) > 0 { + parts = append(parts, pluralCount(len(snapshot.Catalogs), "catalog")) + } + if len(snapshot.Diagnostics) > 0 { + parts = append(parts, pluralCount(len(snapshot.Diagnostics), "diagnostic")) + } + if snapshot.ProjectPluginsShown { + parts = append(parts, "project enabled") + } else { + parts = append(parts, "user scope") + } + return strings.Join(parts, " · ") +} + +func pluginManagerPluginDetail(plugin plugins.LoadedPlugin, state pluginManagerInstalledSummary) string { + counts := []string{ + pluralCount(len(plugin.Tools), "tool"), + pluralCount(len(plugin.Prompts), "prompt"), + pluralCount(len(plugin.Skills), "skill"), + pluralCount(len(plugin.Hooks), "hook"), + } + name := strings.TrimSpace(plugin.Name) + if name == "" { + name = plugin.ID + } + parts := []string{fmt.Sprintf("%s: %s", name, strings.Join(counts, ", "))} + if state.Catalog != "" { + parts = append(parts, "catalog "+state.Catalog) + } + if state.Pinned { + parts = append(parts, "pinned") + } + if state.Commit != "" { + parts = append(parts, "commit "+state.Commit[:minInt(len(state.Commit), 12)]) + } + return strings.Join(parts, " · ") +} + +func (m model) pluginsText(args string) string { + snapshot := m.pluginManagerSnapshot() + lines := strings.Split(plugins.FormatList(snapshot.Plugins, snapshot.Diagnostics), "\n") + if snapshot.TrustNotice != "" { + lines = append(lines, snapshot.TrustNotice) + } + if snapshot.LoadError != "" { + lines = append(lines, snapshot.LoadError) + } + return renderCommandOutput(commandOutput{ + Title: "Plugins", + Status: pluginCommandStatus(snapshot), + Sections: []commandSection{{ + Title: "Requested", + Fields: []commandField{ + {Key: "command", Value: strings.TrimSpace(args)}, + }, + }, { + Title: "Installed", + Lines: lines, + }}, + Hints: []string{ + "open manager: /plugins", + "CLI: zero plugins browse | zero plugins install --scope user --yes [--allow-unverified] | zero plugins verify ", + }, + }) +} + +func (m model) startPluginTranscriptCommand(args string) (model, tea.Cmd) { + args = strings.TrimSpace(args) + if args == "" { + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "plugins", text: m.pluginsText(args)}) + return m, nil + } + parsedArgs, err := splitMCPCommandArgs(args) + if err != nil { + text := strings.Join([]string{ + "Plugin action failed", + err.Error(), + "", + m.pluginsText(args), + }, "\n") + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "plugins", text: text}) + return m, nil + } + return m.startPluginCommand(pluginCommandRequest{origin: pluginCommandOriginTranscript, raw: args, args: parsedArgs}) +} + +func (m model) startPluginCommand(request pluginCommandRequest) (model, tea.Cmd) { + if m.pluginCommand == nil { + result := PluginCommandResult{ + ExitCode: 1, + Error: "Plugin action unavailable", + Snapshot: m.pluginSnapshot, + } + return m.applyPluginCommandResultMessage(pluginCommandResultMsg{request: request, result: result}), nil + } + m.cancelPluginCommand() + ctx := m.ctx + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithCancel(ctx) + m.pluginCommandSeq++ + request.id = m.pluginCommandSeq + request.args = append([]string{}, request.args...) + m.pluginCommandCancel = cancel + runner := m.pluginCommand + return m, func() tea.Msg { + return pluginCommandResultMsg{ + request: request, + result: runner(ctx, request.args), + } + } +} + +func (m *model) cancelPluginCommand() { + if m.pluginCommandCancel != nil { + m.pluginCommandCancel() + m.pluginCommandCancel = nil + m.pluginCommandSeq++ + } +} + +func (m model) applyPluginCommandResultMessage(msg pluginCommandResultMsg) model { + if msg.request.id != 0 && msg.request.id != m.pluginCommandSeq { + return m + } + m.pluginCommandCancel = nil + if hasPluginSnapshot(msg.result.Snapshot) || !m.pluginSnapshotReady { + m.pluginSnapshot = msg.result.Snapshot + m.pluginSnapshotReady = true + } + switch msg.request.origin { + case pluginCommandOriginManager: + text := m.pluginCommandResultText(strings.Join(msg.request.args, " "), msg.result) + m.pluginManager = &pluginManagerState{selected: msg.request.managerSelected, query: msg.request.managerQuery} + if items := m.pluginManagerItems(); len(items) > 0 { + m.pluginManager.selected = clampInt(m.pluginManager.selected, 0, len(items)-1) + } + if text != "" && strings.Join(msg.request.args, " ") != "list" { + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "plugins", text: text}) + } + default: + text := m.pluginCommandResultText(msg.request.raw, msg.result) + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "plugins", text: text}) + } + return m +} + +func (m model) pluginCommandResultText(args string, result PluginCommandResult) string { + if result.ExitCode != 0 || strings.TrimSpace(result.Error) != "" { + message := strings.TrimSpace(result.Error) + if message == "" { + message = strings.TrimSpace(result.Output) + } + if message == "" { + message = "Plugin command failed" + } + return strings.Join([]string{ + "Plugin action failed", + message, + "", + m.pluginsText(args), + }, "\n") + } + output := strings.TrimSpace(result.Output) + if output == "" { + output = "zero plugins " + args + } + lines := []string{"Plugin action complete", output} + if result.RestartRequired { + lines = append(lines, "Restart Zero to apply plugin changes.") + } + lines = append(lines, "", m.pluginsText(args)) + return strings.Join(lines, "\n") +} + +func hasPluginSnapshot(snapshot PluginSnapshot) bool { + return len(snapshot.Plugins) > 0 || + len(snapshot.Diagnostics) > 0 || + len(snapshot.Installed) > 0 || + len(snapshot.Catalogs) > 0 || + len(snapshot.MarketplacePlugins) > 0 || + strings.TrimSpace(snapshot.TrustNotice) != "" || + strings.TrimSpace(snapshot.LoadError) != "" || + snapshot.ProjectPluginsShown +} + +func pluginCommandStatus(snapshot pluginManagerSnapshot) commandStatus { + if snapshot.LoadError != "" || len(snapshot.Diagnostics) > 0 { + return commandStatusWarning + } + return commandStatusInfo +} diff --git a/internal/tui/scroll_test.go b/internal/tui/scroll_test.go index 2a94729c..a18bd725 100644 --- a/internal/tui/scroll_test.go +++ b/internal/tui/scroll_test.go @@ -112,7 +112,7 @@ func TestMouseWheelOnClippedFooterStatusDoesNotMoveComposerCursor(t *testing.T) } func TestAltScreenTranscriptScrollKeepsFooterFixed(t *testing.T) { - m := newModel(context.Background(), Options{AltScreen: true, ProviderName: "openai", ModelName: "gpt-4.1"}) + m := newModel(context.Background(), Options{AltScreen: true, Cwd: "/workspace/zero", ProviderName: "openai", ModelName: "gpt-4.1"}) m.width = 90 m.height = 10 m.gitBranch = "feat/pinned-header" diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index a4528145..1003a6cf 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -65,7 +65,7 @@ func (m model) sidebarToggleAllowed() bool { return false } if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || - m.mcpManager != nil || m.picker != nil || m.suggestionsActive() { + m.mcpManager != nil || m.pluginManager != nil || m.picker != nil || m.suggestionsActive() { return false } // Home/welcome screen: stay single-column until there's real conversation. @@ -99,7 +99,7 @@ func (m model) sidebarAvailable() bool { // second column while any is active so their geometry and mouse hit-testing // stay full-width as before. if m.setup.visible || m.helpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil || - m.mcpManager != nil || m.picker != nil || m.suggestionsActive() { + m.mcpManager != nil || m.pluginManager != nil || m.picker != nil || m.suggestionsActive() { return false } // Home/welcome screen: stay single-column until there's real conversation, so diff --git a/internal/tui/transcript_selection.go b/internal/tui/transcript_selection.go index 3b89e329..fb3570fb 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -1089,7 +1089,7 @@ func (m model) transcriptHitTestSource() (header string, items []transcriptBodyI // transcriptHitTestBlocked reports whether mouse hit-testing must be skipped // outright — a modal/overlay is up, or there's no alt-screen viewport at all. func (m model) transcriptHitTestBlocked() bool { - return !m.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() + return !m.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.pluginManager != nil || m.picker != nil || m.suggestionsActive() } // transcriptHitTestLayout computes the frame/window/layout mouse hit-testing needs, From 0bf831cba2e1fdeb36b6e4ce2f62f22eeb076d10 Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Sat, 11 Jul 2026 04:20:50 +0530 Subject: [PATCH 06/10] fix: harden plugin marketplace review findings --- internal/cli/marketplace_cli_test.go | 128 ++++++++++++++++++++++++++ internal/cli/plugin_marketplace.go | 16 ++-- internal/cli/plugin_pin_cli_test.go | 34 +++++++ internal/marketplace/registry.go | 13 ++- internal/marketplace/registry_test.go | 14 +++ internal/plugins/install.go | 67 ++++++++++++-- internal/plugins/install_test.go | 23 +++++ internal/tui/commands_test.go | 116 ++++++++++++++++++++++- internal/tui/plugin_manager.go | 18 +--- 9 files changed, 390 insertions(+), 39 deletions(-) diff --git a/internal/cli/marketplace_cli_test.go b/internal/cli/marketplace_cli_test.go index f8554690..284eea58 100644 --- a/internal/cli/marketplace_cli_test.go +++ b/internal/cli/marketplace_cli_test.go @@ -231,6 +231,88 @@ func TestRunPluginsMarketplaceRemoteAddUpdateBrowse(t *testing.T) { } } +func TestRunPluginsMarketplaceUpdateHonorsScopeWhenCatalogIDsCollide(t *testing.T) { + setTrustConfigRoot(t) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) + cwd := t.TempDir() + if err := workspacetrust.Trust(cwd); err != nil { + t.Fatal(err) + } + userCatalog := writeMarketplaceTestCatalog(t) + projectCatalog := writeMarketplaceTestCatalog(t) + userPath, err := marketplace.RegistryPathForScope(marketplace.ScopeUser, cwd, nil) + if err != nil { + t.Fatal(err) + } + projectPath, err := marketplace.RegistryPathForScope(marketplace.ScopeProject, cwd, nil) + if err != nil { + t.Fatal(err) + } + if err := marketplace.SaveRegistry(userPath, marketplace.Registry{Catalogs: []marketplace.RegisteredCatalog{{ + ID: "team", + Source: userCatalog, + VerificationStatus: marketplace.VerificationStale, + }}}); err != nil { + t.Fatal(err) + } + if err := marketplace.SaveRegistry(projectPath, marketplace.Registry{Catalogs: []marketplace.RegisteredCatalog{{ + ID: "team", + Source: projectCatalog, + VerificationStatus: marketplace.VerificationStale, + }}}); err != nil { + t.Fatal(err) + } + deps := appDeps{getwd: func() (string, error) { return cwd, nil }} + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"plugins", "marketplace", "update", "team", "--scope", "project", "--json"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("scoped update exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } + userRegistry, err := marketplace.LoadRegistry(userPath) + if err != nil { + t.Fatal(err) + } + projectRegistry, err := marketplace.LoadRegistry(projectPath) + if err != nil { + t.Fatal(err) + } + if userRegistry.Catalogs[0].VerificationStatus != marketplace.VerificationStale { + t.Fatalf("user catalog was updated despite --scope project: %#v", userRegistry.Catalogs) + } + if projectRegistry.Catalogs[0].VerificationStatus != marketplace.VerificationUnsigned { + t.Fatalf("project catalog was not updated: %#v", projectRegistry.Catalogs) + } +} + +func TestRunPluginsBrowseShowsSelectedLatestRelease(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) + catalogPath := writeMarketplaceMultiReleaseCatalog(t) + cwd := t.TempDir() + deps := appDeps{getwd: func() (string, error) { return cwd, nil }} + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runWithDeps([]string{"plugins", "marketplace", "add", catalogPath, "--allow-unverified"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("add exitCode = %d stderr=%s", exitCode, stderr.String()) + } + + stdout.Reset() + stderr.Reset() + exitCode = runWithDeps([]string{"plugins", "browse", "--catalog", "team"}, &stdout, &stderr, deps) + if exitCode != exitSuccess { + t.Fatalf("browse exitCode = %d stderr=%s", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), "zero.demo@0.2.0") { + t.Fatalf("browse should show selected latest release, got:\n%s", stdout.String()) + } + if strings.Contains(stdout.String(), "zero.demo@0.1.0") { + t.Fatalf("browse displayed first catalog release instead of selected latest:\n%s", stdout.String()) + } +} + func TestRunPluginsMarketplaceRemoteCatalogRejectsLocalReleaseRepository(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) source, hash := marketplaceTestPluginSource(t, "zero.demo", "0.1.0") @@ -543,3 +625,49 @@ func writeMarketplaceTestCatalog(t *testing.T) string { } return path } + +func writeMarketplaceMultiReleaseCatalog(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "catalog.json") + body := `{ + "schemaVersion": 1, + "id": "team", + "owner": "Platform", + "plugins": [ + { + "id": "zero.demo", + "name": "Demo", + "description": "Lookup helper", + "author": {"name": "Platform"}, + "license": "MIT", + "review": { + "status": "community", + "date": "2026-07-10", + "reviewer": "Zero Security", + "url": "https://github.com/Gitlawb/zero-plugins/pull/1" + }, + "releases": [ + { + "version": "0.1.0", + "repository": "https://github.com/Gitlawb/zero-demo-plugin.git", + "commit": "` + strings.Repeat("a", 40) + `", + "treeHash": "sha256:` + strings.Repeat("b", 64) + `", + "components": {"tools": [{"name": "lookup", "permission": "prompt"}]} + }, + { + "version": "0.2.0", + "repository": "https://github.com/Gitlawb/zero-demo-plugin.git", + "commit": "` + strings.Repeat("c", 40) + `", + "treeHash": "sha256:` + strings.Repeat("d", 64) + `", + "components": {"tools": [{"name": "lookup", "permission": "prompt"}]} + } + ] + } + ] +}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} diff --git a/internal/cli/plugin_marketplace.go b/internal/cli/plugin_marketplace.go index 2c5e22dd..a47267a6 100644 --- a/internal/cli/plugin_marketplace.go +++ b/internal/cli/plugin_marketplace.go @@ -728,13 +728,13 @@ func runMarketplaceUpdate(args []string, stdout io.Writer, stderr io.Writer, dep return exitSuccess } if id == "" { - return writeExecUsageError(stderr, "usage: zero plugins marketplace update [--json]") + return writeExecUsageError(stderr, "usage: zero plugins marketplace update [--scope user|project] [--json]") } cwd, err := deps.getwd() if err != nil { return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) } - entry, ok, err := findRegisteredCatalog(id, cwd, stderr) + entry, ok, err := findRegisteredCatalogForScope(id, cwd, options.scope, stderr) if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) } @@ -1098,12 +1098,16 @@ func registeredCatalogsForCLI(cwd string, stderr io.Writer) ([]marketplace.Regis } func findRegisteredCatalog(id string, cwd string, stderr io.Writer) (marketplace.RegisteredCatalog, bool, error) { + return findRegisteredCatalogForScope(id, cwd, "", stderr) +} + +func findRegisteredCatalogForScope(id string, cwd string, scope marketplace.Scope, stderr io.Writer) (marketplace.RegisteredCatalog, bool, error) { catalogs, err := registeredCatalogsForCLI(cwd, stderr) if err != nil { return marketplace.RegisteredCatalog{}, false, err } for _, catalog := range catalogs { - if catalog.ID == id { + if catalog.ID == id && (scope == "" || catalog.Scope == scope) { return catalog, true, nil } } @@ -1570,8 +1574,8 @@ func formatMarketplaceBrowse(catalogID string, plugins []marketplace.CatalogPlug } for _, plugin := range plugins { version := "" - if len(plugin.Releases) > 0 { - version = "@" + plugin.Releases[0].Version + if release, ok := selectRelease(plugin, ""); ok { + version = "@" + release.Version } lines = append(lines, fmt.Sprintf(" %s%s %s [%s] - %s", plugin.ID, version, plugin.Name, plugin.Review.Status, plugin.Description)) } @@ -1655,7 +1659,7 @@ func writeMarketplaceRemoveHelp(w io.Writer) error { func writeMarketplaceUpdateHelp(w io.Writer) error { _, err := fmt.Fprint(w, `Usage: - zero plugins marketplace update [--json] + zero plugins marketplace update [--scope user|project] [--json] `) return err } diff --git a/internal/cli/plugin_pin_cli_test.go b/internal/cli/plugin_pin_cli_test.go index c63a2ecc..13bf9eef 100644 --- a/internal/cli/plugin_pin_cli_test.go +++ b/internal/cli/plugin_pin_cli_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "path/filepath" + "strings" "testing" "github.com/Gitlawb/zero/internal/plugins" @@ -65,3 +66,36 @@ func TestRunPluginPinUnpinJSON(t *testing.T) { t.Fatalf("unexpected unpin payload: %#v", unpinned) } } + +func TestRunPluginPinRejectsVersionDifferentFromInstalledPlugin(t *testing.T) { + pluginsDir := t.TempDir() + src := writeSourcePluginDir(t, filepath.Join(t.TempDir(), "src"), map[string]any{ + "schemaVersion": 1, + "id": "zero.demo", + "name": "Zero Demo", + "version": "0.1.0", + }) + deps := appDeps{pluginsDir: func() string { return pluginsDir }} + + var stdout bytes.Buffer + var stderr bytes.Buffer + if exit := runWithDeps([]string{"plugin", "add", src}, &stdout, &stderr, deps); exit != exitSuccess { + t.Fatalf("plugin add exit = %d stderr=%s", exit, stderr.String()) + } + + stdout.Reset() + stderr.Reset() + if exit := runWithDeps([]string{"plugins", "pin", "zero.demo", "--version", "9.9.9"}, &stdout, &stderr, deps); exit != exitUsage { + t.Fatalf("pin mismatch exit = %d stdout=%s stderr=%s", exit, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "installed version") { + t.Fatalf("expected installed version error, got %s", stderr.String()) + } + lock, err := plugins.ReadLock(pluginsDir) + if err != nil { + t.Fatalf("ReadLock: %v", err) + } + if lock["zero.demo"].Pinned { + t.Fatalf("mismatched pin should not mutate lock: %#v", lock["zero.demo"]) + } +} diff --git a/internal/marketplace/registry.go b/internal/marketplace/registry.go index a2ffe94d..bef979a2 100644 --- a/internal/marketplace/registry.go +++ b/internal/marketplace/registry.go @@ -17,8 +17,9 @@ import ( ) const ( - OfficialCatalogID = "official" - OfficialCatalogSource = "Gitlawb/zero-plugins" + OfficialCatalogID = "official" + OfficialCatalogSource = "Gitlawb/zero-plugins" + catalogGitFetchTimeout = 2 * time.Minute ) // OfficialCatalogPublicKey verifies Gitlawb/zero-plugins catalog.sig. The @@ -296,7 +297,9 @@ func fetchGitCatalog(ctx context.Context, source string) ([]byte, []byte, error) return nil, nil, fmt.Errorf("create catalog fetch temp: %w", err) } defer func() { _ = os.RemoveAll(temp) }() - command := exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--", source, temp) + gitCtx, cancel := catalogGitFetchContext(ctx) + defer cancel() + command := exec.CommandContext(gitCtx, "git", "clone", "--depth", "1", "--", source, temp) command.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") if output, err := command.CombinedOutput(); err != nil { return nil, nil, fmt.Errorf("git clone catalog failed: %v: %s", err, strings.TrimSpace(string(output))) @@ -304,6 +307,10 @@ func fetchGitCatalog(ctx context.Context, source string) ([]byte, []byte, error) return ReadLocalCatalog(filepath.Join(temp, "catalog.json")) } +func catalogGitFetchContext(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, catalogGitFetchTimeout) +} + func signaturePathForCatalog(catalogPath string) string { if strings.EqualFold(filepath.Base(catalogPath), "catalog.json") { return filepath.Join(filepath.Dir(catalogPath), "catalog.sig") diff --git a/internal/marketplace/registry_test.go b/internal/marketplace/registry_test.go index 692b11cd..e4bbd226 100644 --- a/internal/marketplace/registry_test.go +++ b/internal/marketplace/registry_test.go @@ -1,10 +1,12 @@ package marketplace import ( + "context" "os" "path/filepath" "strings" "testing" + "time" ) func TestRegistryAddRejectsReservedAndDuplicateCatalogIDs(t *testing.T) { @@ -87,3 +89,15 @@ func TestReadLocalCatalogReadsCatalogAndOptionalSignature(t *testing.T) { t.Fatalf("unexpected data/signature: %q %q", data, sig) } } + +func TestCatalogGitFetchContextHasDeadline(t *testing.T) { + ctx, cancel := catalogGitFetchContext(context.Background()) + defer cancel() + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("expected catalog git fetch context to have a deadline") + } + if time.Until(deadline) <= 0 { + t.Fatalf("expected future catalog git fetch deadline, got %s", deadline) + } +} diff --git a/internal/plugins/install.go b/internal/plugins/install.go index 3bf0b52f..96527bdb 100644 --- a/internal/plugins/install.go +++ b/internal/plugins/install.go @@ -20,15 +20,19 @@ import ( "regexp" "sort" "strings" + "time" ) -// manifestFileName is the plugin manifest filename, matching the loader. -const manifestFileName = "plugin.json" +const ( + // manifestFileName is the plugin manifest filename, matching the loader. + manifestFileName = "plugin.json" -// LockFileName maps an installed plugin id to its source and content hash. -const LockFileName = "plugins.lock" + // LockFileName maps an installed plugin id to its source and content hash. + LockFileName = "plugins.lock" -const disabledDirName = ".disabled" + disabledDirName = ".disabled" + pluginGitFetchTimeout = 2 * time.Minute +) // ErrNameClash is returned when an install would overwrite a plugin already // installed from a DIFFERENT source, unless InstallOptions.Force is set. @@ -541,8 +545,16 @@ func Pin(dir string, id string, version string) (LockEntry, error) { return fmt.Errorf("plugin %q is not installed", id) } entry.Pinned = true - if strings.TrimSpace(version) != "" { - entry.Version = strings.TrimSpace(version) + requestedVersion := strings.TrimSpace(version) + if requestedVersion != "" { + installedVersion, err := installedPluginVersion(dir, id) + if err != nil { + return err + } + if requestedVersion != installedVersion { + return fmt.Errorf("requested pin version %q does not match installed version %q", requestedVersion, installedVersion) + } + entry.Version = requestedVersion } lock[id] = entry if err := writeLock(dir, lock); err != nil { @@ -782,7 +794,9 @@ func fetchSource(ctx context.Context, source string, runner GitRunner) (string, return "", func() {}, fmt.Errorf("create temp dir: %w", err) } cleanup := func() { _ = os.RemoveAll(temp) } - if err := runner(ctx, temp, source); err != nil { + gitCtx, cancel := pluginGitFetchContext(ctx) + defer cancel() + if err := runner(gitCtx, temp, source); err != nil { cleanup() return "", func() {}, fmt.Errorf("fetch %s: %w", source, err) } @@ -806,12 +820,14 @@ func checkoutCommit(ctx context.Context, repo string, commit string) error { if !installCommitPattern.MatchString(commit) { return fmt.Errorf("commit must be a 40-character git SHA") } - fetch := exec.CommandContext(ctx, "git", "-C", repo, "fetch", "--depth", "1", "origin", commit) + gitCtx, cancel := pluginGitFetchContext(ctx) + defer cancel() + fetch := exec.CommandContext(gitCtx, "git", "-C", repo, "fetch", "--depth", "1", "origin", commit) fetch.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") if output, err := fetch.CombinedOutput(); err != nil { return fmt.Errorf("git fetch commit failed: %v: %s", err, strings.TrimSpace(string(output))) } - checkout := exec.CommandContext(ctx, "git", "-C", repo, "checkout", "--detach", commit) + checkout := exec.CommandContext(gitCtx, "git", "-C", repo, "checkout", "--detach", commit) checkout.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") if output, err := checkout.CombinedOutput(); err != nil { return fmt.Errorf("git checkout commit failed: %v: %s", err, strings.TrimSpace(string(output))) @@ -819,6 +835,10 @@ func checkoutCommit(ctx context.Context, repo string, commit string) error { return nil } +func pluginGitFetchContext(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, pluginGitFetchTimeout) +} + // locatePluginDir finds the directory holding plugin.json within root: the root // itself, or exactly one immediate subdirectory. func locatePluginDir(root string) (string, error) { @@ -916,6 +936,33 @@ func fileExists(path string) bool { return err == nil && info.Mode().IsRegular() } +func installedPluginVersion(dir string, id string) (string, error) { + for _, pluginDir := range []string{ + filepath.Join(dir, id), + filepath.Join(dir, disabledDirName, id), + } { + data, err := os.ReadFile(filepath.Join(pluginDir, manifestFileName)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return "", fmt.Errorf("read installed plugin manifest: %w", err) + } + var manifest struct { + Version string `json:"version"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + return "", fmt.Errorf("parse installed plugin manifest: %w", err) + } + version := strings.TrimSpace(manifest.Version) + if version == "" { + return "", fmt.Errorf("installed plugin %q has no version", id) + } + return version, nil + } + return "", fmt.Errorf("plugin %q is not installed", id) +} + // canonicalSource normalizes a local filesystem source to an absolute, // symlink-evaluated path so a re-install via a different spelling of the same // directory is recognized as the same source. Remote sources (git URLs) are diff --git a/internal/plugins/install_test.go b/internal/plugins/install_test.go index 7c68b124..3aa4c1a3 100644 --- a/internal/plugins/install_test.go +++ b/internal/plugins/install_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) // initGitPluginRepo creates a real local git repo holding a plugin and returns a @@ -330,6 +331,28 @@ func TestInstallGitSourceUsesRunner(t *testing.T) { } } +func TestInstallGitSourcePassesBoundedContextToRunner(t *testing.T) { + destDir := t.TempDir() + runner := func(ctx context.Context, destination string, source string) error { + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("expected git runner context to have a deadline") + } + if time.Until(deadline) <= 0 { + t.Fatalf("expected future git runner deadline, got %s", deadline) + } + writeSourcePlugin(t, destination, validManifest()) + return nil + } + if _, err := Install(context.Background(), InstallOptions{ + Source: "https://example.com/plugin.git", + Dir: destDir, + GitRunner: runner, + }); err != nil { + t.Fatalf("install via runner: %v", err) + } +} + func TestRemoveDeletesPluginAndLockEntry(t *testing.T) { destDir := t.TempDir() src := writeSourcePlugin(t, filepath.Join(t.TempDir(), "src"), validManifest()) diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index 8b6a446a..b60ae752 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -353,12 +353,12 @@ func TestPluginManagerMarketplaceInstallActionsUseBridgeScopes(t *testing.T) { { name: "user", key: testKeyAltText("i"), - want: []string{"install", "zero.demo@team", "--scope", "user", "--yes", "--allow-unverified"}, + want: []string{"install", "zero.demo@team", "--scope", "user", "--yes"}, }, { name: "project", key: testKeyAltText("p"), - want: []string{"install", "zero.demo@team", "--scope", "project", "--yes", "--allow-unverified"}, + want: []string{"install", "zero.demo@team", "--scope", "project", "--yes"}, }, } { t.Run(tc.name, func(t *testing.T) { @@ -383,8 +383,8 @@ func TestPluginManagerMarketplaceInstallActionsUseBridgeScopes(t *testing.T) { if next.pluginManager == nil || next.pluginManager.confirm == nil { t.Fatal("expected install action confirmation") } - if !strings.Contains(next.pluginManager.confirm.message, "Warning: installing from unsigned/stale catalog") { - t.Fatalf("expected unsigned/stale warning, got %q", next.pluginManager.confirm.message) + if strings.Contains(next.pluginManager.confirm.message, "--allow-unverified") { + t.Fatalf("manager must not auto-add --allow-unverified, got %q", next.pluginManager.confirm.message) } updated, cmd = next.Update(testKey(tea.KeyEnter)) @@ -403,6 +403,114 @@ func TestPluginManagerMarketplaceInstallActionsUseBridgeScopes(t *testing.T) { } } +func TestPluginManagerMarketplaceInstalledUpdateDoesNotAutoAllowUnverified(t *testing.T) { + enabled := true + var called []string + m := newModel(context.Background(), Options{ + Cwd: t.TempDir(), + Registry: tools.NewRegistry(), + PluginCommand: func(_ context.Context, args []string) PluginCommandResult { + called = append([]string{}, args...) + return PluginCommandResult{ExitCode: 0, Snapshot: PluginSnapshot{ + Plugins: []plugins.LoadedPlugin{{ + ID: "zero.demo", + Name: "Zero Demo", + Version: "1.2.3", + Enabled: true, + Source: plugins.SourceUser, + }}, + Installed: []PluginInstalledSnapshot{{ + ID: "zero.demo", + Source: plugins.SourceUser, + Catalog: "team", + Version: "1.2.3", + Enabled: &enabled, + }}, + Catalogs: []PluginCatalogSnapshot{{ + ID: "team", + Scope: marketplace.ScopeUser, + Verification: marketplace.Verification{Status: marketplace.VerificationStale}, + }}, + }} + }, + }) + m.pluginSnapshot = PluginSnapshot{ + Plugins: []plugins.LoadedPlugin{{ + ID: "zero.demo", + Name: "Zero Demo", + Version: "1.2.3", + Enabled: true, + Source: plugins.SourceUser, + }}, + Installed: []PluginInstalledSnapshot{{ + ID: "zero.demo", + Source: plugins.SourceUser, + Catalog: "team", + Version: "1.2.3", + Enabled: &enabled, + }}, + Catalogs: []PluginCatalogSnapshot{{ + ID: "team", + Scope: marketplace.ScopeUser, + Verification: marketplace.Verification{Status: marketplace.VerificationStale}, + }}, + } + m.pluginSnapshotReady = true + m.pluginManager = &pluginManagerState{} + + updated, cmd := m.Update(testKeyAltText("u")) + next := updated.(model) + if cmd != nil { + t.Fatal("expected update action to wait for confirmation") + } + if next.pluginManager == nil || next.pluginManager.confirm == nil { + t.Fatal("expected update action confirmation") + } + + updated, cmd = next.Update(testKey(tea.KeyEnter)) + next = updated.(model) + if cmd == nil { + t.Fatal("expected confirmed update action to run asynchronously") + } + next = applyCommandResult(t, next, cmd) + want := []string{"update", "zero.demo", "--scope", "user", "--yes"} + if !reflect.DeepEqual(called, want) { + t.Fatalf("PluginCommand args = %#v, want %#v", called, want) + } + if next.pluginManager == nil { + t.Fatal("expected plugin manager to stay open after update action") + } +} + +func TestPluginManagerMarketplaceInfoDoesNotAutoAllowUnverified(t *testing.T) { + var called []string + m := newModel(context.Background(), Options{ + Cwd: t.TempDir(), + Registry: tools.NewRegistry(), + PluginCommand: func(_ context.Context, args []string) PluginCommandResult { + called = append([]string{}, args...) + return PluginCommandResult{ExitCode: 0, Snapshot: pluginManagerMarketplaceActionSnapshot()} + }, + }) + m.pluginSnapshot = pluginManagerMarketplaceActionSnapshot() + m.pluginSnapshotReady = true + m.pluginManager = &pluginManagerState{} + + updated, cmd := m.Update(testKey(tea.KeyEnter)) + next := updated.(model) + if cmd == nil { + t.Fatal("expected marketplace info action to run asynchronously") + } + next = applyCommandResult(t, next, cmd) + want := []string{"info", "zero.demo@team"} + if !reflect.DeepEqual(called, want) { + t.Fatalf("PluginCommand args = %#v, want %#v", called, want) + } + if next.pluginManager == nil { + t.Fatal("expected plugin manager to stay open after info action") + } +} + func pluginManagerMarketplaceActionSnapshot() PluginSnapshot { return PluginSnapshot{ MarketplacePlugins: []PluginMarketplaceSnapshot{{ diff --git a/internal/tui/plugin_manager.go b/internal/tui/plugin_manager.go index 11cbb4d8..e10d0473 100644 --- a/internal/tui/plugin_manager.go +++ b/internal/tui/plugin_manager.go @@ -143,9 +143,6 @@ func (m model) handlePluginManagerKey(msg tea.KeyMsg) (model, tea.Cmd) { switch item.Kind { case pluginManagerItemInstalled: args := []string{"update", item.Name, "--scope", item.Scope, "--yes"} - if item.Verified != marketplace.VerificationSigned { - args = append(args, "--allow-unverified") - } return m.runPluginManagerCommand(args, true) case pluginManagerItemCatalog: return m.runPluginManagerCommand([]string{"marketplace", "update", item.Name, "--scope", item.Scope}, true) @@ -211,11 +208,7 @@ func (m model) choosePluginManagerItem() (model, tea.Cmd) { case pluginManagerItemInstalled: return m.runPluginManagerCommand([]string{"info", item.Name}, false) case pluginManagerItemMarketplacePlugin: - args := []string{"info", item.Name + "@" + item.CatalogID} - if item.Verified != marketplace.VerificationSigned { - args = append(args, "--allow-unverified") - } - return m.runPluginManagerCommand(args, false) + return m.runPluginManagerCommand([]string{"info", item.Name + "@" + item.CatalogID}, false) case pluginManagerItemCatalog: return m.runPluginManagerCommand([]string{"browse", "--catalog", item.Name}, false) } @@ -472,9 +465,6 @@ func pluginManagerMarketplaceItem(entry PluginMarketplaceSnapshot) pluginManager name = entry.Plugin.ID } input := "/plugins info " + entry.Plugin.ID + "@" + entry.CatalogID - if verification != marketplace.VerificationSigned { - input += " --allow-unverified" - } return pluginManagerItem{ Kind: pluginManagerItemMarketplacePlugin, Name: entry.Plugin.ID, @@ -519,11 +509,7 @@ func pluginManagerCatalogItem(catalog PluginCatalogSnapshot) pluginManagerItem { } func pluginManagerMarketplaceInstallArgs(item pluginManagerItem, scope marketplace.Scope) []string { - args := []string{"install", item.Name + "@" + item.CatalogID, "--scope", string(scope), "--yes"} - if item.Verified != marketplace.VerificationSigned { - args = append(args, "--allow-unverified") - } - return args + return []string{"install", item.Name + "@" + item.CatalogID, "--scope", string(scope), "--yes"} } func pluginManagerMarketplaceDetail(entry PluginMarketplaceSnapshot) string { From 0fbe4e193afdfaf434b69fc8f97a8809b7c313d9 Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Sat, 11 Jul 2026 04:35:36 +0530 Subject: [PATCH 07/10] fix: address plugin marketplace review comments --- internal/cli/app.go | 2 +- internal/cli/extensions.go | 17 ++- internal/cli/marketplace_cli_test.go | 54 ++++++++ internal/cli/plugin_marketplace.go | 127 +++++++++++++++--- internal/marketplace/catalog.go | 2 +- internal/marketplace/catalog_test.go | 43 ++++++ internal/marketplace/registry.go | 11 +- internal/marketplace/registry_test.go | 6 + internal/plugins/root_lock_windows.go | 22 ++- internal/tui/commands_test.go | 25 ++++ internal/tui/model.go | 1 + internal/tui/plugin_manager.go | 2 + internal/zerocommands/backend_snapshots.go | 3 +- .../zerocommands/backend_snapshots_test.go | 12 ++ 14 files changed, 290 insertions(+), 37 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 8bc1d4d8..4dd5576d 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -894,7 +894,7 @@ func tuiPluginCommand(workspaceRoot string, deps appDeps) func(context.Context, ctx = context.Background() } var stdout, stderr bytes.Buffer - exitCode := runPlugins(args, &stdout, &stderr, deps) + exitCode := runPluginsWithContext(ctx, args, &stdout, &stderr, deps) return tui.PluginCommandResult{ Snapshot: tuiPluginSnapshot(workspaceRoot, deps), Output: strings.TrimSpace(stdout.String()), diff --git a/internal/cli/extensions.go b/internal/cli/extensions.go index 2d355fef..982dfc20 100644 --- a/internal/cli/extensions.go +++ b/internal/cli/extensions.go @@ -33,6 +33,13 @@ type mcpLegacyListOptions struct { } func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + return runPluginsWithContext(context.Background(), args, stdout, stderr, deps) +} + +func runPluginsWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + if ctx == nil { + ctx = context.Background() + } if len(args) == 0 { return writeExecUsageError(stderr, "plugins subcommand required. Use `zero plugins list`.") } @@ -77,17 +84,17 @@ func runPlugins(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) } return exitSuccess case "browse": - return runPluginBrowse(args[1:], stdout, stderr, deps) + return runPluginBrowseWithContext(ctx, args[1:], stdout, stderr, deps) case "install": - return runPluginMarketplaceInstall(args[1:], stdout, stderr, deps) + return runPluginMarketplaceInstallWithContext(ctx, args[1:], stdout, stderr, deps) case "info": - return runPluginMarketplaceInfo(args[1:], stdout, stderr, deps) + return runPluginMarketplaceInfoWithContext(ctx, args[1:], stdout, stderr, deps) case "update": - return runPluginMarketplaceUpdate(args[1:], stdout, stderr, deps) + return runPluginMarketplaceUpdateWithContext(ctx, args[1:], stdout, stderr, deps) case "verify": return runPluginVerify(args[1:], stdout, stderr, deps) case "marketplace": - return runPluginMarketplace(args[1:], stdout, stderr, deps) + return runPluginMarketplaceWithContext(ctx, args[1:], stdout, stderr, deps) case "add": return runPluginAdd(args[1:], deps.pluginsDir(), stdout, stderr) case "remove", "rm": diff --git a/internal/cli/marketplace_cli_test.go b/internal/cli/marketplace_cli_test.go index 284eea58..36018553 100644 --- a/internal/cli/marketplace_cli_test.go +++ b/internal/cli/marketplace_cli_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "context" "crypto/ed25519" "encoding/base64" "encoding/json" @@ -313,6 +314,22 @@ func TestRunPluginsBrowseShowsSelectedLatestRelease(t *testing.T) { } } +func TestSelectReleasePrefersStableOverPrerelease(t *testing.T) { + release, ok := selectRelease(marketplace.CatalogPlugin{ + Releases: []marketplace.Release{ + {Version: "2.0.0-beta"}, + {Version: "2.0.0"}, + {Version: "1.9.9"}, + }, + }, "") + if !ok { + t.Fatal("selectRelease returned false") + } + if release.Version != "2.0.0" { + t.Fatalf("selected version = %q, want stable 2.0.0", release.Version) + } +} + func TestRunPluginsMarketplaceRemoteCatalogRejectsLocalReleaseRepository(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) source, hash := marketplaceTestPluginSource(t, "zero.demo", "0.1.0") @@ -341,6 +358,43 @@ func TestRunPluginsMarketplaceRemoteCatalogRejectsLocalReleaseRepository(t *test } } +func TestRunPluginsBrowseRefreshHonorsCanceledContext(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) + catalogBytes, err := os.ReadFile(writeMarketplaceTestCatalog(t)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(catalogBytes) + })) + defer server.Close() + cwd := t.TempDir() + registryPath, err := marketplace.RegistryPathForScope(marketplace.ScopeUser, cwd, nil) + if err != nil { + t.Fatal(err) + } + if err := marketplace.SaveRegistry(registryPath, marketplace.Registry{Catalogs: []marketplace.RegisteredCatalog{{ + ID: "team", + Source: server.URL + "/catalog.json", + VerificationStatus: marketplace.VerificationUnsigned, + }}}); err != nil { + t.Fatal(err) + } + deps := appDeps{getwd: func() (string, error) { return cwd, nil }} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := runPluginsWithContext(ctx, []string{"browse", "lookup", "--catalog", "team", "--refresh", "--json"}, &stdout, &stderr, deps) + if exitCode != exitUsage { + t.Fatalf("browse exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "context canceled") { + t.Fatalf("expected canceled context error, got %s", stderr.String()) + } +} + func TestTUIPluginSnapshotDoesNotRefreshMissingRemoteCatalogCache(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) cwd := t.TempDir() diff --git a/internal/cli/plugin_marketplace.go b/internal/cli/plugin_marketplace.go index a47267a6..a53087d8 100644 --- a/internal/cli/plugin_marketplace.go +++ b/internal/cli/plugin_marketplace.go @@ -11,12 +11,15 @@ import ( "sort" "strconv" "strings" + "time" "github.com/Gitlawb/zero/internal/marketplace" "github.com/Gitlawb/zero/internal/plugins" "github.com/Gitlawb/zero/internal/redaction" ) +const marketplaceCatalogOperationTimeout = 30 * time.Second + type marketplaceCommandOptions struct { json bool scope marketplace.Scope @@ -31,6 +34,13 @@ type marketplaceCommandOptions struct { } func runPluginMarketplace(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + return runPluginMarketplaceWithContext(context.Background(), args, stdout, stderr, deps) +} + +func runPluginMarketplaceWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + if ctx == nil { + ctx = context.Background() + } if len(args) == 0 { return writeExecUsageError(stderr, "marketplace subcommand required. Use `zero plugins marketplace list`.") } @@ -41,13 +51,13 @@ func runPluginMarketplace(args []string, stdout io.Writer, stderr io.Writer, dep } return exitSuccess case "add": - return runMarketplaceAdd(args[1:], stdout, stderr, deps) + return runMarketplaceAddWithContext(ctx, args[1:], stdout, stderr, deps) case "list": return runMarketplaceList(args[1:], stdout, stderr, deps) case "remove", "rm": return runMarketplaceRemove(args[1:], stdout, stderr, deps) case "update", "refresh": - return runMarketplaceUpdate(args[1:], stdout, stderr, deps) + return runMarketplaceUpdateWithContext(ctx, args[1:], stdout, stderr, deps) case "sign": return runMarketplaceSign(args[1:], stdout, stderr) case "validate": @@ -58,6 +68,13 @@ func runPluginMarketplace(args []string, stdout io.Writer, stderr io.Writer, dep } func runPluginBrowse(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + return runPluginBrowseWithContext(context.Background(), args, stdout, stderr, deps) +} + +func runPluginBrowseWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + if ctx == nil { + ctx = context.Background() + } query, options, help, err := parseBrowseArgs(args) if err != nil { return writeExecUsageError(stderr, err.Error()) @@ -85,7 +102,9 @@ func runPluginBrowse(args []string, stdout io.Writer, stderr io.Writer, deps app var catalog marketplace.Catalog var verification marketplace.Verification if options.refresh { - catalog, verification, _, err = updateCatalogCache(context.Background(), catalogEntry) + catalogCtx, cancel := marketplaceCatalogOperationContext(ctx) + catalog, verification, _, err = updateCatalogCache(catalogCtx, catalogEntry) + cancel() if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) } @@ -93,7 +112,9 @@ func runPluginBrowse(args []string, stdout io.Writer, stderr io.Writer, deps app return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) } } else { - catalog, verification, err = loadCatalogEntry(context.Background(), catalogEntry) + catalogCtx, cancel := marketplaceCatalogOperationContext(ctx) + catalog, verification, err = loadCatalogEntry(catalogCtx, catalogEntry) + cancel() if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) } @@ -117,6 +138,13 @@ func runPluginBrowse(args []string, stdout io.Writer, stderr io.Writer, deps app } func runPluginMarketplaceInstall(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + return runPluginMarketplaceInstallWithContext(context.Background(), args, stdout, stderr, deps) +} + +func runPluginMarketplaceInstallWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + if ctx == nil { + ctx = context.Background() + } target, options, help, err := parseMarketplaceInstallArgs(args) if err != nil { return writeExecUsageError(stderr, err.Error()) @@ -138,7 +166,7 @@ func runPluginMarketplaceInstall(args []string, stdout io.Writer, stderr io.Writ if err != nil { return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) } - resolved, err := resolveMarketplacePlugin(cwd, pluginID, catalogID, stderr) + resolved, err := resolveMarketplacePlugin(ctx, cwd, pluginID, catalogID, stderr) if err != nil { return writeExecUsageError(stderr, err.Error()) } @@ -163,7 +191,7 @@ func runPluginMarketplaceInstall(args []string, stdout io.Writer, stderr io.Writ if err != nil { return writeExecUsageError(stderr, err.Error()) } - result, err := plugins.Install(context.Background(), plugins.InstallOptions{ + result, err := plugins.Install(ctx, plugins.InstallOptions{ Source: release.Repository, Dir: dir, ExpectedID: resolved.plugin.ID, @@ -214,6 +242,13 @@ func runPluginMarketplaceInstall(args []string, stdout io.Writer, stderr io.Writ } func runPluginMarketplaceInfo(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + return runPluginMarketplaceInfoWithContext(context.Background(), args, stdout, stderr, deps) +} + +func runPluginMarketplaceInfoWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + if ctx == nil { + ctx = context.Background() + } target, options, help, err := parseMarketplaceInstallArgs(args) if err != nil { return writeExecUsageError(stderr, err.Error()) @@ -232,7 +267,7 @@ func runPluginMarketplaceInfo(args []string, stdout io.Writer, stderr io.Writer, if err != nil { return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash) } - resolved, err := resolveMarketplacePlugin(cwd, pluginID, catalogID, stderr) + resolved, err := resolveMarketplacePlugin(ctx, cwd, pluginID, catalogID, stderr) if err != nil { return writeExecUsageError(stderr, err.Error()) } @@ -263,6 +298,13 @@ func runPluginMarketplaceInfo(args []string, stdout io.Writer, stderr io.Writer, } func runPluginMarketplaceUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + return runPluginMarketplaceUpdateWithContext(context.Background(), args, stdout, stderr, deps) +} + +func runPluginMarketplaceUpdateWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + if ctx == nil { + ctx = context.Background() + } target, options, help, err := parseMarketplaceUpdateArgs(args) if err != nil { return writeExecUsageError(stderr, err.Error()) @@ -302,7 +344,7 @@ func runPluginMarketplaceUpdate(args []string, stdout io.Writer, stderr io.Write } results := make([]pluginUpdateResult, 0, len(targets)) for _, updateTarget := range targets { - result, err := runMarketplaceUpdateTarget(cwd, dir, updateTarget, lock[updateTarget.pluginID], options, stderr) + result, err := runMarketplaceUpdateTarget(ctx, cwd, dir, updateTarget, lock[updateTarget.pluginID], options, stderr) if err != nil { return writeExecUsageError(stderr, err.Error()) } @@ -370,7 +412,7 @@ func marketplaceUpdateTargets(target string, lock map[string]plugins.LockEntry) return targets, nil } -func runMarketplaceUpdateTarget(cwd string, dir string, target marketplaceUpdateTarget, entry plugins.LockEntry, options marketplaceCommandOptions, stderr io.Writer) (pluginUpdateResult, error) { +func runMarketplaceUpdateTarget(ctx context.Context, cwd string, dir string, target marketplaceUpdateTarget, entry plugins.LockEntry, options marketplaceCommandOptions, stderr io.Writer) (pluginUpdateResult, error) { result := pluginUpdateResult{ ID: target.pluginID, Catalog: target.catalogID, @@ -384,7 +426,7 @@ func runMarketplaceUpdateTarget(cwd string, dir string, target marketplaceUpdate result.Status = "pinned" return result, nil } - resolved, err := resolveMarketplacePlugin(cwd, target.pluginID, target.catalogID, stderr) + resolved, err := resolveMarketplacePlugin(ctx, cwd, target.pluginID, target.catalogID, stderr) if err != nil { return pluginUpdateResult{}, err } @@ -411,7 +453,7 @@ func runMarketplaceUpdateTarget(cwd string, dir string, target marketplaceUpdate result.Status = "current" return result, nil } - install, err := plugins.Install(context.Background(), plugins.InstallOptions{ + install, err := plugins.Install(ctx, plugins.InstallOptions{ Source: release.Repository, Dir: dir, Force: true, @@ -550,6 +592,13 @@ func runMarketplaceValidate(args []string, stdout io.Writer, stderr io.Writer) i } func runMarketplaceAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + return runMarketplaceAddWithContext(context.Background(), args, stdout, stderr, deps) +} + +func runMarketplaceAddWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + if ctx == nil { + ctx = context.Background() + } source, options, help, err := parseMarketplaceAddArgs(args) if err != nil { return writeExecUsageError(stderr, err.Error()) @@ -581,7 +630,9 @@ func runMarketplaceAdd(args []string, stdout io.Writer, stderr io.Writer, deps a return writeExecUsageError(stderr, "project marketplace catalogs require a trusted workspace; run `zero trust`") } } - catalog, verification, cachePath, err := loadCatalogForRegistration(context.Background(), source, parsedSource, options.scope, cwd, options.publicKeyPath) + catalogCtx, cancel := marketplaceCatalogOperationContext(ctx) + catalog, verification, cachePath, err := loadCatalogForRegistration(catalogCtx, source, parsedSource, options.scope, cwd, options.publicKeyPath) + cancel() if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) } @@ -717,6 +768,13 @@ func runMarketplaceRemove(args []string, stdout io.Writer, stderr io.Writer, dep } func runMarketplaceUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + return runMarketplaceUpdateWithContext(context.Background(), args, stdout, stderr, deps) +} + +func runMarketplaceUpdateWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + if ctx == nil { + ctx = context.Background() + } id, options, help, err := parseMarketplaceIDArgs(args, "marketplace update") if err != nil { return writeExecUsageError(stderr, err.Error()) @@ -741,7 +799,9 @@ func runMarketplaceUpdate(args []string, stdout io.Writer, stderr io.Writer, dep if !ok { return writeExecUsageError(stderr, fmt.Sprintf("marketplace catalog %q is not registered", id)) } - catalog, verification, cached, err := updateCatalogCache(context.Background(), entry) + catalogCtx, cancel := marketplaceCatalogOperationContext(ctx) + catalog, verification, cached, err := updateCatalogCache(catalogCtx, entry) + cancel() if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitUsage) } @@ -1114,6 +1174,13 @@ func findRegisteredCatalogForScope(id string, cwd string, scope marketplace.Scop return marketplace.RegisteredCatalog{}, false, nil } +func marketplaceCatalogOperationContext(ctx context.Context) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + return context.WithTimeout(ctx, marketplaceCatalogOperationTimeout) +} + type resolvedMarketplacePlugin struct { entry marketplace.RegisteredCatalog catalog marketplace.Catalog @@ -1121,7 +1188,10 @@ type resolvedMarketplacePlugin struct { plugin marketplace.CatalogPlugin } -func resolveMarketplacePlugin(cwd string, pluginID string, catalogID string, stderr io.Writer) (resolvedMarketplacePlugin, error) { +func resolveMarketplacePlugin(ctx context.Context, cwd string, pluginID string, catalogID string, stderr io.Writer) (resolvedMarketplacePlugin, error) { + if ctx == nil { + ctx = context.Background() + } if strings.TrimSpace(pluginID) == "" { return resolvedMarketplacePlugin{}, fmt.Errorf("plugin id is required") } @@ -1134,8 +1204,13 @@ func resolveMarketplacePlugin(cwd string, pluginID string, catalogID string, std if catalogID != "" && entry.ID != catalogID { continue } - catalog, verification, err := loadCatalogEntry(context.Background(), entry) + catalogCtx, cancel := marketplaceCatalogOperationContext(ctx) + catalog, verification, err := loadCatalogEntry(catalogCtx, entry) + cancel() if err != nil { + if ctx.Err() != nil { + return resolvedMarketplacePlugin{}, ctx.Err() + } if catalogID != "" { return resolvedMarketplacePlugin{}, err } @@ -1193,8 +1268,8 @@ func selectRelease(plugin marketplace.CatalogPlugin, version string) (marketplac } func compareSemver(left string, right string) int { - leftParts := semverCore(left) - rightParts := semverCore(right) + leftParts, leftPre := semverCoreAndPre(left) + rightParts, rightPre := semverCoreAndPre(right) for index := 0; index < 3; index++ { if leftParts[index] > rightParts[index] { return 1 @@ -1203,15 +1278,23 @@ func compareSemver(left string, right string) int { return -1 } } - return strings.Compare(left, right) + if leftPre == "" && rightPre != "" { + return 1 + } + if leftPre != "" && rightPre == "" { + return -1 + } + return strings.Compare(leftPre, rightPre) } -func semverCore(version string) [3]int { +func semverCoreAndPre(version string) ([3]int, string) { version = strings.TrimPrefix(strings.TrimSpace(version), "v") - if before, _, ok := strings.Cut(version, "-"); ok { + pre := "" + if before, _, ok := strings.Cut(version, "+"); ok { version = before } - if before, _, ok := strings.Cut(version, "+"); ok { + if before, _, ok := strings.Cut(version, "-"); ok { + pre = strings.TrimPrefix(version[len(before):], "-") version = before } parts := strings.Split(version, ".") @@ -1220,7 +1303,7 @@ func semverCore(version string) [3]int { value, _ := strconv.Atoi(parts[index]) result[index] = value } - return result + return result, pre } func readEd25519PublicKey(path string) (ed25519.PublicKey, error) { diff --git a/internal/marketplace/catalog.go b/internal/marketplace/catalog.go index 6e63dd23..ab9be366 100644 --- a/internal/marketplace/catalog.go +++ b/internal/marketplace/catalog.go @@ -276,7 +276,7 @@ func ValidateRemoteCatalogReleaseSources(catalog Catalog) error { if err != nil { return fmt.Errorf("%s: %w", field, err) } - if source.Kind == CatalogSourceLocal { + if source.Kind == CatalogSourceLocal || strings.HasPrefix(strings.ToLower(source.Canonical), "file://") { return fmt.Errorf("%s: expected an absolute git repository source, got local path", field) } } diff --git a/internal/marketplace/catalog_test.go b/internal/marketplace/catalog_test.go index 887c4e03..ef67c676 100644 --- a/internal/marketplace/catalog_test.go +++ b/internal/marketplace/catalog_test.go @@ -123,6 +123,18 @@ func TestRemoteCatalogReleaseSourcesRejectLocalPaths(t *testing.T) { } } +func TestRemoteCatalogReleaseSourcesRejectFileURLs(t *testing.T) { + body := strings.Replace(testCatalogJSON(), `"repository": "https://github.com/Gitlawb/zero-demo-plugin.git"`, `"repository": "file:///tmp/zero-demo-plugin"`, 1) + catalog, err := ParseCatalog([]byte(body)) + if err != nil { + t.Fatalf("ParseCatalog returned error: %v", err) + } + err = ValidateRemoteCatalogReleaseSources(catalog) + if err == nil || !strings.Contains(err.Error(), "absolute git repository source") { + t.Fatalf("expected absolute git source error, got %v", err) + } +} + func TestParseCatalogRejectsIncompleteReviewMetadata(t *testing.T) { cases := []struct { name string @@ -213,6 +225,37 @@ func TestParseCatalogSource(t *testing.T) { } } +func TestParseCatalogSourceHTTPRequiresLoopbackHost(t *testing.T) { + cases := []struct { + name string + source string + wantErr bool + }{ + {name: "ipv4 loopback", source: "http://127.0.0.1/catalog.json"}, + {name: "ipv6 loopback", source: "http://[::1]/catalog.json"}, + {name: "ipv4 mapped ipv6 loopback", source: "http://[::ffff:127.0.0.1]/catalog.json"}, + {name: "unspecified local bind", source: "http://0.0.0.0/catalog.json"}, + {name: "localhost suffix rejected", source: "http://localhost.example.com/catalog.json", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + parsed, err := ParseCatalogSource(tc.source) + if tc.wantErr { + if err == nil { + t.Fatalf("ParseCatalogSource(%q) returned nil error", tc.source) + } + return + } + if err != nil { + t.Fatalf("ParseCatalogSource(%q): %v", tc.source, err) + } + if parsed.Kind != CatalogSourceHTTPS { + t.Fatalf("kind = %s, want %s", parsed.Kind, CatalogSourceHTTPS) + } + }) + } +} + func testCatalogJSON() string { return `{ "schemaVersion": 1, diff --git a/internal/marketplace/registry.go b/internal/marketplace/registry.go index bef979a2..32917abf 100644 --- a/internal/marketplace/registry.go +++ b/internal/marketplace/registry.go @@ -19,7 +19,7 @@ import ( const ( OfficialCatalogID = "official" OfficialCatalogSource = "Gitlawb/zero-plugins" - catalogGitFetchTimeout = 2 * time.Minute + catalogGitFetchTimeout = 30 * time.Second ) // OfficialCatalogPublicKey verifies Gitlawb/zero-plugins catalog.sig. The @@ -334,8 +334,13 @@ func isLoopbackHost(host string) bool { if name == "localhost" { return true } - if ip := net.ParseIP(name); ip != nil && ip.IsLoopback() { - return true + if ip := net.ParseIP(name); ip != nil { + if ip.IsLoopback() || ip.IsUnspecified() { + return true + } + if v4 := ip.To4(); v4 != nil && v4[0] == 127 { + return true + } } return false } diff --git a/internal/marketplace/registry_test.go b/internal/marketplace/registry_test.go index e4bbd226..819e9ada 100644 --- a/internal/marketplace/registry_test.go +++ b/internal/marketplace/registry_test.go @@ -46,6 +46,12 @@ func TestRegistryLoadSaveRoundTrip(t *testing.T) { if len(loaded.Catalogs) != 1 || loaded.Catalogs[0].ID != "team" || loaded.Catalogs[0].Source != "./catalog.json" { t.Fatalf("unexpected registry: %#v", loaded) } + if loaded.Catalogs[0].PublicKeyPath != "./catalog.pub" { + t.Fatalf("publicKeyPath = %q, want ./catalog.pub", loaded.Catalogs[0].PublicKeyPath) + } + if loaded.Catalogs[0].VerificationStatus != VerificationUnsigned { + t.Fatalf("verificationStatus = %q, want %q", loaded.Catalogs[0].VerificationStatus, VerificationUnsigned) + } } func TestRegistryPathForScope(t *testing.T) { diff --git a/internal/plugins/root_lock_windows.go b/internal/plugins/root_lock_windows.go index 733fb503..ebdfc9cd 100644 --- a/internal/plugins/root_lock_windows.go +++ b/internal/plugins/root_lock_windows.go @@ -9,12 +9,15 @@ import ( "path/filepath" "strconv" "strings" + "syscall" "time" ) const ( pluginRootLockFileName = ".plugins-root.lock" pluginRootLockStaleAge = 30 * time.Minute + + processQueryLimitedInformation = 0x1000 ) type pluginRootLock struct { @@ -53,15 +56,26 @@ func recoverStalePluginRootLock(lockPath string) bool { data, err := os.ReadFile(lockPath) if err == nil { pid, _ := strconv.Atoi(strings.TrimSpace(string(data))) - if pid > 0 { - if process, err := os.FindProcess(pid); err == nil { - _ = process.Release() - } + if pid > 0 && processAlive(pid) { + return false } } return os.Remove(lockPath) == nil } +func processAlive(pid int) bool { + handle, err := syscall.OpenProcess(processQueryLimitedInformation, false, uint32(pid)) + if err != nil { + return false + } + defer syscall.CloseHandle(handle) + event, err := syscall.WaitForSingleObject(handle, 0) + if err != nil { + return true + } + return event == syscall.WAIT_TIMEOUT +} + func (lock *pluginRootLock) release() { if lock == nil || lock.file == nil { return diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index b60ae752..8be78818 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -249,6 +249,31 @@ func TestPluginManagerIgnoresStaleCommandResult(t *testing.T) { } } +func TestPluginManagerEscCancelsInFlightCommandResult(t *testing.T) { + m := newModel(context.Background(), Options{ + Cwd: t.TempDir(), + PluginCommand: func(_ context.Context, _ []string) PluginCommandResult { + return PluginCommandResult{ExitCode: 0, Output: "late"} + }, + }) + m.pluginManager = &pluginManagerState{} + updated, cmd := m.startPluginCommand(pluginCommandRequest{origin: pluginCommandOriginManager, args: []string{"verify", "zero.demo"}}) + if cmd == nil { + t.Fatal("expected async plugin command") + } + + updated, _ = updated.handlePluginManagerKey(testKey(tea.KeyEsc)) + msg := execCmd(cmd).(pluginCommandResultMsg) + next := updated.applyPluginCommandResultMessage(msg) + + if next.pluginManager != nil { + t.Fatal("late plugin result reopened manager after Esc") + } + if transcriptContains(next.transcript, "late") { + t.Fatal("late plugin result appended transcript after Esc") + } +} + func TestPluginManagerRendersMarketplaceCatalogAndRiskState(t *testing.T) { enabled := true m := newModel(context.Background(), Options{Cwd: t.TempDir(), Registry: tools.NewRegistry()}) diff --git a/internal/tui/model.go b/internal/tui/model.go index 822653bc..be6697d8 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1369,6 +1369,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if m.pluginManager != nil { + m.cancelPluginCommand() m.pluginManager = nil return m, nil } diff --git a/internal/tui/plugin_manager.go b/internal/tui/plugin_manager.go index e10d0473..69ab30ac 100644 --- a/internal/tui/plugin_manager.go +++ b/internal/tui/plugin_manager.go @@ -91,6 +91,7 @@ func (m model) handlePluginManagerKey(msg tea.KeyMsg) (model, tea.Cmd) { } switch { case keyIs(msg, tea.KeyEsc): + m.cancelPluginCommand() m.pluginManager = nil case keyIs(msg, tea.KeyUp): m.movePluginManager(-1) @@ -216,6 +217,7 @@ func (m model) choosePluginManagerItem() (model, tea.Cmd) { } func (m model) prefillPluginManagerCommand(input string) model { + m.cancelPluginCommand() m.pluginManager = nil m.input.SetValue(input) m.input.SetCursor(len([]rune(input))) diff --git a/internal/zerocommands/backend_snapshots.go b/internal/zerocommands/backend_snapshots.go index 6f70638f..efb3ea0d 100644 --- a/internal/zerocommands/backend_snapshots.go +++ b/internal/zerocommands/backend_snapshots.go @@ -266,7 +266,8 @@ func pluginState(plugin plugins.LoadedPlugin) string { } func pluginQuarantined(plugin plugins.LoadedPlugin) bool { - return strings.Contains(filepath.ToSlash(plugin.PluginDir), "/.disabled/") + dir := filepath.ToSlash(plugin.PluginDir) + return strings.Contains(dir, "/.disabled/") || strings.HasPrefix(dir, ".disabled/") } // PluginSnapshots converts a slice of plugins.LoadedPlugin into a diff --git a/internal/zerocommands/backend_snapshots_test.go b/internal/zerocommands/backend_snapshots_test.go index 699a7a68..e61db2d2 100644 --- a/internal/zerocommands/backend_snapshots_test.go +++ b/internal/zerocommands/backend_snapshots_test.go @@ -140,6 +140,18 @@ func TestPluginSnapshotIncludesMarketplaceLockMetadata(t *testing.T) { } } +func TestPluginSnapshotQuarantinedDetectsRelativeDisabledPrefix(t *testing.T) { + snapshot := PluginSnapshotFromPlugin(plugins.LoadedPlugin{ + ID: "zero.demo", + Name: "Demo", + Enabled: false, + PluginDir: ".disabled/zero.demo", + }) + if !snapshot.Quarantined { + t.Fatalf("expected relative disabled plugin to be quarantined: %#v", snapshot) + } +} + func TestMCPServerSnapshotsSortsAndReturnsEmptySliceForEmptyInput(t *testing.T) { servers := []mcp.Server{ {Name: "zulu", Type: mcp.ServerTypeHTTP, URL: "https://zulu.test"}, From 733120df7af8c73036fc026eea561cf153e4d58c Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Sat, 11 Jul 2026 04:53:47 +0530 Subject: [PATCH 08/10] fix: wait for interrupted exec sessions --- internal/tools/exec_command.go | 6 ++++- internal/tools/exec_command_test.go | 41 ++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/internal/tools/exec_command.go b/internal/tools/exec_command.go index 027eca89..f7b22527 100644 --- a/internal/tools/exec_command.go +++ b/internal/tools/exec_command.go @@ -779,7 +779,11 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a } } } - output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond) + wait := time.Duration(yieldTimeMS) * time.Millisecond + if interrupted && wait < execSessionStopTimeout { + wait = execSessionStopTimeout + } + output, outputTruncated := session.collect(ctx, wait) exitCode, exited := session.exitStatus() if exited { tool.manager.remove(session.id) diff --git a/internal/tools/exec_command_test.go b/internal/tools/exec_command_test.go index 835ee922..9e75f593 100644 --- a/internal/tools/exec_command_test.go +++ b/internal/tools/exec_command_test.go @@ -719,7 +719,7 @@ func TestWriteStdinRejectsInputForNonTTYSession(t *testing.T) { func TestWriteStdinStopIntentTerminatesNonTTYSession(t *testing.T) { for _, chars := range []string{`\u0003`, "exit\n"} { - root := t.TempDir() + root := resilientTempDir(t) manager := newExecSessionManager() execTool := NewScopedExecCommandTool(root, nil, manager) writeTool := NewWriteStdinTool(manager) @@ -756,6 +756,45 @@ func TestWriteStdinStopIntentTerminatesNonTTYSession(t *testing.T) { } } +func TestWriteStdinInterruptWaitsForDelayedNonTTYExit(t *testing.T) { + manager := newExecSessionManager() + writeTool := NewWriteStdinTool(manager) + cancelCalled := make(chan struct{}) + var cancelOnce sync.Once + session := &execSession{ + id: manager.allocateID(), + commandText: "delayed-exit", + relativeCwd: ".", + tty: false, + cancel: func() { + cancelOnce.Do(func() { close(cancelCalled) }) + }, + output: newExecOutputBuffer(), + done: make(chan struct{}), + } + manager.store(session) + go func() { + <-cancelCalled + time.Sleep(20 * time.Millisecond) + session.markDone(context.Canceled, 1) + }() + + result := writeTool.Run(context.Background(), map[string]any{ + "session_id": session.id, + "chars": `\u0003`, + "yield_time_ms": 1, + }) + if result.Status != StatusOK { + t.Fatalf("interrupt status = %s: %s", result.Status, result.Output) + } + if result.Meta["session_id"] != "" { + t.Fatalf("interrupt should wait for delayed exit, meta=%#v output=%q", result.Meta, result.Output) + } + if result.Meta["exit_code"] == "" || result.Meta["interrupted"] != "true" { + t.Fatalf("interrupt should report exit metadata, meta=%#v output=%q", result.Meta, result.Output) + } +} + func TestShouldInterruptExecSession(t *testing.T) { cases := []struct { chars string From 236d6aba6f4b0134cd06391da7341716fb3c1d90 Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Wed, 15 Jul 2026 20:01:15 +0530 Subject: [PATCH 09/10] fix: address coderabbit lint feedback --- internal/agenteval/score.go | 4 +-- internal/agentinit/agentinit.go | 6 ++-- internal/background/process_posix_test.go | 6 ++-- internal/cli/agent_eval.go | 4 +-- internal/cli/extensions.go | 26 +++++++------- internal/cli/mcp_config.go | 2 -- internal/cli/plugin_marketplace.go | 28 --------------- internal/cli/provider_detect.go | 6 ++-- internal/cli/usage.go | 18 +++++----- internal/dictation/transcriber_deepgram.go | 2 +- internal/imageinput/pdf_test.go | 4 +-- internal/lsp/navigate_test.go | 5 +-- internal/plugins/root_lock_windows.go | 16 ++++++--- internal/providerhealth/providerhealth.go | 8 ++--- .../provideronboarding/localruntime_test.go | 2 +- internal/repomap/prompt.go | 2 +- internal/sandbox/adapters.go | 2 +- internal/sandbox/command_prefix.go | 5 +-- internal/sandbox/engine.go | 2 +- internal/sandbox/landlock_other.go | 2 +- internal/sandbox/runner.go | 23 ------------- internal/sandbox/windows_network.go | 2 +- internal/specialist/exec.go | 2 +- internal/tui/command_center.go | 2 +- internal/tui/files_git_sweep_test.go | 9 ++--- internal/tui/flush.go | 4 +-- internal/tui/image_attach_test.go | 2 +- internal/tui/loop.go | 4 +-- internal/tui/model.go | 4 --- internal/tui/model_test.go | 4 +-- internal/tui/onboarding.go | 34 +++++++++++-------- internal/tui/provider_wizard_test.go | 3 +- internal/tui/rendering.go | 2 +- internal/tui/specialist_card.go | 6 ++-- internal/tui/streaming_fade.go | 11 ------ internal/tui/transcript_selection.go | 5 --- internal/update/apply.go | 5 +-- internal/update/apply_test.go | 5 +-- internal/update/update.go | 2 +- internal/usercommands/usercommands.go | 2 +- 40 files changed, 107 insertions(+), 174 deletions(-) diff --git a/internal/agenteval/score.go b/internal/agenteval/score.go index 73002902..174aa3cc 100644 --- a/internal/agenteval/score.go +++ b/internal/agenteval/score.go @@ -125,9 +125,7 @@ func Score(suite Suite, input ScoreInput) Report { if len(task.RequiredTraceEvents) > 0 { report.Results = append(report.Results, scoreTraceEvents(task.RequiredTraceEvents, input)) } - for _, result := range unknownCommandResults(input.CommandResults, seenCommands, input) { - report.Results = append(report.Results, result) - } + report.Results = append(report.Results, unknownCommandResults(input.CommandResults, seenCommands, input)...) report.finishSummary() return report } diff --git a/internal/agentinit/agentinit.go b/internal/agentinit/agentinit.go index 060fe4f6..a65b5486 100644 --- a/internal/agentinit/agentinit.go +++ b/internal/agentinit/agentinit.go @@ -40,14 +40,14 @@ func FormatFacts(info repoinfo.Info) string { b.WriteString("Pre-computed repository facts (from a local scan — verify and fill gaps):\n") if info.PrimaryLanguage != "" { - b.WriteString(fmt.Sprintf("- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount)) + fmt.Fprintf(&b, "- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount) } if langs := topLanguages(info.Languages, 5); langs != "" { b.WriteString("- Languages: " + langs + "\n") } - b.WriteString(fmt.Sprintf("- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth)) + fmt.Fprintf(&b, "- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth) if info.WorkspaceType != "" && info.WorkspaceType != "none" { - b.WriteString(fmt.Sprintf("- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount)) + fmt.Fprintf(&b, "- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount) } if len(info.BuildTools) > 0 { b.WriteString("- Build tools: " + strings.Join(info.BuildTools, ", ") + "\n") diff --git a/internal/background/process_posix_test.go b/internal/background/process_posix_test.go index fedd9205..be76924d 100644 --- a/internal/background/process_posix_test.go +++ b/internal/background/process_posix_test.go @@ -149,12 +149,10 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) { // The forked child must be gone too (poll until reaped by init). deadline := time.Now().Add(2 * time.Second) - for { + for !errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) { // Only ESRCH proves the child is gone; any other error (e.g. EPERM) would // wrongly pass the test, so treat it as still-present and keep polling. - if errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) { - break // child no longer exists - } + if time.Now().After(deadline) { _ = syscall.Kill(childPID, syscall.SIGKILL) t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID) diff --git a/internal/cli/agent_eval.go b/internal/cli/agent_eval.go index e8cb80fe..d0f6e6ce 100644 --- a/internal/cli/agent_eval.go +++ b/internal/cli/agent_eval.go @@ -515,9 +515,7 @@ func agentEvalReportFromBenchmark(suite agenteval.Suite, report agenteval.Benchm if task.Agent.Truncated { converted.Truncated = true } - for _, failure := range agentEvalFailuresFromTaskReport(task) { - converted.Failures = append(converted.Failures, failure) - } + converted.Failures = append(converted.Failures, agentEvalFailuresFromTaskReport(task)...) } return converted } diff --git a/internal/cli/extensions.go b/internal/cli/extensions.go index 982dfc20..c6e29018 100644 --- a/internal/cli/extensions.go +++ b/internal/cli/extensions.go @@ -587,19 +587,19 @@ func writePluginsHelp(w io.Writer) error { zero plugins Commands: - list List local Zero plugins - browse [query] Browse marketplace plugins - install Install a marketplace plugin - info Show marketplace plugin info - update [id] Update marketplace-managed plugins - verify Verify installed plugin integrity - add Install a plugin (manifest-validated, pinned in plugins.lock) - remove Remove an installed plugin and its lockfile entry - enable Enable a quarantined plugin - disable Disable a plugin by moving it to .disabled - pin Pin a marketplace-managed plugin - unpin Unpin a marketplace-managed plugin - marketplace Manage plugin catalogs + list List local Zero plugins + browse [query] Browse marketplace plugins + install Install a marketplace plugin + info Show marketplace plugin info + update [id] Update marketplace-managed plugins + verify Verify installed plugin integrity + add Install a plugin (manifest-validated, pinned in plugins.lock) + remove Remove an installed plugin and its lockfile entry + enable Enable a quarantined plugin + disable Disable a plugin by moving it to .disabled + pin Pin a marketplace-managed plugin + unpin Unpin a marketplace-managed plugin + marketplace Manage plugin catalogs `) return err } diff --git a/internal/cli/mcp_config.go b/internal/cli/mcp_config.go index 603fcd50..6abd2036 100644 --- a/internal/cli/mcp_config.go +++ b/internal/cli/mcp_config.go @@ -734,14 +734,12 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo switch { case legacyFound: raw = legacyRaw - found = true case config.IsDefaultMCPServer(name): // A built-in default server isn't written to the file until the user // overrides it. Treat it as present with an empty base so disabling it // writes a minimal {"disabled":true} entry that merges over the default — // letting `zero mcp disable ` work even though it lives in code. raw = nil - found = true default: return false, false, nil } diff --git a/internal/cli/plugin_marketplace.go b/internal/cli/plugin_marketplace.go index a53087d8..9991b64c 100644 --- a/internal/cli/plugin_marketplace.go +++ b/internal/cli/plugin_marketplace.go @@ -33,10 +33,6 @@ type marketplaceCommandOptions struct { refresh bool } -func runPluginMarketplace(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { - return runPluginMarketplaceWithContext(context.Background(), args, stdout, stderr, deps) -} - func runPluginMarketplaceWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { if ctx == nil { ctx = context.Background() @@ -67,10 +63,6 @@ func runPluginMarketplaceWithContext(ctx context.Context, args []string, stdout } } -func runPluginBrowse(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { - return runPluginBrowseWithContext(context.Background(), args, stdout, stderr, deps) -} - func runPluginBrowseWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { if ctx == nil { ctx = context.Background() @@ -137,10 +129,6 @@ func runPluginBrowseWithContext(ctx context.Context, args []string, stdout io.Wr return exitSuccess } -func runPluginMarketplaceInstall(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { - return runPluginMarketplaceInstallWithContext(context.Background(), args, stdout, stderr, deps) -} - func runPluginMarketplaceInstallWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { if ctx == nil { ctx = context.Background() @@ -241,10 +229,6 @@ func runPluginMarketplaceInstallWithContext(ctx context.Context, args []string, return exitSuccess } -func runPluginMarketplaceInfo(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { - return runPluginMarketplaceInfoWithContext(context.Background(), args, stdout, stderr, deps) -} - func runPluginMarketplaceInfoWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { if ctx == nil { ctx = context.Background() @@ -297,10 +281,6 @@ func runPluginMarketplaceInfoWithContext(ctx context.Context, args []string, std return exitSuccess } -func runPluginMarketplaceUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { - return runPluginMarketplaceUpdateWithContext(context.Background(), args, stdout, stderr, deps) -} - func runPluginMarketplaceUpdateWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { if ctx == nil { ctx = context.Background() @@ -591,10 +571,6 @@ func runMarketplaceValidate(args []string, stdout io.Writer, stderr io.Writer) i return exitSuccess } -func runMarketplaceAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { - return runMarketplaceAddWithContext(context.Background(), args, stdout, stderr, deps) -} - func runMarketplaceAddWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { if ctx == nil { ctx = context.Background() @@ -767,10 +743,6 @@ func runMarketplaceRemove(args []string, stdout io.Writer, stderr io.Writer, dep return exitSuccess } -func runMarketplaceUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { - return runMarketplaceUpdateWithContext(context.Background(), args, stdout, stderr, deps) -} - func runMarketplaceUpdateWithContext(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { if ctx == nil { ctx = context.Background() diff --git a/internal/cli/provider_detect.go b/internal/cli/provider_detect.go index c4063bd9..8c06667c 100644 --- a/internal/cli/provider_detect.go +++ b/internal/cli/provider_detect.go @@ -83,10 +83,10 @@ func runProvidersDetect(args []string, stdout io.Writer, stderr io.Writer, deps func parseProviderDetectArgs(args []string) (providerDetectOptions, bool, error) { options := providerDetectOptions{} for _, arg := range args { - switch { - case arg == "-h" || arg == "--help" || arg == "help": + switch arg { + case "-h", "--help", "help": return options, true, nil - case arg == "--json": + case "--json": options.json = true default: return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)} diff --git a/internal/cli/usage.go b/internal/cli/usage.go index 0c133d68..bdffe265 100644 --- a/internal/cli/usage.go +++ b/internal/cli/usage.go @@ -244,19 +244,19 @@ func parseUsageArgs(args []string) (usageOptions, bool, error) { func FormatReport(report usage.Report, insertions int, deletions int) string { var builder strings.Builder builder.WriteString("Usage report (cost is a reconstructed estimate)\n\n") - builder.WriteString(fmt.Sprintf("%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost")) + fmt.Fprintf(&builder, "%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost") for _, bucket := range report.Buckets { - builder.WriteString(fmt.Sprintf("%-12s %10d %14s %14s\n", - bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost))) + fmt.Fprintf(&builder, "%-12s %10d %14s %14s\n", + bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost)) } - builder.WriteString(fmt.Sprintf("\n%-12s %10d %14s %14s\n", - "total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost))) + fmt.Fprintf(&builder, "\n%-12s %10d %14s %14s\n", + "total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost)) - builder.WriteString(fmt.Sprintf("\nnet LOC (estimate): +%d / -%d = %d\n", - insertions, deletions, report.NetLOC)) + fmt.Fprintf(&builder, "\nnet LOC (estimate): +%d / -%d = %d\n", + insertions, deletions, report.NetLOC) if report.NetLOCPositive { - builder.WriteString(fmt.Sprintf("tokens per net LOC: %.1f\n", report.TokensPerNetLOC)) - builder.WriteString(fmt.Sprintf("est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC))) + fmt.Fprintf(&builder, "tokens per net LOC: %.1f\n", report.TokensPerNetLOC) + fmt.Fprintf(&builder, "est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC)) } else { builder.WriteString("tokens per net LOC: n/a (net LOC <= 0)\n") builder.WriteString("est. cost per net LOC: n/a (net LOC <= 0)\n") diff --git a/internal/dictation/transcriber_deepgram.go b/internal/dictation/transcriber_deepgram.go index b8b9319a..97202f9b 100644 --- a/internal/dictation/transcriber_deepgram.go +++ b/internal/dictation/transcriber_deepgram.go @@ -100,7 +100,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha } default: } - return compose(), fmt.Errorf("Deepgram stream error: %w", err) + return compose(), fmt.Errorf("deepgram stream error: %w", err) } if typ != websocket.MessageText { continue diff --git a/internal/imageinput/pdf_test.go b/internal/imageinput/pdf_test.go index 72d61d08..614250ab 100644 --- a/internal/imageinput/pdf_test.go +++ b/internal/imageinput/pdf_test.go @@ -48,7 +48,7 @@ func buildMinimalPDF(text string) []byte { buf.WriteString("0 " + strconv.Itoa(len(offsets)+1) + "\n") buf.WriteString("0000000000 65535 f \n") for _, off := range offsets { - buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off)) + fmt.Fprintf(&buf, "%010d 00000 n \n", off) } buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\n") buf.WriteString("startxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n") @@ -277,7 +277,7 @@ func buildEmptyTextPDF() []byte { buf.WriteString("0 " + strconv.Itoa(len(offsets)+1) + "\n") buf.WriteString("0000000000 65535 f \n") for _, off := range offsets { - buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off)) + fmt.Fprintf(&buf, "%010d 00000 n \n", off) } buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\n") buf.WriteString("startxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n") diff --git a/internal/lsp/navigate_test.go b/internal/lsp/navigate_test.go index 2976a0e8..81683035 100644 --- a/internal/lsp/navigate_test.go +++ b/internal/lsp/navigate_test.go @@ -1,6 +1,7 @@ package lsp import ( + "context" "encoding/json" "testing" ) @@ -64,7 +65,7 @@ func TestDecodeSymbols(t *testing.T) { func TestNavigateUnknownOp(t *testing.T) { m := NewManager(t.TempDir()) - _, _, ok, err := m.Navigate(nil, NavRequest{Op: "bogus", Path: "x.go"}) + _, _, ok, err := m.Navigate(context.Background(), NavRequest{Op: "bogus", Path: "x.go"}) if err == nil { t.Fatal("expected an error for an unknown nav op") } @@ -76,7 +77,7 @@ func TestNavigateUnknownOp(t *testing.T) { func TestNavigateUnsupportedExtensionDegrades(t *testing.T) { // A file type with no configured server degrades to ok=false, no error. m := NewManager(t.TempDir()) - _, _, ok, err := m.Navigate(nil, NavRequest{Op: NavDefinition, Path: "notes.unknownext", Line: 1, Character: 1}) + _, _, ok, err := m.Navigate(context.Background(), NavRequest{Op: NavDefinition, Path: "notes.unknownext", Line: 1, Character: 1}) if err != nil { t.Fatalf("unsupported extension should not error, got %v", err) } diff --git a/internal/plugins/root_lock_windows.go b/internal/plugins/root_lock_windows.go index ebdfc9cd..2af7d7aa 100644 --- a/internal/plugins/root_lock_windows.go +++ b/internal/plugins/root_lock_windows.go @@ -50,22 +50,28 @@ func recoverStalePluginRootLock(lockPath string) bool { if err != nil { return errors.Is(err, os.ErrNotExist) } - if time.Since(info.ModTime()) < pluginRootLockStaleAge { - return false - } data, err := os.ReadFile(lockPath) if err == nil { pid, _ := strconv.Atoi(strings.TrimSpace(string(data))) - if pid > 0 && processAlive(pid) { - return false + if pid > 0 { + if processAlive(pid) { + return false + } + return os.Remove(lockPath) == nil } } + if time.Since(info.ModTime()) < pluginRootLockStaleAge { + return false + } return os.Remove(lockPath) == nil } func processAlive(pid int) bool { handle, err := syscall.OpenProcess(processQueryLimitedInformation, false, uint32(pid)) if err != nil { + if errors.Is(err, os.ErrPermission) { + return true + } return false } defer syscall.CloseHandle(handle) diff --git a/internal/providerhealth/providerhealth.go b/internal/providerhealth/providerhealth.go index fe375a43..b213a9d8 100644 --- a/internal/providerhealth/providerhealth.go +++ b/internal/providerhealth/providerhealth.go @@ -391,7 +391,7 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"} } if addr, err := netip.ParseAddr(normalized); err == nil { - if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) { + if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) { return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason} } return nil @@ -407,7 +407,7 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a return endpointSafetyError{message: "provider connectivity host resolved to no addresses"} } for _, addr := range addrs { - if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) { + if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) { return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason} } } @@ -529,7 +529,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex return nil, err } if addr, parseErr := netip.ParseAddr(host); parseErr == nil { - if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) { + if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) { return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason} } return dialer.DialContext(ctx, network, address) @@ -542,7 +542,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"} } for _, addr := range addrs { - if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) { + if reason := blockedAddrReason(addr); reason != "" && (!allowLoopbackOrPrivate || (!addr.IsLoopback() && !addr.IsPrivate())) { return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason} } } diff --git a/internal/provideronboarding/localruntime_test.go b/internal/provideronboarding/localruntime_test.go index 29df60f2..8889cc1d 100644 --- a/internal/provideronboarding/localruntime_test.go +++ b/internal/provideronboarding/localruntime_test.go @@ -62,7 +62,7 @@ func TestDetectLocalRuntimesReportsReachableRuntime(t *testing.T) { if !detected[0].Reachable { t.Fatalf("runtime should be reachable: %#v", detected[0]) } - if detected[0].Models == nil || len(detected[0].Models) == 0 || detected[0].Models[0] != "llama3.1" { + if len(detected[0].Models) == 0 || detected[0].Models[0] != "llama3.1" { t.Fatalf("expected discovered model list, got %#v", detected[0].Models) } } diff --git a/internal/repomap/prompt.go b/internal/repomap/prompt.go index 42390a2f..f92c6438 100644 --- a/internal/repomap/prompt.go +++ b/internal/repomap/prompt.go @@ -188,7 +188,7 @@ func writePromptLine(builder *strings.Builder, format string, args ...any) { if builder.Len() > 0 { builder.WriteByte('\n') } - builder.WriteString(fmt.Sprintf(format, args...)) + fmt.Fprintf(builder, format, args...) } func clampBudget(text string, budget int) string { diff --git a/internal/sandbox/adapters.go b/internal/sandbox/adapters.go index 41f5fef1..24e01d2b 100644 --- a/internal/sandbox/adapters.go +++ b/internal/sandbox/adapters.go @@ -225,7 +225,7 @@ func (backend Backend) SandboxEnvMarkers(policy Policy) []string { if policy.Mode == ModeDisabled { return nil } - if !(backend.CommandWrapping && backend.Available) && backend.Name != BackendWSL { + if (!backend.CommandWrapping || !backend.Available) && backend.Name != BackendWSL { return nil } name := backend.Name diff --git a/internal/sandbox/command_prefix.go b/internal/sandbox/command_prefix.go index e9f402ad..53a4efd4 100644 --- a/internal/sandbox/command_prefix.go +++ b/internal/sandbox/command_prefix.go @@ -153,10 +153,7 @@ func unsafeCommandPrefix(prefix []string) bool { return true } } - if unsafeCommandPrefixLauncher(prefix[0]) { - return true - } - return false + return unsafeCommandPrefixLauncher(prefix[0]) } func unsafeCommandPrefixPart(part string) bool { diff --git a/internal/sandbox/engine.go b/internal/sandbox/engine.go index 162bd96e..d845083f 100644 --- a/internal/sandbox/engine.go +++ b/internal/sandbox/engine.go @@ -228,7 +228,7 @@ func (engine *Engine) shellSandboxActive(policy Policy) bool { return false } backend := engine.backend - if !(backend.Available && backend.Executable != "" && backend.CommandWrapping && backend.NativeIsolation) { + if !backend.Available || backend.Executable == "" || !backend.CommandWrapping || !backend.NativeIsolation { return false } // On Windows the command is only actually wrapped once `zero sandbox setup` diff --git a/internal/sandbox/landlock_other.go b/internal/sandbox/landlock_other.go index ec221dfb..c3878ee2 100644 --- a/internal/sandbox/landlock_other.go +++ b/internal/sandbox/landlock_other.go @@ -4,7 +4,7 @@ package sandbox import "errors" -var ErrLandlockUnsupported = errors.New("Landlock is only supported on Linux") +var ErrLandlockUnsupported = errors.New("landlock is only supported on Linux") func ApplyLandlockFilesystemProfile(profile PermissionProfile, cwd string) error { return ErrLandlockUnsupported diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 4bcf8380..fe214538 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -361,17 +361,6 @@ func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy) } } -func existingBubblewrapMounts() []string { - candidates := []string{"/bin", "/usr", "/lib", "/lib64", "/sbin", "/etc"} - mounts := []string{} - for _, candidate := range candidates { - if _, err := os.Stat(candidate); err == nil { - mounts = append(mounts, candidate) - } - } - return mounts -} - func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string) []string { return sandboxEnvironmentForCommand(nil, policy, backend, workspaceRoot) } @@ -764,13 +753,6 @@ func writeRootCarveoutDenyRules(fs FileSystemPolicy) []string { return out } -// denyWriteRules returns seatbelt deny clauses for the policy's resolved -// DenyWrite paths: a (subpath ...) clause for a directory, a (literal ...) clause -// for a single file. Empty when DenyWrite is unset. -func denyWriteRules(policy Policy) []string { - return denyWriteRulesFromPaths(resolvePolicyPaths(policy.DenyWrite)) -} - func denyWriteRulesFromPaths(paths []string) []string { return denySeatbeltPathRules("file-write*", paths) } @@ -798,11 +780,6 @@ func denySeatbeltPathRules(action string, paths []string) []string { return out } -// networkRuleFor returns the seatbelt network clause for a policy. -func networkRuleFor(policy Policy) string { - return networkRuleForProfile(NetworkPolicy{Mode: policy.Network}) -} - func networkRuleForProfile(network NetworkPolicy) string { switch network.Mode { case NetworkAllow: diff --git a/internal/sandbox/windows_network.go b/internal/sandbox/windows_network.go index 55fb419b..322e4d1e 100644 --- a/internal/sandbox/windows_network.go +++ b/internal/sandbox/windows_network.go @@ -10,7 +10,7 @@ import ( "strings" ) -var ErrWindowsNetworkEnforcementUnavailable = errors.New("Windows sandbox network enforcement is not available") +var ErrWindowsNetworkEnforcementUnavailable = errors.New("windows sandbox network enforcement is not available") const ( windowsWFPProviderKey = "2e31d31c-3948-4753-9117-e5d1a6496f41" diff --git a/internal/specialist/exec.go b/internal/specialist/exec.go index 6dac1d25..70236054 100644 --- a/internal/specialist/exec.go +++ b/internal/specialist/exec.go @@ -827,7 +827,7 @@ func runChildProcess(ctx context.Context, binaryPath string, args []string, prog // ExitCode() is -1 when the child was terminated by a signal rather // than exiting. ProcessState.String() is the portable description // (e.g. "signal: killed") — capture it so the failure isn't opaque. - signalDesc = exitErr.ProcessState.String() + signalDesc = exitErr.String() } } else { return ChildRunResult{Events: events, Stderr: stderr.String(), ExitCode: -1, Started: started}, fmt.Errorf("run specialist child: %w", err) diff --git a/internal/tui/command_center.go b/internal/tui/command_center.go index 2f5175a0..a2e9fd6c 100644 --- a/internal/tui/command_center.go +++ b/internal/tui/command_center.go @@ -540,7 +540,7 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string, // and a stored OAuth login (e.g. ChatGPT) is a credential too — the profile stays // keyless on purpose so newProvider attaches the bearer resolver + login key. if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" && - !(hasDescriptor && descriptor.Local) && !oauthLoginAvailable(target) { + (!hasDescriptor || !descriptor.Local) && !oauthLoginAvailable(target) { return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", false, nil } next, err := m.newProvider(target) diff --git a/internal/tui/files_git_sweep_test.go b/internal/tui/files_git_sweep_test.go index 484392e3..05a40e35 100644 --- a/internal/tui/files_git_sweep_test.go +++ b/internal/tui/files_git_sweep_test.go @@ -1,6 +1,7 @@ package tui import ( + "context" "os" "os/exec" "path/filepath" @@ -192,7 +193,7 @@ func TestGitSweepCmdAgainstRealRepo(t *testing.T) { run("commit", "-q", "-m", "seed") write("pre-existing.txt", "dirt\n") // dirty BEFORE the TUI "opens" - baseline := gitSweepCmd(nil, dir, true)().(gitSweepMsg) + baseline := gitSweepCmd(context.Background(), dir, true)().(gitSweepMsg) if !baseline.ok || len(baseline.files) != 1 || baseline.files[0].path != "pre-existing.txt" { t.Fatalf("baseline should see only the pre-existing dirt: %+v", baseline) } @@ -201,7 +202,7 @@ func TestGitSweepCmdAgainstRealRepo(t *testing.T) { write("scaffolded.txt", "hello\n") write("tracked.txt", "one\ntwo\nthree\nfour\n") - sweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg) + sweep := gitSweepCmd(context.Background(), dir, false)().(gitSweepMsg) if !sweep.ok { t.Fatal("sweep failed") } @@ -224,7 +225,7 @@ func TestGitSweepCmdAgainstRealRepo(t *testing.T) { run("commit", "-q", "-m", "second") run("mv", "tracked.txt", "renamed.txt") write("renamed.txt", "one\ntwo\nthree\nfour\nfive\n") - renameSweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg) + renameSweep := gitSweepCmd(context.Background(), dir, false)().(gitSweepMsg) if !renameSweep.ok { t.Fatal("rename sweep failed") } @@ -237,7 +238,7 @@ func TestGitSweepCmdAgainstRealRepo(t *testing.T) { } // Not-a-repo → ok=false (the sweep latches off). - if msg := gitSweepCmd(nil, t.TempDir(), false)().(gitSweepMsg); msg.ok { + if msg := gitSweepCmd(context.Background(), t.TempDir(), false)().(gitSweepMsg); msg.ok { t.Fatal("a non-repo should report ok=false") } } diff --git a/internal/tui/flush.go b/internal/tui/flush.go index e47740db..c04ef475 100644 --- a/internal/tui/flush.go +++ b/internal/tui/flush.go @@ -46,7 +46,7 @@ func (m model) settledRow(row transcriptRow, rc rowContext) bool { if row.id != "" && rc.resolved[rcKey(row.runID, row.id)] { return true } - return !(m.pending && row.runID != 0 && row.runID == m.activeRunID) + return !m.pending || row.runID == 0 || row.runID != m.activeRunID case rowPermission: event := row.permission if event == nil || event.ToolCallID == "" || event.Action != agent.PermissionActionPrompt { @@ -57,7 +57,7 @@ func (m model) settledRow(row transcriptRow, rc rowContext) bool { } // An undecided prompt renders live (with the modal card) until its // decision lands or its run ends. - return !(m.pending && row.runID != 0 && row.runID == m.activeRunID) + return !m.pending || row.runID == 0 || row.runID != m.activeRunID default: // User/system/error/assistant/ask-user rows are appended in final form; // welcome rows render nothing and settle trivially. diff --git a/internal/tui/image_attach_test.go b/internal/tui/image_attach_test.go index d4754028..33a78b77 100644 --- a/internal/tui/image_attach_test.go +++ b/internal/tui/image_attach_test.go @@ -290,7 +290,7 @@ func writeTestPDF(t *testing.T, dir, name, text string) string { xrefStart := buf.Len() buf.WriteString("xref\n0 " + strconv.Itoa(len(offsets)+1) + "\n0000000000 65535 f \n") for _, off := range offsets { - buf.WriteString(fmt.Sprintf("%010d 00000 n \n", off)) + fmt.Fprintf(&buf, "%010d 00000 n \n", off) } buf.WriteString("trailer\n<< /Size " + strconv.Itoa(len(offsets)+1) + " /Root 1 0 R >>\nstartxref\n" + strconv.Itoa(xrefStart) + "\n%%EOF\n") diff --git a/internal/tui/loop.go b/internal/tui/loop.go index 0d0ea739..03fb5803 100644 --- a/internal/tui/loop.go +++ b/internal/tui/loop.go @@ -594,8 +594,8 @@ func (m model) loopListText() string { when = "due" } } - b.WriteString(fmt.Sprintf(" %s · %s · iter %d · %s · %s\n", - l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt))) + fmt.Fprintf(&b, " %s · %s · iter %d · %s · %s\n", + l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt)) } b.WriteString("Stop with /loop stop or /loop stop all.") return b.String() diff --git a/internal/tui/model.go b/internal/tui/model.go index be6697d8..e605ba36 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2997,10 +2997,6 @@ func (f transcriptFrameLayout) footerLineRect(line int) tuiRect { } } -func (m model) scrollableTranscriptView(header string, body string, footer string, width int, overlay string) string { - return m.scrollableTranscriptLayoutView(header, transcriptBodyLayout{lines: viewLines(body)}, footer, width, overlay) -} - func (m model) scrollableTranscriptLayoutView(header string, body transcriptBodyLayout, footer string, width int, overlay string) string { frame := m.scrollableTranscriptFrame(header, footer) window := transcriptViewportForLayout(body, frame, m.chatScrollOffset).window() diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index e9fd5de2..64f6de5e 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1394,8 +1394,8 @@ func TestBeginRunPreparesRunCompletionWarningEachTurn(t *testing.T) { PrepareRunCompletionWarning: func() { prepares++ }, }) - m = m.beginRun(nil) - m = m.beginRun(nil) + m.beginRun(nil) + m.beginRun(nil) if prepares != 2 { t.Fatalf("PrepareRunCompletionWarning called %d times, want once per run", prepares) diff --git a/internal/tui/onboarding.go b/internal/tui/onboarding.go index fdc98535..20c94ed6 100644 --- a/internal/tui/onboarding.go +++ b/internal/tui/onboarding.go @@ -184,20 +184,22 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case keyIs(msg, tea.KeyUp): - if m.setup.stage == setupStageMethod { + switch m.setup.stage { + case setupStageMethod: m.moveSetupMethod(-1) - } else if m.setup.stage == setupStageProvider { + case setupStageProvider: m.moveSetupProvider(-1) - } else if m.setup.stage == setupStageModel { + case setupStageModel: m.moveSetupModel(-1) } return m, nil case keyIs(msg, tea.KeyDown): - if m.setup.stage == setupStageMethod { + switch m.setup.stage { + case setupStageMethod: m.moveSetupMethod(1) - } else if m.setup.stage == setupStageProvider { + case setupStageProvider: m.moveSetupProvider(1) - } else if m.setup.stage == setupStageModel { + case setupStageModel: m.moveSetupModel(1) } return m, nil @@ -245,15 +247,17 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "q": return m, tea.Quit case "k": - if m.setup.stage == setupStageProvider { + switch m.setup.stage { + case setupStageProvider: m.moveSetupProvider(-1) - } else if m.setup.stage == setupStageModel { + case setupStageModel: m.moveSetupModel(-1) } case "j": - if m.setup.stage == setupStageProvider { + switch m.setup.stage { + case setupStageProvider: m.moveSetupProvider(1) - } else if m.setup.stage == setupStageModel { + case setupStageModel: m.moveSetupModel(1) } } @@ -322,16 +326,18 @@ func (m model) handleSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { switch { case mouseWheelUp(msg): m.clearMouseSelection() - if m.setup.stage == setupStageProvider { + switch m.setup.stage { + case setupStageProvider: m.moveSetupProvider(-1) - } else if m.setup.stage == setupStageModel { + case setupStageModel: m.moveSetupModel(-1) } case mouseWheelDown(msg): m.clearMouseSelection() - if m.setup.stage == setupStageProvider { + switch m.setup.stage { + case setupStageProvider: m.moveSetupProvider(1) - } else if m.setup.stage == setupStageModel { + case setupStageModel: m.moveSetupModel(1) } } diff --git a/internal/tui/provider_wizard_test.go b/internal/tui/provider_wizard_test.go index 6026a228..9200857b 100644 --- a/internal/tui/provider_wizard_test.go +++ b/internal/tui/provider_wizard_test.go @@ -683,8 +683,7 @@ func TestProviderWizardPersistsPastedKeyToUserConfig(t *testing.T) { next = finishProviderWizardModelDiscoveryForTest(t, next) updated, _ = next.Update(testKey(tea.KeyEnter)) next = updated.(model) - updated, _ = next.Update(testKey(tea.KeyEnter)) - next = updated.(model) + next.Update(testKey(tea.KeyEnter)) if captured.APIKey != secret { t.Fatalf("captured APIKey = %q, want pasted secret", captured.APIKey) diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index 4569a255..381d4862 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -1436,7 +1436,7 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card // scrollback clean. Skipped for: the uncapped detailed view (opts.bodyCap==0), // diff tools whose body must stay reviewable, and short output. collapsedFooter := "" - if opts.bodyCap > 0 && !toolCardAlwaysExpands(name) && !(!failed && (isExploreTool(name) || isLocalControlTool(name))) { + if opts.bodyCap > 0 && !toolCardAlwaysExpands(name) && (failed || (!isExploreTool(name) && !isLocalControlTool(name))) { collapsedFooter = collapsedToolFooter(row.detail) } if collapsedFooter != "" && !row.expanded { diff --git a/internal/tui/specialist_card.go b/internal/tui/specialist_card.go index cf4624fb..8bfefb3c 100644 --- a/internal/tui/specialist_card.go +++ b/internal/tui/specialist_card.go @@ -414,10 +414,10 @@ func toolCallSummary(event streamjson.Event) string { case "write_stdin": sessionID := toolCallIntArg(args, "session_id") chars, _ := args["chars"].(string) - switch { - case chars == "": + switch chars { + case "": return fmt.Sprintf("poll session %d", sessionID) - case chars == "\x03": + case "\x03": return fmt.Sprintf("interrupt session %d", sessionID) default: return fmt.Sprintf("send input to session %d", sessionID) diff --git a/internal/tui/streaming_fade.go b/internal/tui/streaming_fade.go index 676ffb9a..e8aa1ac7 100644 --- a/internal/tui/streaming_fade.go +++ b/internal/tui/streaming_fade.go @@ -276,14 +276,3 @@ func (m model) styleStreamingLine(line string, visualIndex, visualCount int) str bornAt := streamingLineBornAt(visualIndex, visualCount, m.lineAges, m.lastStreamActivity) return ageDimLine(line, bornAt, m.now(), zeroTheme.ink) } - -// ensureAgeTickReschedule is a small helper used after a fade-state change -// to start the tick if it's not already running. The age-tick case -// short-circuits when fadeActive is false, so calling this on a no-op -// transition (e.g. a 0-byte delta) is safe. -func (m model) ensureAgeTickReschedule() tea.Cmd { - if !m.fadeActive { - return nil - } - return streamingFadeTick() -} diff --git a/internal/tui/transcript_selection.go b/internal/tui/transcript_selection.go index fb3570fb..8c469635 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -1275,11 +1275,6 @@ func (m model) transcriptViewportStart(body string, width int) (int, int, int) { return transcriptViewportStartForFrame(body, frame, m.chatScrollOffset) } -func transcriptViewportStartForLayout(layout transcriptBodyLayout, frame transcriptFrameLayout, scrollOffset int) (int, int, int) { - window := transcriptViewportForLayout(layout, frame, scrollOffset).window() - return window.start, window.height, frame.bodyRect.y -} - func transcriptViewportStartForFrame(body string, frame transcriptFrameLayout, scrollOffset int) (int, int, int) { window := transcriptViewportForBody(body, frame, scrollOffset).window() return window.start, window.height, frame.bodyRect.y diff --git a/internal/update/apply.go b/internal/update/apply.go index 29736de1..4f97dd45 100644 --- a/internal/update/apply.go +++ b/internal/update/apply.go @@ -162,10 +162,11 @@ func applyStandaloneUpdate(ctx context.Context, result Result, executablePath st binaryName := "zero" optionalBinaries := linuxOptionalBinaries - if runtime.GOOS == "windows" { + switch runtime.GOOS { + case "windows": binaryName = "zero.exe" optionalBinaries = windowsOptionalBinaries - } else if runtime.GOOS == "darwin" { + case "darwin": optionalBinaries = nil } diff --git a/internal/update/apply_test.go b/internal/update/apply_test.go index 37927349..80e9d9f0 100644 --- a/internal/update/apply_test.go +++ b/internal/update/apply_test.go @@ -151,10 +151,11 @@ func TestApplyStandaloneUpdateReplacesBinary(t *testing.T) { func TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails(t *testing.T) { binaryName := "zero" optionalName := "zero-seccomp" - if runtime.GOOS == "windows" { + switch runtime.GOOS { + case "windows": binaryName = "zero.exe" optionalName = "zero-windows-command-runner.exe" - } else if runtime.GOOS == "darwin" { + case "darwin": t.Skip("macOS ships no optional helper binaries to refresh") } diff --git a/internal/update/update.go b/internal/update/update.go index cd83577f..dbd685ee 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -367,7 +367,7 @@ func releasePlatform(goos string) (string, error) { // self-updating release asset. Point users at `npm update` rather // than a source rebuild, since that's the documented Termux // install/upgrade path and doesn't require a Go toolchain. - return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows). Your build is the current version of record. Upgrade with `npm update -g @gitlawb/zero` to get the latest.", goos) + return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows); your build is the current version of record; upgrade with `npm update -g @gitlawb/zero` to get the latest", goos) } } diff --git a/internal/usercommands/usercommands.go b/internal/usercommands/usercommands.go index 3cb969ca..f043e1bb 100644 --- a/internal/usercommands/usercommands.go +++ b/internal/usercommands/usercommands.go @@ -109,7 +109,7 @@ func validCommandName(name string) bool { return false } for _, r := range name { - if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '-' { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { return false } } From 98c1f79da90979f889abeecd5c55cdcc3111566f Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Wed, 15 Jul 2026 20:21:32 +0530 Subject: [PATCH 10/10] fix: handle missing recovered plugin locks --- internal/plugins/root_lock_windows.go | 11 +++++++++-- internal/plugins/root_lock_windows_test.go | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 internal/plugins/root_lock_windows_test.go diff --git a/internal/plugins/root_lock_windows.go b/internal/plugins/root_lock_windows.go index 2af7d7aa..0df20652 100644 --- a/internal/plugins/root_lock_windows.go +++ b/internal/plugins/root_lock_windows.go @@ -57,13 +57,20 @@ func recoverStalePluginRootLock(lockPath string) bool { if processAlive(pid) { return false } - return os.Remove(lockPath) == nil + return removeRecoveredPluginRootLock(lockPath) } + } else if errors.Is(err, os.ErrNotExist) { + return true } if time.Since(info.ModTime()) < pluginRootLockStaleAge { return false } - return os.Remove(lockPath) == nil + return removeRecoveredPluginRootLock(lockPath) +} + +func removeRecoveredPluginRootLock(lockPath string) bool { + err := os.Remove(lockPath) + return err == nil || errors.Is(err, os.ErrNotExist) } func processAlive(pid int) bool { diff --git a/internal/plugins/root_lock_windows_test.go b/internal/plugins/root_lock_windows_test.go new file mode 100644 index 00000000..11c05381 --- /dev/null +++ b/internal/plugins/root_lock_windows_test.go @@ -0,0 +1,15 @@ +//go:build windows + +package plugins + +import ( + "path/filepath" + "testing" +) + +func TestRemoveRecoveredPluginRootLockTreatsMissingLockAsRecovered(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), pluginRootLockFileName) + if !removeRecoveredPluginRootLock(lockPath) { + t.Fatal("missing lock should be treated as recovered") + } +}