diff --git a/cmd/wfctl/main_test.go b/cmd/wfctl/main_test.go index 855aa2e1..9955d990 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 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 { + 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..fc67befa 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -383,11 +383,27 @@ 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 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 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 +// 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"` + TriggerTypes []string `json:"triggerTypes"` + WorkflowHandlers []string `json:"workflowHandlers"` } // LoadPluginTypesFromDir scans pluginDir for subdirectories containing a @@ -426,6 +442,26 @@ func LoadPluginTypesFromDir(pluginDir string) error { for _, t := range m.WorkflowTypes { 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) + } + } + } } return nil } diff --git a/schema/schema_test.go b/schema/schema_test.go index 305dda9f..56be2ef5 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -945,6 +945,95 @@ 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 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) }