From 9777a682db007ddf7c58074dd1c9b991d9504599 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:42:49 +0000 Subject: [PATCH 1/4] Initial plan From 92005856166c0eb2c64384d3bab6bede2b497e11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:51:25 +0000 Subject: [PATCH 2/4] Fix PluginManifest to handle legacy object-style capabilities in plugin.json Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- plugin/manifest.go | 72 ++++++++++++++++++++++++++++ plugin/manifest_test.go | 102 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/plugin/manifest.go b/plugin/manifest.go index 13ef66bc..994c88ef 100644 --- a/plugin/manifest.go +++ b/plugin/manifest.go @@ -74,6 +74,78 @@ type CapabilityDecl struct { Priority int `json:"priority,omitempty" yaml:"priority,omitempty"` } +// legacyCapabilitiesObject represents the alternative object-style capabilities +// field used by some external plugin manifests (e.g. workflow-plugin-authz) where +// capabilities is a single JSON object instead of an array of CapabilityDecl. +type legacyCapabilitiesObject struct { + ConfigProvider bool `json:"configProvider"` + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + WorkflowTypes []string `json:"workflowTypes"` +} + +// UnmarshalJSON implements custom JSON decoding for PluginManifest so that the +// "capabilities" field can be either: +// - an array of CapabilityDecl objects (the canonical engine format), or +// - a plain JSON object with moduleTypes/stepTypes/triggerTypes keys +// (the legacy external-plugin format used by e.g. workflow-plugin-authz). +// +// In the legacy-object case the type lists are merged into the manifest's +// top-level ModuleTypes/StepTypes/TriggerTypes fields so the information is +// not lost, and Capabilities is left nil. +func (m *PluginManifest) UnmarshalJSON(data []byte) error { + // Use a type alias to avoid infinite recursion through UnmarshalJSON. + type Alias PluginManifest + type rawManifest struct { + Alias + Capabilities json.RawMessage `json:"capabilities,omitempty"` + } + + var raw rawManifest + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *m = PluginManifest(raw.Alias) + + if len(raw.Capabilities) == 0 { + return nil + } + + // Try the canonical array-of-CapabilityDecl format first. + var caps []CapabilityDecl + if err := json.Unmarshal(raw.Capabilities, &caps); err == nil { + m.Capabilities = caps + return nil + } + + // Fall back to legacy object format – extract type lists into top-level fields. + var legacy legacyCapabilitiesObject + if err := json.Unmarshal(raw.Capabilities, &legacy); err == nil { + m.ModuleTypes = appendUnique(m.ModuleTypes, legacy.ModuleTypes...) + m.StepTypes = appendUnique(m.StepTypes, legacy.StepTypes...) + m.TriggerTypes = appendUnique(m.TriggerTypes, legacy.TriggerTypes...) + m.WorkflowTypes = appendUnique(m.WorkflowTypes, legacy.WorkflowTypes...) + } + // Unknown capability format is silently ignored – capabilities is left nil. + return nil +} + +// appendUnique appends values to dst, skipping any that are already present. +func appendUnique(dst []string, values ...string) []string { + existing := make(map[string]struct{}, len(dst)) + for _, v := range dst { + existing[v] = struct{}{} + } + for _, v := range values { + if _, ok := existing[v]; !ok { + dst = append(dst, v) + existing[v] = struct{}{} + } + } + return dst +} + // Dependency declares a versioned dependency on another plugin. type Dependency struct { Name string `json:"name" yaml:"name"` diff --git a/plugin/manifest_test.go b/plugin/manifest_test.go index f84af625..8323cc54 100644 --- a/plugin/manifest_test.go +++ b/plugin/manifest_test.go @@ -425,3 +425,105 @@ func TestManifestEngineFieldsLoadFromFile(t *testing.T) { t.Errorf("Capabilities = %v, want [{storage provider 5}]", loaded.Capabilities) } } + +// TestManifestLegacyCapabilitiesObject verifies that a plugin.json whose +// "capabilities" field is a plain JSON object (the format used by external +// plugins such as workflow-plugin-authz) is parsed without error and that the +// type lists nested inside the object are promoted to the manifest's top-level +// ModuleTypes/StepTypes/TriggerTypes fields. +func TestManifestLegacyCapabilitiesObject(t *testing.T) { + const legacyJSON = `{ + "name": "workflow-plugin-authz", + "version": "1.0.0", + "description": "RBAC authorization plugin using Casbin", + "author": "GoCodeAlone", + "license": "MIT", + "type": "external", + "tier": "core", + "minEngineVersion": "0.3.11", + "keywords": ["authz", "rbac", "casbin", "authorization", "policy"], + "homepage": "https://github.com/GoCodeAlone/workflow-plugin-authz", + "repository": "https://github.com/GoCodeAlone/workflow-plugin-authz", + "capabilities": { + "configProvider": false, + "moduleTypes": ["authz.casbin"], + "stepTypes": [ + "step.authz_check_casbin", + "step.authz_add_policy", + "step.authz_remove_policy", + "step.authz_role_assign" + ], + "triggerTypes": [] + } + }` + + var m PluginManifest + if err := json.Unmarshal([]byte(legacyJSON), &m); err != nil { + t.Fatalf("unexpected unmarshal error for legacy capabilities object: %v", err) + } + + // Capabilities array should be nil / empty – the object format has no CapabilityDecl items. + if len(m.Capabilities) != 0 { + t.Errorf("Capabilities = %v, want empty", m.Capabilities) + } + + // moduleTypes from the nested object should be promoted to the top level. + if len(m.ModuleTypes) != 1 || m.ModuleTypes[0] != "authz.casbin" { + t.Errorf("ModuleTypes = %v, want [authz.casbin]", m.ModuleTypes) + } + + // stepTypes should be promoted. + wantSteps := []string{ + "step.authz_check_casbin", + "step.authz_add_policy", + "step.authz_remove_policy", + "step.authz_role_assign", + } + if len(m.StepTypes) != len(wantSteps) { + t.Errorf("StepTypes len = %d, want %d; got %v", len(m.StepTypes), len(wantSteps), m.StepTypes) + } else { + for i, want := range wantSteps { + if m.StepTypes[i] != want { + t.Errorf("StepTypes[%d] = %q, want %q", i, m.StepTypes[i], want) + } + } + } + + // triggerTypes is an empty array – TriggerTypes should remain nil/empty. + if len(m.TriggerTypes) != 0 { + t.Errorf("TriggerTypes = %v, want empty", m.TriggerTypes) + } +} + +// TestManifestLegacyCapabilitiesObjectFile verifies that LoadManifest succeeds +// for a plugin.json that uses the legacy object-style capabilities field. +func TestManifestLegacyCapabilitiesObjectFile(t *testing.T) { + const legacyJSON = `{ + "name": "workflow-plugin-authz", + "version": "1.0.0", + "description": "RBAC authorization plugin", + "author": "GoCodeAlone", + "capabilities": { + "moduleTypes": ["authz.casbin"], + "stepTypes": ["step.authz_check"], + "triggerTypes": [] + } + }` + + dir := t.TempDir() + path := filepath.Join(dir, "plugin.json") + if err := os.WriteFile(path, []byte(legacyJSON), 0644); err != nil { + t.Fatal(err) + } + + m, err := LoadManifest(path) + if err != nil { + t.Fatalf("LoadManifest error: %v", err) + } + if len(m.ModuleTypes) != 1 || m.ModuleTypes[0] != "authz.casbin" { + t.Errorf("ModuleTypes = %v, want [authz.casbin]", m.ModuleTypes) + } + if len(m.StepTypes) != 1 || m.StepTypes[0] != "step.authz_check" { + t.Errorf("StepTypes = %v, want [step.authz_check]", m.StepTypes) + } +} From 5932d163b5e4dbe98a61253bdfe36d66603254d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:53:42 +0000 Subject: [PATCH 3/4] Return error for unsupported capabilities JSON type; add negative test Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- plugin/manifest.go | 43 ++++++++++++++++++++++++++++++++--------- plugin/manifest_test.go | 32 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/plugin/manifest.go b/plugin/manifest.go index 994c88ef..1bc9c14d 100644 --- a/plugin/manifest.go +++ b/plugin/manifest.go @@ -112,25 +112,50 @@ func (m *PluginManifest) UnmarshalJSON(data []byte) error { return nil } - // Try the canonical array-of-CapabilityDecl format first. - var caps []CapabilityDecl - if err := json.Unmarshal(raw.Capabilities, &caps); err == nil { + // Peek at the first non-whitespace byte to decide which branch to take. + // This avoids silently ignoring genuinely invalid values. + firstByte := firstNonSpace(raw.Capabilities) + + switch firstByte { + case 0, 'n': + // Empty or JSON null – treat as absent. + + case '[': + // Canonical array-of-CapabilityDecl format. + var caps []CapabilityDecl + if err := json.Unmarshal(raw.Capabilities, &caps); err != nil { + return fmt.Errorf("capabilities: %w", err) + } m.Capabilities = caps - return nil - } - // Fall back to legacy object format – extract type lists into top-level fields. - var legacy legacyCapabilitiesObject - if err := json.Unmarshal(raw.Capabilities, &legacy); err == nil { + case '{': + // Legacy object format – extract type lists into top-level fields. + var legacy legacyCapabilitiesObject + if err := json.Unmarshal(raw.Capabilities, &legacy); err != nil { + return fmt.Errorf("capabilities: %w", err) + } m.ModuleTypes = appendUnique(m.ModuleTypes, legacy.ModuleTypes...) m.StepTypes = appendUnique(m.StepTypes, legacy.StepTypes...) m.TriggerTypes = appendUnique(m.TriggerTypes, legacy.TriggerTypes...) m.WorkflowTypes = appendUnique(m.WorkflowTypes, legacy.WorkflowTypes...) + + default: + return fmt.Errorf("capabilities: unsupported JSON type (expected array or object, got %q)", string(raw.Capabilities)) } - // Unknown capability format is silently ignored – capabilities is left nil. + return nil } +// firstNonSpace returns the first non-whitespace byte in b, or 0 if b is empty/all-whitespace. +func firstNonSpace(b []byte) byte { + for _, c := range b { + if c != ' ' && c != '\t' && c != '\r' && c != '\n' { + return c + } + } + return 0 +} + // appendUnique appends values to dst, skipping any that are already present. func appendUnique(dst []string, values ...string) []string { existing := make(map[string]struct{}, len(dst)) diff --git a/plugin/manifest_test.go b/plugin/manifest_test.go index 8323cc54..b6755e50 100644 --- a/plugin/manifest_test.go +++ b/plugin/manifest_test.go @@ -527,3 +527,35 @@ func TestManifestLegacyCapabilitiesObjectFile(t *testing.T) { t.Errorf("StepTypes = %v, want [step.authz_check]", m.StepTypes) } } + +// TestManifestCapabilitiesInvalidFormat verifies that a plugin.json whose +// "capabilities" field is neither an array nor an object (e.g. a bare string) +// is rejected with a descriptive error. +func TestManifestCapabilitiesInvalidFormat(t *testing.T) { + cases := []struct { + name string + json string + }{ + { + name: "string value", + json: `{"name":"p","version":"1.0.0","author":"A","description":"D","capabilities":"oops"}`, + }, + { + name: "numeric value", + json: `{"name":"p","version":"1.0.0","author":"A","description":"D","capabilities":42}`, + }, + { + name: "boolean value", + json: `{"name":"p","version":"1.0.0","author":"A","description":"D","capabilities":true}`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var m PluginManifest + err := json.Unmarshal([]byte(tc.json), &m) + if err == nil { + t.Errorf("expected error for capabilities %s, got nil", tc.name) + } + }) + } +} From 62dfb7f5c99142d748b8b744d929cec915f48c75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 02:50:16 +0000 Subject: [PATCH 4/4] Resolve merge conflict: add MinEngineVersion, adopt rawManifest/withRawCaps pattern, move Dependency Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- plugin/manifest.go | 90 +++++++++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 44 deletions(-) diff --git a/plugin/manifest.go b/plugin/manifest.go index 1bc9c14d..aa2fb28b 100644 --- a/plugin/manifest.go +++ b/plugin/manifest.go @@ -65,6 +65,10 @@ type PluginManifest struct { // Config mutability and sample plugin support ConfigMutable bool `json:"configMutable,omitempty" yaml:"configMutable,omitempty"` SampleCategory string `json:"sampleCategory,omitempty" yaml:"sampleCategory,omitempty"` + + // MinEngineVersion declares the minimum engine version required to run this plugin. + // A semver string without the "v" prefix, e.g. "0.3.30". + MinEngineVersion string `json:"minEngineVersion,omitempty" yaml:"minEngineVersion,omitempty"` } // CapabilityDecl declares a capability relationship for a plugin in the manifest. @@ -74,70 +78,74 @@ type CapabilityDecl struct { Priority int `json:"priority,omitempty" yaml:"priority,omitempty"` } -// legacyCapabilitiesObject represents the alternative object-style capabilities -// field used by some external plugin manifests (e.g. workflow-plugin-authz) where -// capabilities is a single JSON object instead of an array of CapabilityDecl. -type legacyCapabilitiesObject struct { - ConfigProvider bool `json:"configProvider"` - ModuleTypes []string `json:"moduleTypes"` - StepTypes []string `json:"stepTypes"` - TriggerTypes []string `json:"triggerTypes"` - WorkflowTypes []string `json:"workflowTypes"` +// Dependency declares a versioned dependency on another plugin. +type Dependency struct { + Name string `json:"name" yaml:"name"` + Constraint string `json:"constraint" yaml:"constraint"` // semver constraint, e.g. ">=1.0.0", "^2.1" } -// UnmarshalJSON implements custom JSON decoding for PluginManifest so that the -// "capabilities" field can be either: -// - an array of CapabilityDecl objects (the canonical engine format), or -// - a plain JSON object with moduleTypes/stepTypes/triggerTypes keys -// (the legacy external-plugin format used by e.g. workflow-plugin-authz). +// UnmarshalJSON implements custom JSON unmarshalling for PluginManifest that +// handles both the canonical capabilities array format and the legacy object +// format used by registry manifests and older plugin.json files. +// +// Legacy format: "capabilities": {"configProvider": bool, "moduleTypes": [...], ...} +// New format: "capabilities": [{"name": "...", "role": "..."}] // -// In the legacy-object case the type lists are merged into the manifest's -// top-level ModuleTypes/StepTypes/TriggerTypes fields so the information is -// not lost, and Capabilities is left nil. +// When the legacy object format is detected, its type lists are merged into the +// top-level ModuleTypes, StepTypes, and TriggerTypes fields so callers always +// find types in a consistent location. Any other JSON type (string, number, +// bool) is rejected with a descriptive error. func (m *PluginManifest) UnmarshalJSON(data []byte) error { - // Use a type alias to avoid infinite recursion through UnmarshalJSON. - type Alias PluginManifest - type rawManifest struct { - Alias + // rawManifest breaks the recursion: it is the same layout as PluginManifest + // but without the custom UnmarshalJSON method. + type rawManifest PluginManifest + // withRawCaps shadows the Capabilities field so we can capture it as raw JSON + // and inspect whether it is an array or object before decoding. + type withRawCaps struct { + rawManifest Capabilities json.RawMessage `json:"capabilities,omitempty"` } - - var raw rawManifest + var raw withRawCaps if err := json.Unmarshal(data, &raw); err != nil { return err } - *m = PluginManifest(raw.Alias) + *m = PluginManifest(raw.rawManifest) + m.Capabilities = nil // captured in raw.Capabilities; reset and repopulate below if len(raw.Capabilities) == 0 { return nil } // Peek at the first non-whitespace byte to decide which branch to take. - // This avoids silently ignoring genuinely invalid values. - firstByte := firstNonSpace(raw.Capabilities) - - switch firstByte { + // This avoids silently ignoring genuinely invalid capability values. + switch firstNonSpace(raw.Capabilities) { case 0, 'n': // Empty or JSON null – treat as absent. case '[': - // Canonical array-of-CapabilityDecl format. + // New format: array of CapabilityDecl. var caps []CapabilityDecl if err := json.Unmarshal(raw.Capabilities, &caps); err != nil { - return fmt.Errorf("capabilities: %w", err) + return fmt.Errorf("invalid capabilities array: %w", err) } m.Capabilities = caps case '{': - // Legacy object format – extract type lists into top-level fields. - var legacy legacyCapabilitiesObject - if err := json.Unmarshal(raw.Capabilities, &legacy); err != nil { - return fmt.Errorf("capabilities: %w", err) + // Legacy format: object with configProvider, moduleTypes, stepTypes, triggerTypes. + // Merge type lists into the top-level fields so callers see them consistently. + var legacyCaps struct { + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + WorkflowTypes []string `json:"workflowTypes"` } - m.ModuleTypes = appendUnique(m.ModuleTypes, legacy.ModuleTypes...) - m.StepTypes = appendUnique(m.StepTypes, legacy.StepTypes...) - m.TriggerTypes = appendUnique(m.TriggerTypes, legacy.TriggerTypes...) - m.WorkflowTypes = appendUnique(m.WorkflowTypes, legacy.WorkflowTypes...) + if err := json.Unmarshal(raw.Capabilities, &legacyCaps); err != nil { + return fmt.Errorf("invalid capabilities object: %w", err) + } + m.ModuleTypes = appendUnique(m.ModuleTypes, legacyCaps.ModuleTypes...) + m.StepTypes = appendUnique(m.StepTypes, legacyCaps.StepTypes...) + m.TriggerTypes = appendUnique(m.TriggerTypes, legacyCaps.TriggerTypes...) + m.WorkflowTypes = appendUnique(m.WorkflowTypes, legacyCaps.WorkflowTypes...) default: return fmt.Errorf("capabilities: unsupported JSON type (expected array or object, got %q)", string(raw.Capabilities)) @@ -171,12 +179,6 @@ func appendUnique(dst []string, values ...string) []string { return dst } -// Dependency declares a versioned dependency on another plugin. -type Dependency struct { - Name string `json:"name" yaml:"name"` - Constraint string `json:"constraint" yaml:"constraint"` // semver constraint, e.g. ">=1.0.0", "^2.1" -} - // Validate checks that a manifest has all required fields and valid semver. func (m *PluginManifest) Validate() error { if m.Name == "" {