From dc7eb8acf73abeb4c5db24bad2488604fbbc81a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:26:09 +0000 Subject: [PATCH 1/3] Initial plan From 396fa7aeeb66caea1bb185eed2834774d27defe9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:37:17 +0000 Subject: [PATCH 2/3] fix: load module/step/trigger types from plugin.json capabilities object in LoadPluginTypesFromDir Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/main_test.go | 33 ++++++++++++++++++++++++ schema/schema.go | 35 ++++++++++++++++++++++--- schema/schema_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/cmd/wfctl/main_test.go b/cmd/wfctl/main_test.go index 855aa2e1..cd187cb7 100644 --- a/cmd/wfctl/main_test.go +++ b/cmd/wfctl/main_test.go @@ -319,3 +319,36 @@ modules: schema.UnregisterModuleType("custom.ext.validate.testonly") }) } + +func TestRunValidatePluginDirCapabilities(t *testing.T) { + // Create a fake external plugin directory with a plugin.json using the + // v0.3.0+ nested "capabilities" object format (as produced by wfctl plugin install). + pluginsDir := t.TempDir() + pluginSubdir := filepath.Join(pluginsDir, "my-ext-plugin-caps") + if err := os.MkdirAll(pluginSubdir, 0755); err != nil { + t.Fatal(err) + } + manifest := `{"name":"my-ext-plugin-caps","version":"1.0.0","type":"external","capabilities":{"configProvider":false,"moduleTypes":["custom.caps.validate.testonly"],"stepTypes":["step.caps_validate_testonly"],"triggerTypes":[]}}` + if err := os.WriteFile(filepath.Join(pluginSubdir, "plugin.json"), []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + // Config using the module type declared in capabilities + dir := t.TempDir() + configContent := "modules:\n - name: caps-mod\n type: custom.caps.validate.testonly\n" + path := writeTestConfig(t, dir, "workflow.yaml", configContent) + + // Without --plugin-dir: should fail (unknown type) + if err := runValidate([]string{path}); err == nil { + t.Fatal("expected error for unknown external module type without --plugin-dir") + } + + // With --plugin-dir: should pass (types from capabilities object are recognized) + if err := runValidate([]string{"--plugin-dir", pluginsDir, path}); err != nil { + t.Errorf("expected valid config with --plugin-dir (capabilities format), got: %v", err) + } + t.Cleanup(func() { + schema.UnregisterModuleType("custom.caps.validate.testonly") + schema.UnregisterModuleType("step.caps_validate_testonly") + }) +} diff --git a/schema/schema.go b/schema/schema.go index 8f3f2670..d52a041e 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -383,11 +383,23 @@ func KnownWorkflowTypes() []string { // pluginManifestTypes holds the type declarations from a plugin.json manifest. // This is a minimal subset of the full plugin manifest to avoid import cycles. +// It supports both the flat format (types at root level) and the v0.3.0+ +// nested capabilities object format. type pluginManifestTypes struct { - ModuleTypes []string `json:"moduleTypes"` - StepTypes []string `json:"stepTypes"` - TriggerTypes []string `json:"triggerTypes"` - WorkflowTypes []string `json:"workflowTypes"` + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + WorkflowTypes []string `json:"workflowTypes"` + Capabilities *pluginManifestCapabilities `json:"capabilities,omitempty"` +} + +// pluginManifestCapabilities holds the nested capabilities object used in the +// v0.3.0+ external plugin.json format (e.g. from wfctl plugin install). +type pluginManifestCapabilities struct { + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + WorkflowHandlers []string `json:"workflowHandlers"` } // LoadPluginTypesFromDir scans pluginDir for subdirectories containing a @@ -426,6 +438,21 @@ func LoadPluginTypesFromDir(pluginDir string) error { for _, t := range m.WorkflowTypes { RegisterWorkflowType(t) } + // Also handle the v0.3.0+ nested capabilities object format. + if cap := m.Capabilities; cap != nil { + for _, t := range cap.ModuleTypes { + RegisterModuleType(t) + } + for _, t := range cap.StepTypes { + RegisterModuleType(t) + } + for _, t := range cap.TriggerTypes { + RegisterTriggerType(t) + } + for _, t := range cap.WorkflowHandlers { + RegisterWorkflowType(t) + } + } } return nil } diff --git a/schema/schema_test.go b/schema/schema_test.go index 305dda9f..6e0013ad 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -945,6 +945,64 @@ func TestLoadPluginTypesFromDir_MalformedManifest(t *testing.T) { } } +func TestLoadPluginTypesFromDir_CapabilitiesFormat(t *testing.T) { + // Tests the v0.3.0+ nested "capabilities" object format used by external plugins. + const customModuleType = "external.caps.module.testonly" + const customStepType = "step.caps_step_testonly" + const customTriggerType = "external.caps.trigger.testonly" + + t.Cleanup(func() { + UnregisterModuleType(customModuleType) + UnregisterModuleType(customStepType) + UnregisterTriggerType(customTriggerType) + }) + + dir := t.TempDir() + pluginDir := dir + "/caps-plugin" + if err := makeDir(pluginDir); err != nil { + t.Fatal(err) + } + manifest := `{"name":"caps-plugin","version":"1.0.0","type":"external","capabilities":{"configProvider":false,"moduleTypes":["` + customModuleType + `"],"stepTypes":["` + customStepType + `"],"triggerTypes":["` + customTriggerType + `"]}}` + if err := writeFile(pluginDir+"/plugin.json", []byte(manifest)); err != nil { + t.Fatal(err) + } + + if err := LoadPluginTypesFromDir(dir); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // All types declared in capabilities should now be recognized + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "ext", Type: customModuleType}, + }, + } + if err := ValidateConfig(cfg, WithAllowNoEntryPoints()); err != nil { + t.Errorf("expected capabilities module type to be recognized, got: %v", err) + } + + knownModules := KnownModuleTypes() + if !sliceContains(knownModules, customModuleType) { + t.Errorf("expected %q in KnownModuleTypes, got: %v", customModuleType, knownModules) + } + if !sliceContains(knownModules, customStepType) { + t.Errorf("expected %q in KnownModuleTypes (step), got: %v", customStepType, knownModules) + } + knownTriggers := KnownTriggerTypes() + if !sliceContains(knownTriggers, customTriggerType) { + t.Errorf("expected %q in KnownTriggerTypes, got: %v", customTriggerType, knownTriggers) + } +} + +func sliceContains(slice []string, s string) bool { + for _, v := range slice { + if v == s { + return true + } + } + return false +} + func makeDir(path string) error { return os.MkdirAll(path, 0755) } From 30077e54014944bf26a061cb8548639df4b9f355 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:04:05 +0000 Subject: [PATCH 3/3] fix: use json.RawMessage for Capabilities to handle both array and object formats; update comments Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/main_test.go | 2 +- schema/schema.go | 49 +++++++++++++++++++++++++----------------- schema/schema_test.go | 31 ++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/cmd/wfctl/main_test.go b/cmd/wfctl/main_test.go index cd187cb7..9955d990 100644 --- a/cmd/wfctl/main_test.go +++ b/cmd/wfctl/main_test.go @@ -322,7 +322,7 @@ modules: func TestRunValidatePluginDirCapabilities(t *testing.T) { // Create a fake external plugin directory with a plugin.json using the - // v0.3.0+ nested "capabilities" object format (as produced by wfctl plugin install). + // v0.3.0+ nested "capabilities" object format (as used by registry manifests and older installers). pluginsDir := t.TempDir() pluginSubdir := filepath.Join(pluginsDir, "my-ext-plugin-caps") if err := os.MkdirAll(pluginSubdir, 0755); err != nil { diff --git a/schema/schema.go b/schema/schema.go index d52a041e..fc67befa 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -383,18 +383,22 @@ func KnownWorkflowTypes() []string { // pluginManifestTypes holds the type declarations from a plugin.json manifest. // This is a minimal subset of the full plugin manifest to avoid import cycles. -// It supports both the flat format (types at root level) and the v0.3.0+ +// It supports both the flat format (types at root level) and the registry-manifest // nested capabilities object format. type pluginManifestTypes struct { - ModuleTypes []string `json:"moduleTypes"` - StepTypes []string `json:"stepTypes"` - TriggerTypes []string `json:"triggerTypes"` - WorkflowTypes []string `json:"workflowTypes"` - Capabilities *pluginManifestCapabilities `json:"capabilities,omitempty"` + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + WorkflowTypes []string `json:"workflowTypes"` + // Capabilities is stored as raw JSON to safely handle both the registry-manifest + // format (object with moduleTypes/stepTypes/etc.) and the engine-internal format + // (array of CapabilityDecl). A non-object value is silently ignored. + Capabilities json.RawMessage `json:"capabilities,omitempty"` } // pluginManifestCapabilities holds the nested capabilities object used in the -// v0.3.0+ external plugin.json format (e.g. from wfctl plugin install). +// registry manifest plugin.json format (not the engine-internal format, which +// uses a JSON array of CapabilityDecl instead). type pluginManifestCapabilities struct { ModuleTypes []string `json:"moduleTypes"` StepTypes []string `json:"stepTypes"` @@ -438,19 +442,24 @@ func LoadPluginTypesFromDir(pluginDir string) error { for _, t := range m.WorkflowTypes { RegisterWorkflowType(t) } - // Also handle the v0.3.0+ nested capabilities object format. - if cap := m.Capabilities; cap != nil { - for _, t := range cap.ModuleTypes { - RegisterModuleType(t) - } - for _, t := range cap.StepTypes { - RegisterModuleType(t) - } - for _, t := range cap.TriggerTypes { - RegisterTriggerType(t) - } - for _, t := range cap.WorkflowHandlers { - RegisterWorkflowType(t) + // Also handle the registry-manifest nested capabilities object format. + // The capabilities field may be a JSON array (engine-internal CapabilityDecl format) + // or a JSON object (registry manifest format). Only process it when it's an object. + if len(m.Capabilities) > 0 && m.Capabilities[0] == '{' { + var cap pluginManifestCapabilities + if err := json.Unmarshal(m.Capabilities, &cap); err == nil { + for _, t := range cap.ModuleTypes { + RegisterModuleType(t) + } + for _, t := range cap.StepTypes { + RegisterModuleType(t) + } + for _, t := range cap.TriggerTypes { + RegisterTriggerType(t) + } + for _, t := range cap.WorkflowHandlers { + RegisterWorkflowType(t) + } } } } diff --git a/schema/schema_test.go b/schema/schema_test.go index 6e0013ad..56be2ef5 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -1003,6 +1003,37 @@ func sliceContains(slice []string, s string) bool { return false } +func TestLoadPluginTypesFromDir_CapabilitiesArrayFormat(t *testing.T) { + // Tests that a plugin.json using the engine-internal format (capabilities as a JSON + // array of CapabilityDecl objects) does not break loading of flat top-level types. + const customModuleType = "external.caps.array.module.testonly" + + t.Cleanup(func() { + UnregisterModuleType(customModuleType) + }) + + dir := t.TempDir() + pluginDir := dir + "/array-caps-plugin" + if err := makeDir(pluginDir); err != nil { + t.Fatal(err) + } + // capabilities is a JSON array (engine-internal CapabilityDecl format) + manifest := `{"name":"array-caps-plugin","version":"1.0.0","moduleTypes":["` + customModuleType + `"],"capabilities":[{"name":"http-server","role":"provider"}]}` + if err := writeFile(pluginDir+"/plugin.json", []byte(manifest)); err != nil { + t.Fatal(err) + } + + if err := LoadPluginTypesFromDir(dir); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The flat top-level module type should still be registered despite the array-format capabilities + knownModules := KnownModuleTypes() + if !sliceContains(knownModules, customModuleType) { + t.Errorf("expected %q in KnownModuleTypes even with array capabilities, got: %v", customModuleType, knownModules) + } +} + func makeDir(path string) error { return os.MkdirAll(path, 0755) }