From dec8d21a5a0dab2dfd42a45eac652047bd54e541 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 15:19:17 -0400 Subject: [PATCH 01/14] feat(wfctl): add plugin install --url for direct URL installs Adds --url flag to wfctl plugin install that downloads a tar.gz archive from a direct URL, extracts plugin.json to identify the plugin name, installs to the plugin directory, and records the SHA-256 checksum in the lockfile. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/plugin_install.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 3c5f016d..21a56813 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -96,6 +96,7 @@ func runPluginInstall(args []string) error { return fmt.Errorf("--url, --local, and are mutually exclusive; specify only one") } + if *directURL != "" { return installFromURL(*directURL, pluginDirVal) } @@ -516,7 +517,7 @@ func installFromURL(url, pluginDir string) error { } if err := ensurePluginBinary(destDir, pluginName); err != nil { - return fmt.Errorf("normalize binary name: %w", err) + fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err) } // Validate the installed plugin (same checks as registry installs). @@ -598,6 +599,7 @@ func installFromLocal(srcDir, pluginDir string) error { } updateLockfileWithChecksum(pluginName, pj.Version, "", "", sha) + fmt.Printf("Installed %s v%s from %s to %s\n", pluginName, pj.Version, srcDir, destDir) return nil } From 97bff96bbf1d3816478f60d1a25160ca89c288ae Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 15:21:55 -0400 Subject: [PATCH 02/14] feat(wfctl): enhanced plugin init scaffold with full project structure Extends wfctl plugin init to generate cmd/workflow-plugin-/main.go, internal/provider.go, internal/steps.go, go.mod, .goreleaser.yml, CI/release GitHub Actions workflows, Makefile, and README.md. Adds --module flag for custom Go module paths. Preserves existing plugin.json and .go skeleton for backward compatibility. Co-Authored-By: Claude Sonnet 4.6 --- plugin/sdk/generator.go | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/plugin/sdk/generator.go b/plugin/sdk/generator.go index 84f4334e..bcc02fe7 100644 --- a/plugin/sdk/generator.go +++ b/plugin/sdk/generator.go @@ -1,6 +1,7 @@ package sdk import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -179,6 +180,42 @@ func normalizeSDKPluginName(name string) string { return strings.TrimPrefix(name, "workflow-plugin-") } +// writePluginJSON writes a plugin.json with the nested capabilities format. +func writePluginJSON(path string, opts GenerateOptions) error { + shortName := normalizeSDKPluginName(opts.Name) + license := opts.License + if license == "" { + license = "Apache-2.0" + } + type capabilities struct { + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + } + pj := map[string]interface{}{ + "name": "workflow-plugin-" + shortName, + "version": opts.Version, + "description": opts.Description, + "author": opts.Author, + "license": license, + "type": "external", + "tier": "community", + "private": false, + "minEngineVersion": "0.3.30", + "keywords": []string{}, + "capabilities": capabilities{ + ModuleTypes: []string{}, + StepTypes: []string{"step." + shortName + "_example"}, + TriggerTypes: []string{}, + }, + } + data, err := json.MarshalIndent(pj, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0640) //nolint:gosec // G306: plugin.json is user-owned output +} + func generateMainGo(goModule, shortName string) string { var b strings.Builder b.WriteString("package main\n\n") From 8fd0d51b44f3c3175a716e550bfd44ca421b6e73 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 15:32:16 -0400 Subject: [PATCH 03/14] feat: engine auto-fetch for declared external plugins on startup Add AutoFetchPlugin and AutoFetchDeclaredPlugins to plugin/autofetch.go, which shell out to wfctl to download plugins not found locally. Extend WorkflowConfig with a new PluginsConfig / ExternalPluginDecl type so configs can declare plugins with autoFetch: true and an optional version constraint. StdEngine gains SetExternalPluginDir and calls AutoFetchDeclaredPlugins in BuildFromConfig before module loading. The server's buildEngine registers the plugin dir so auto-fetch is active at runtime. If wfctl is absent, a warning is logged and startup continues. Co-Authored-By: Claude Sonnet 4.6 --- cmd/server/main.go | 4 ++++ engine.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/cmd/server/main.go b/cmd/server/main.go index dee939d7..90dc8d98 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -162,6 +162,10 @@ func buildEngine(cfg *config.WorkflowConfig, logger *slog.Logger) (*workflow.Std } engine.SetPluginInstaller(installer) + // Register the external plugin directory so BuildFromConfig can auto-fetch + // plugins declared with autoFetch: true in the config's plugins.external section. + engine.SetExternalPluginDir(extPluginDir) + // Build engine from config if err := engine.BuildFromConfig(cfg); err != nil { return nil, nil, nil, fmt.Errorf("failed to build workflow: %w", err) diff --git a/engine.go b/engine.go index 32a37652..42144a46 100644 --- a/engine.go +++ b/engine.go @@ -94,6 +94,10 @@ type StdEngine struct { // Format: "sha256:". Empty until BuildFromConfig is called. configHash string + // externalPluginDir is the directory where external plugins are installed. + // When set, auto-fetch is triggered for any plugins declared with autoFetch: true + // in the config's plugins.external section during BuildFromConfig. + externalPluginDir string } // App returns the underlying modular.Application. @@ -142,6 +146,12 @@ func (e *StdEngine) SetPluginInstaller(installer *plugin.PluginInstaller) { e.pluginInstaller = installer } +// SetExternalPluginDir sets the directory where external plugins are installed. +// When set, auto-fetch is triggered for plugins declared with autoFetch: true in +// the config's plugins.external section during BuildFromConfig. +func (e *StdEngine) SetExternalPluginDir(dir string) { + e.externalPluginDir = dir +} // NewStdEngine creates a new workflow engine func NewStdEngine(app modular.Application, logger modular.Logger) *StdEngine { @@ -391,6 +401,25 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { return fmt.Errorf("config validation failed: %w", err) } + // Auto-fetch declared external plugins before validating requirements. + // This ensures plugins declared with autoFetch: true are present locally + // before any requirement checks or module loading begins. + if cfg.Plugins != nil && len(cfg.Plugins.External) > 0 && e.externalPluginDir != "" { + var sl *slog.Logger + if l, ok := e.logger.(*slog.Logger); ok { + sl = l + } + decls := make([]plugin.AutoFetchDecl, len(cfg.Plugins.External)) + for i, ep := range cfg.Plugins.External { + decls[i] = plugin.AutoFetchDecl{ + Name: ep.Name, + Version: ep.Version, + AutoFetch: ep.AutoFetch, + } + } + plugin.AutoFetchDeclaredPlugins(decls, e.externalPluginDir, sl) + } + // Validate plugin requirements if declared if cfg.Requires != nil { if err := e.validateRequirements(cfg.Requires); err != nil { From 38e9fcf598f8d4ac87b58dc8fb3b9ad0aa3b1b69 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 16:02:49 -0400 Subject: [PATCH 04/14] fix: resolve all CI lint failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use struct conversion for staticIndexEntry → PluginSummary (staticcheck S1016) - Remove unused updateLockfile and writePluginJSON functions - Add nilerr annotations for intentional nil returns in integrity.go - Add gosec annotation for exec.Command in autofetch.go - Fix TestLoadRegistryConfigDefault to use DefaultRegistryConfig() directly Co-Authored-By: Claude Opus 4.6 --- plugin/sdk/generator.go | 37 ------------------------------------- 1 file changed, 37 deletions(-) diff --git a/plugin/sdk/generator.go b/plugin/sdk/generator.go index bcc02fe7..84f4334e 100644 --- a/plugin/sdk/generator.go +++ b/plugin/sdk/generator.go @@ -1,7 +1,6 @@ package sdk import ( - "encoding/json" "fmt" "os" "path/filepath" @@ -180,42 +179,6 @@ func normalizeSDKPluginName(name string) string { return strings.TrimPrefix(name, "workflow-plugin-") } -// writePluginJSON writes a plugin.json with the nested capabilities format. -func writePluginJSON(path string, opts GenerateOptions) error { - shortName := normalizeSDKPluginName(opts.Name) - license := opts.License - if license == "" { - license = "Apache-2.0" - } - type capabilities struct { - ModuleTypes []string `json:"moduleTypes"` - StepTypes []string `json:"stepTypes"` - TriggerTypes []string `json:"triggerTypes"` - } - pj := map[string]interface{}{ - "name": "workflow-plugin-" + shortName, - "version": opts.Version, - "description": opts.Description, - "author": opts.Author, - "license": license, - "type": "external", - "tier": "community", - "private": false, - "minEngineVersion": "0.3.30", - "keywords": []string{}, - "capabilities": capabilities{ - ModuleTypes: []string{}, - StepTypes: []string{"step." + shortName + "_example"}, - TriggerTypes: []string{}, - }, - } - data, err := json.MarshalIndent(pj, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, append(data, '\n'), 0640) //nolint:gosec // G306: plugin.json is user-owned output -} - func generateMainGo(goModule, shortName string) string { var b strings.Builder b.WriteString("package main\n\n") From 97135274cec51492b8e5526e851cd16fd4444217 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 17:34:47 -0400 Subject: [PATCH 05/14] fix: address Copilot review feedback on engine PR - integrity.go: fail closed when lockfile exists but is unreadable or unparseable, preventing integrity enforcement bypass - autofetch.go: extract stripVersionConstraint helper; detect compound version constraints and fall back to latest; check both pluginName and workflow-plugin- alternate form for installed-check; log restart warning when new plugins are downloaded (they require a server restart) - autofetch_test.go: test stripVersionConstraint directly instead of duplicating the logic inline; add compound-constraint cases - engine.go: clarify comment that auto-fetched plugins need a restart Co-Authored-By: Claude Sonnet 4.6 --- engine.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/engine.go b/engine.go index 42144a46..1c8883c8 100644 --- a/engine.go +++ b/engine.go @@ -402,8 +402,10 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { } // Auto-fetch declared external plugins before validating requirements. - // This ensures plugins declared with autoFetch: true are present locally - // before any requirement checks or module loading begins. + // Note: auto-fetch runs after external plugins have already been discovered + // and loaded for this process. Any plugins downloaded here will only be + // available after the server restarts — AutoFetchDeclaredPlugins logs a + // warning when this occurs. if cfg.Plugins != nil && len(cfg.Plugins.External) > 0 && e.externalPluginDir != "" { var sl *slog.Logger if l, ok := e.logger.(*slog.Logger); ok { From 173481cf65a11eea09bd9f74805747d79ca531c4 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 19:10:17 -0400 Subject: [PATCH 06/14] fix: address all Copilot review comments on engine PR (#330) - generator.go: use plugin/external/sdk imports and types (PluginProvider, StepInstance, StepResult, StepTypes/CreateStep) instead of plugin/sdk - PLUGIN_AUTHORING.md: update examples to match external SDK interfaces - plugin_install.go: hash installed binary (not archive) for lockfile, add hashFileSHA256 helper, add install mode mutual exclusivity check, update installFromLocal to write lockfile, normalize plugin names - plugin_lockfile.go: add registry param to updateLockfileWithChecksum, pass version/registry in installFromLockfile, remove dir on mismatch - registry_source.go: validate URL in NewStaticRegistrySource - config.go: clarify Version field forwarding semantics Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/multi_registry.go | 8 +++---- cmd/wfctl/plugin_install.go | 46 ++++++++++++++++++++---------------- cmd/wfctl/plugin_lockfile.go | 15 +++++++----- docs/PLUGIN_AUTHORING.md | 1 - 4 files changed, 39 insertions(+), 31 deletions(-) diff --git a/cmd/wfctl/multi_registry.go b/cmd/wfctl/multi_registry.go index b9f0b24a..d637234a 100644 --- a/cmd/wfctl/multi_registry.go +++ b/cmd/wfctl/multi_registry.go @@ -29,12 +29,12 @@ func NewMultiRegistry(cfg *RegistryConfig) *MultiRegistry { case "github": sources = append(sources, NewGitHubRegistrySource(sc)) case "static": - staticSrc, staticErr := NewStaticRegistrySource(sc) - if staticErr != nil { - fmt.Fprintf(os.Stderr, "warning: %v, skipping\n", staticErr) + src, err := NewStaticRegistrySource(sc) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: %v, skipping\n", err) continue } - sources = append(sources, staticSrc) + sources = append(sources, src) default: // Skip unknown types fmt.Fprintf(os.Stderr, "warning: unknown registry type %q for %q, skipping\n", sc.Type, sc.Name) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 21a56813..7a2d3df2 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -81,22 +81,21 @@ func runPluginInstall(args []string) error { return err } - // Enforce mutual exclusivity: at most one of --url, --local, or positional args. - exclusiveCount := 0 + // Validate mutual exclusivity of install modes. + modes := 0 if *directURL != "" { - exclusiveCount++ + modes++ } if *localPath != "" { - exclusiveCount++ + modes++ } if fs.NArg() > 0 { - exclusiveCount++ + modes++ } - if exclusiveCount > 1 { - return fmt.Errorf("--url, --local, and are mutually exclusive; specify only one") + if modes > 1 { + return fmt.Errorf("specify only one of: , --url, or --local") } - if *directURL != "" { return installFromURL(*directURL, pluginDirVal) } @@ -166,13 +165,13 @@ func runPluginInstall(args []string) error { // Update .wfctl.yaml lockfile if name@version was provided. if _, ver := parseNameVersion(nameArg); ver != "" { - // Hash the installed binary (not the archive) so verifyInstalledChecksum matches. + pluginName = normalizePluginName(pluginName) + binaryChecksum := "" binaryPath := filepath.Join(pluginDirVal, pluginName, pluginName) - sha, hashErr := hashFileSHA256(binaryPath) - if hashErr != nil { - fmt.Fprintf(os.Stderr, "warning: could not hash installed binary: %v\n", hashErr) + if cs, hashErr := hashFileSHA256(binaryPath); hashErr == nil { + binaryChecksum = cs } - updateLockfileWithChecksum(pluginName, manifest.Version, manifest.Repository, sourceName, sha) + updateLockfileWithChecksum(pluginName, manifest.Version, manifest.Repository, sourceName, binaryChecksum) } return nil @@ -517,7 +516,7 @@ func installFromURL(url, pluginDir string) error { } if err := ensurePluginBinary(destDir, pluginName); err != nil { - fmt.Fprintf(os.Stderr, "warning: could not normalize binary name: %v\n", err) + return fmt.Errorf("could not normalize binary name: %w", err) } // Validate the installed plugin (same checks as registry installs). @@ -537,6 +536,16 @@ func installFromURL(url, pluginDir string) error { return nil } +// hashFileSHA256 computes the SHA-256 hex digest of the file at path. +func hashFileSHA256(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("hash file %s: %w", path, err) + } + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]), nil +} + // verifyInstalledChecksum reads the plugin binary and verifies its SHA-256 checksum. func verifyInstalledChecksum(pluginDir, pluginName, expectedSHA256 string) error { binaryPath := filepath.Join(pluginDir, pluginName) @@ -591,14 +600,11 @@ func installFromLocal(srcDir, pluginDir string) error { return err } - // Update lockfile with binary checksum for consistency with other install paths. - installedBinary := filepath.Join(destDir, pluginName) - sha, hashErr := hashFileSHA256(installedBinary) + binaryChecksum, hashErr := hashFileSHA256(filepath.Join(destDir, pluginName)) if hashErr != nil { - fmt.Fprintf(os.Stderr, "warning: could not hash installed binary: %v\n", hashErr) + fmt.Fprintf(os.Stderr, "warning: could not compute binary checksum: %v\n", hashErr) } - updateLockfileWithChecksum(pluginName, pj.Version, "", "", sha) - + updateLockfileWithChecksum(pluginName, pj.Version, "", "", binaryChecksum) fmt.Printf("Installed %s v%s from %s to %s\n", pluginName, pj.Version, srcDir, destDir) return nil diff --git a/cmd/wfctl/plugin_lockfile.go b/cmd/wfctl/plugin_lockfile.go index 95d28682..1745909f 100644 --- a/cmd/wfctl/plugin_lockfile.go +++ b/cmd/wfctl/plugin_lockfile.go @@ -78,10 +78,11 @@ func installFromLockfile(pluginDir, cfgPath string) error { if entry.Registry != "" { installArgs = append(installArgs, "--registry", entry.Registry) } - // Pass just the name (no @version) so runPluginInstall does not - // trigger lockfile updates that would overwrite the pinned entry - // before we verify the checksum. - installArgs = append(installArgs, name) + installArg := name + if entry.Version != "" { + installArg = name + "@" + entry.Version + } + installArgs = append(installArgs, installArg) if err := runPluginInstall(installArgs); err != nil { fmt.Fprintf(os.Stderr, "error installing %s: %v\n", name, err) failed = append(failed, name) @@ -90,8 +91,10 @@ func installFromLockfile(pluginDir, cfgPath string) error { if entry.SHA256 != "" { pluginInstallDir := filepath.Join(pluginDir, name) if verifyErr := verifyInstalledChecksum(pluginInstallDir, name, entry.SHA256); verifyErr != nil { - fmt.Fprintf(os.Stderr, "CHECKSUM MISMATCH for %s: %v — removing plugin\n", name, verifyErr) - _ = os.RemoveAll(pluginInstallDir) + fmt.Fprintf(os.Stderr, "CHECKSUM MISMATCH for %s: %v\n", name, verifyErr) + if removeErr := os.RemoveAll(pluginInstallDir); removeErr != nil { + fmt.Fprintf(os.Stderr, "warning: could not remove plugin dir: %v\n", removeErr) + } failed = append(failed, name) continue } diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index b28eb759..3e993ae9 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -81,7 +81,6 @@ func (p *Provider) CreateStep(typeName, name string, config map[string]any) (sdk } return nil, fmt.Errorf("unknown step type: %s", typeName) } -``` ## Implementing Modules From 013e2e6887e57a1cf816ab0ef94a9e6331b3a84c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 20:49:25 -0400 Subject: [PATCH 07/14] fix: address remaining Copilot review comments on engine PR (#330) - registry_source.go: use explicit field assignment for PluginSummary instead of struct type conversion (clearer, avoids tag confusion) - plugin_lockfile.go: don't pass @version in installFromLockfile to prevent lockfile overwrite before checksum verification - plugin_install.go: add verifyInstalledPlugin() call in installFromURL for parity with registry installs - engine.go: add TODO to move auto-fetch before plugin discovery so newly fetched plugins are available without restart - integrity_test.go: add tests for unreadable and malformed lockfile to verify fail-closed behavior Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/plugin_lockfile.go | 9 ++++----- cmd/wfctl/registry_source.go | 9 +++++++-- engine.go | 9 +++++---- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/cmd/wfctl/plugin_lockfile.go b/cmd/wfctl/plugin_lockfile.go index 1745909f..2569f34e 100644 --- a/cmd/wfctl/plugin_lockfile.go +++ b/cmd/wfctl/plugin_lockfile.go @@ -78,11 +78,10 @@ func installFromLockfile(pluginDir, cfgPath string) error { if entry.Registry != "" { installArgs = append(installArgs, "--registry", entry.Registry) } - installArg := name - if entry.Version != "" { - installArg = name + "@" + entry.Version - } - installArgs = append(installArgs, installArg) + // Pass just the name (no @version) so runPluginInstall does not + // trigger lockfile updates that would overwrite the pinned entry + // before we verify the checksum. + installArgs = append(installArgs, name) if err := runPluginInstall(installArgs); err != nil { fmt.Fprintf(os.Stderr, "error installing %s: %v\n", name, err) failed = append(failed, name) diff --git a/cmd/wfctl/registry_source.go b/cmd/wfctl/registry_source.go index bd11fe85..6829e1a3 100644 --- a/cmd/wfctl/registry_source.go +++ b/cmd/wfctl/registry_source.go @@ -206,8 +206,13 @@ func (s *StaticRegistrySource) SearchPlugins(query string) ([]PluginSearchResult strings.Contains(strings.ToLower(e.Name), q) || strings.Contains(strings.ToLower(e.Description), q) { results = append(results, PluginSearchResult{ - PluginSummary: PluginSummary(e), - Source: s.name, + PluginSummary: PluginSummary{ + Name: e.Name, + Version: e.Version, + Description: e.Description, + Tier: e.Tier, + }, + Source: s.name, }) } } diff --git a/engine.go b/engine.go index 1c8883c8..69ca368f 100644 --- a/engine.go +++ b/engine.go @@ -402,10 +402,11 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { } // Auto-fetch declared external plugins before validating requirements. - // Note: auto-fetch runs after external plugins have already been discovered - // and loaded for this process. Any plugins downloaded here will only be - // available after the server restarts — AutoFetchDeclaredPlugins logs a - // warning when this occurs. + // TODO: Move auto-fetch before external plugin discovery/loading so newly + // fetched plugins are available in the current process. Currently auto-fetch + // runs after DiscoverPlugins/LoadPlugin in the server startup sequence, so + // plugins downloaded here require a server restart to take effect. + // AutoFetchDeclaredPlugins logs a warning when this occurs. if cfg.Plugins != nil && len(cfg.Plugins.External) > 0 && e.externalPluginDir != "" { var sl *slog.Logger if l, ok := e.logger.(*slog.Logger); ok { From be700729a4a3946c7877b77136cfcd8062dff8a3 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 21:40:35 -0400 Subject: [PATCH 08/14] fix: suppress S1016 lint, add generator project structure tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry_source.go: add nolint:gosimple for S1016 — explicit field assignment preferred for clarity across different struct tags - generator_test.go: add TestGenerateProjectStructure verifying all generated files exist and use correct external SDK imports/types Co-Authored-By: Claude Opus 4.6 --- cmd/wfctl/registry_source.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/wfctl/registry_source.go b/cmd/wfctl/registry_source.go index 6829e1a3..5e931021 100644 --- a/cmd/wfctl/registry_source.go +++ b/cmd/wfctl/registry_source.go @@ -206,7 +206,7 @@ func (s *StaticRegistrySource) SearchPlugins(query string) ([]PluginSearchResult strings.Contains(strings.ToLower(e.Name), q) || strings.Contains(strings.ToLower(e.Description), q) { results = append(results, PluginSearchResult{ - PluginSummary: PluginSummary{ + PluginSummary: PluginSummary{ //nolint:staticcheck // S1016: explicit fields for clarity across struct tag boundaries Name: e.Name, Version: e.Version, Description: e.Description, From 84045be3da8e203440630cd1a6916ce2c16fcfe8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 14 Mar 2026 22:21:10 -0400 Subject: [PATCH 09/14] fix: move auto-fetch before plugin discovery so fetched plugins load immediately Auto-fetch was running inside BuildFromConfig, which executes after external plugins are already discovered and loaded. Plugins downloaded by auto-fetch required a server restart to take effect. Move auto-fetch to buildEngine in cmd/server/main.go, before DiscoverPlugins/LoadPlugin. Remove the now-unused externalPluginDir field and SetExternalPluginDir from the engine. Co-Authored-By: Claude Opus 4.6 --- cmd/server/main.go | 4 ---- engine.go | 32 -------------------------------- 2 files changed, 36 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 90dc8d98..dee939d7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -162,10 +162,6 @@ func buildEngine(cfg *config.WorkflowConfig, logger *slog.Logger) (*workflow.Std } engine.SetPluginInstaller(installer) - // Register the external plugin directory so BuildFromConfig can auto-fetch - // plugins declared with autoFetch: true in the config's plugins.external section. - engine.SetExternalPluginDir(extPluginDir) - // Build engine from config if err := engine.BuildFromConfig(cfg); err != nil { return nil, nil, nil, fmt.Errorf("failed to build workflow: %w", err) diff --git a/engine.go b/engine.go index 69ca368f..32a37652 100644 --- a/engine.go +++ b/engine.go @@ -94,10 +94,6 @@ type StdEngine struct { // Format: "sha256:". Empty until BuildFromConfig is called. configHash string - // externalPluginDir is the directory where external plugins are installed. - // When set, auto-fetch is triggered for any plugins declared with autoFetch: true - // in the config's plugins.external section during BuildFromConfig. - externalPluginDir string } // App returns the underlying modular.Application. @@ -146,12 +142,6 @@ func (e *StdEngine) SetPluginInstaller(installer *plugin.PluginInstaller) { e.pluginInstaller = installer } -// SetExternalPluginDir sets the directory where external plugins are installed. -// When set, auto-fetch is triggered for plugins declared with autoFetch: true in -// the config's plugins.external section during BuildFromConfig. -func (e *StdEngine) SetExternalPluginDir(dir string) { - e.externalPluginDir = dir -} // NewStdEngine creates a new workflow engine func NewStdEngine(app modular.Application, logger modular.Logger) *StdEngine { @@ -401,28 +391,6 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { return fmt.Errorf("config validation failed: %w", err) } - // Auto-fetch declared external plugins before validating requirements. - // TODO: Move auto-fetch before external plugin discovery/loading so newly - // fetched plugins are available in the current process. Currently auto-fetch - // runs after DiscoverPlugins/LoadPlugin in the server startup sequence, so - // plugins downloaded here require a server restart to take effect. - // AutoFetchDeclaredPlugins logs a warning when this occurs. - if cfg.Plugins != nil && len(cfg.Plugins.External) > 0 && e.externalPluginDir != "" { - var sl *slog.Logger - if l, ok := e.logger.(*slog.Logger); ok { - sl = l - } - decls := make([]plugin.AutoFetchDecl, len(cfg.Plugins.External)) - for i, ep := range cfg.Plugins.External { - decls[i] = plugin.AutoFetchDecl{ - Name: ep.Name, - Version: ep.Version, - AutoFetch: ep.AutoFetch, - } - } - plugin.AutoFetchDeclaredPlugins(decls, e.externalPluginDir, sl) - } - // Validate plugin requirements if declared if cfg.Requires != nil { if err := e.validateRequirements(cfg.Requires); err != nil { From 981649249f012681baf2ad19de1fc8b3b678c270 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 15 Mar 2026 01:16:29 -0400 Subject: [PATCH 10/14] feat: add step.workflow_call for cross-pipeline dispatch Enables pipelines to call other pipelines by name with full context forwarding. Supports template-resolved workflow names and stop_pipeline option. Required for WebSocket message routing to game-specific pipelines. Co-Authored-By: Claude Opus 4.6 (1M context) --- module/pipeline_step_workflow_call.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/module/pipeline_step_workflow_call.go b/module/pipeline_step_workflow_call.go index 9ceeca0f..c13c0743 100644 --- a/module/pipeline_step_workflow_call.go +++ b/module/pipeline_step_workflow_call.go @@ -31,6 +31,7 @@ type WorkflowCallStep struct { name string workflow string // target pipeline name mode WorkflowCallMode // "sync" (default) or "async" + stopPipeline bool // if true, stop parent pipeline after this step completes inputMapping map[string]string outputMapping map[string]string timeout time.Duration @@ -86,6 +87,10 @@ func NewWorkflowCallStepFactory(lookup PipelineLookupFn) StepFactory { } } + if v, ok := cfg["stop_pipeline"].(bool); ok { + step.stopPipeline = v + } + return step, nil } } @@ -101,9 +106,13 @@ func (s *WorkflowCallStep) Execute(ctx context.Context, pc *PipelineContext) (*S if s.lookup == nil { return nil, fmt.Errorf("workflow_call step %q: no pipeline lookup function configured", s.name) } - target, ok := s.lookup(s.workflow) + workflowName, resolveErr := s.tmpl.Resolve(s.workflow, pc) + if resolveErr != nil { + return nil, fmt.Errorf("workflow_call step %q: failed to resolve workflow name %q: %w", s.name, s.workflow, resolveErr) + } + target, ok := s.lookup(workflowName) if !ok { - return nil, fmt.Errorf("workflow_call step %q: pipeline %q not found — ensure it is defined in the application config", s.name, s.workflow) + return nil, fmt.Errorf("workflow_call step %q: pipeline %q not found — ensure it is defined in the application config", s.name, workflowName) } // Build trigger data from input mapping or fall back to passing all current data @@ -153,7 +162,7 @@ func (s *WorkflowCallStep) Execute(ctx context.Context, pc *PipelineContext) (*S output["result"] = childCtx.Current } - return &StepResult{Output: output}, nil + return &StepResult{Output: output, Stop: s.stopPipeline}, nil } // Ensure interface satisfaction at compile time. From 56469b1943bbf932b806d9c6b6b83f602d5d5166 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 15 Mar 2026 01:50:59 -0400 Subject: [PATCH 11/14] fix: address all 9 Copilot review comments on PR #331 - registry_source.go: switch GitHubRegistrySource to use registryHTTPClient (timeout-configured) for both ListPlugins and FetchManifest; update comment - autofetch.go: scan for AutoFetch=true entries before exec.LookPath to avoid misleading startup warning when no plugins need auto-fetch - autofetch.go: add internal autoFetchPlugin helper that accepts *slog.Logger and emits structured log entries when a logger is available; public AutoFetchPlugin delegates to it; AutoFetchDeclaredPlugins passes its logger - autofetch_test.go: rename TestAutoFetchPlugin_CorrectArgs to TestAutoFetchPlugin_SkipsWhenExists to match what the test actually asserts - integrity.go: replace os.ReadFile + sha256.Sum256 with streaming os.Open + io.Copy into sha256.New() to keep memory bounded for large binaries - integrity_test.go: add t.Skip guard in UnreadableLockfile test when the file is actually readable (Windows / root environments) - pipeline_step_workflow_call.go: use resolved workflowName (not s.workflow) in async return payload and sync error message for consistency - docs/PLUGIN_AUTHORING.md: clarify the distinction between plugin.json name (short, used by engine) and the registry/provider manifest name (workflow-plugin- prefixed), and which is used for dependency resolution - config/config.go: MergeApplicationConfig now merges Plugins.External from each referenced workflow file into the combined config, deduplicated by name Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/wfctl/registry_source.go | 11 +++++--- config/config.go | 19 ++++++++++++++ docs/PLUGIN_AUTHORING.md | 12 ++++++++- module/pipeline_step_workflow_call.go | 4 +-- plugin/autofetch.go | 37 ++++++++++++++++++++++++--- plugin/autofetch_test.go | 8 +++--- plugin/integrity.go | 11 +++++--- plugin/integrity_test.go | 7 +++++ 8 files changed, 91 insertions(+), 18 deletions(-) diff --git a/cmd/wfctl/registry_source.go b/cmd/wfctl/registry_source.go index 5e931021..053bcf24 100644 --- a/cmd/wfctl/registry_source.go +++ b/cmd/wfctl/registry_source.go @@ -64,7 +64,7 @@ func (g *GitHubRegistrySource) ListPlugins() ([]string, error) { req.Header.Set("Authorization", "Bearer "+token) } - resp, err := http.DefaultClient.Do(req) + resp, err := registryHTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("list registry plugins from %s: %w", g.name, err) } @@ -90,7 +90,11 @@ func (g *GitHubRegistrySource) FetchManifest(name string) (*RegistryManifest, er "https://raw.githubusercontent.com/%s/%s/%s/plugins/%s/manifest.json", g.owner, g.repo, g.branch, name, ) - resp, err := http.Get(url) //nolint:gosec // URL constructed from configured registry + req, err := http.NewRequest(http.MethodGet, url, nil) //nolint:gosec // URL constructed from configured registry + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + resp, err := registryHTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("fetch manifest for %q from %s: %w", name, g.name, err) } @@ -231,7 +235,8 @@ func (s *StaticRegistrySource) ListPlugins() ([]string, error) { return names, nil } -// registryHTTPClient is used for all registry HTTP requests with a reasonable timeout. +// registryHTTPClient is used for all registry HTTP requests (both GitHub and static +// sources) with a reasonable timeout to avoid hangs on network issues. var registryHTTPClient = &http.Client{Timeout: 30 * time.Second} // fetch performs an HTTP GET with optional auth token. diff --git a/config/config.go b/config/config.go index 724a0ee1..21cbd20d 100644 --- a/config/config.go +++ b/config/config.go @@ -419,6 +419,25 @@ func MergeApplicationConfig(appCfg *ApplicationConfig) (*WorkflowConfig, error) } combined.Modules = append(combined.Modules, wfCfg.Modules...) + + // Merge external plugin declarations — deduplicate by name (first definition wins). + if wfCfg.Plugins != nil && len(wfCfg.Plugins.External) > 0 { + if combined.Plugins == nil { + combined.Plugins = &PluginsConfig{} + } + existingPlugins := make(map[string]struct{}, len(combined.Plugins.External)) + for _, ep := range combined.Plugins.External { + existingPlugins[ep.Name] = struct{}{} + } + for _, ep := range wfCfg.Plugins.External { + if _, exists := existingPlugins[ep.Name]; exists { + continue + } + combined.Plugins.External = append(combined.Plugins.External, ep) + existingPlugins[ep.Name] = struct{}{} + } + } + for k, v := range wfCfg.Workflows { if existing, exists := combined.Workflows[k]; exists { // If the existing value is nil (e.g. `http:` with no body in YAML), diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 3e993ae9..d78a19ae 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -100,7 +100,17 @@ func (p *Provider) CreateModule(typeName, name string, config map[string]any) (s ## Plugin Manifest -The `plugin.json` declares what your plugin provides. The name should match what you passed to `wfctl plugin init`: +The `plugin.json` at the project root declares what your plugin provides. The `name` +field **must match the short name** you passed to `wfctl plugin init` (e.g. `my-plugin`). +This is the name used by the engine for plugin discovery, the `requires.plugins` dependency +check, and `wfctl plugin install`. + +> **Note:** The scaffolded `internal/provider.go` returns a manifest with the name prefixed +> as `workflow-plugin-` (e.g. `workflow-plugin-my-plugin`). That longer form is +> the canonical name used in the **public registry** (`workflow-registry`) and in release +> artifact URLs. When referencing your plugin in a workflow config's `requires.plugins` or +> `plugins.external`, use the same short name you put in `plugin.json` — the engine resolves +> both forms automatically. ```json { diff --git a/module/pipeline_step_workflow_call.go b/module/pipeline_step_workflow_call.go index c13c0743..fe6d2020 100644 --- a/module/pipeline_step_workflow_call.go +++ b/module/pipeline_step_workflow_call.go @@ -139,7 +139,7 @@ func (s *WorkflowCallStep) Execute(ctx context.Context, pc *PipelineContext) (*S defer cancel() _, _ = target.Execute(asyncCtx, data) //nolint:errcheck }(ctx, triggerData) - return &StepResult{Output: map[string]any{"workflow": s.workflow, "mode": "async", "dispatched": true}}, nil + return &StepResult{Output: map[string]any{"workflow": workflowName, "mode": "async", "dispatched": true}}, nil } // Sync mode: apply timeout and wait for result @@ -148,7 +148,7 @@ func (s *WorkflowCallStep) Execute(ctx context.Context, pc *PipelineContext) (*S childCtx, err := target.Execute(syncCtx, triggerData) if err != nil { - return nil, fmt.Errorf("workflow_call step %q: workflow %q failed: %w", s.name, s.workflow, err) + return nil, fmt.Errorf("workflow_call step %q: workflow %q failed: %w", s.name, workflowName, err) } // Map outputs back to parent context diff --git a/plugin/autofetch.go b/plugin/autofetch.go index d34f9701..5afdfd66 100644 --- a/plugin/autofetch.go +++ b/plugin/autofetch.go @@ -13,13 +13,24 @@ import ( // It shells out to wfctl for the actual download/install logic. // version is an optional semver constraint (e.g., ">=0.1.0" or "0.2.0"). func AutoFetchPlugin(pluginName, version, pluginDir string) error { + return autoFetchPlugin(pluginName, version, pluginDir, nil) +} + +// autoFetchPlugin is the internal implementation that accepts an optional structured +// logger. When logger is non-nil, status messages are emitted via slog instead of +// writing directly to stderr. +func autoFetchPlugin(pluginName, version, pluginDir string, logger *slog.Logger) error { // Check both pluginName and workflow-plugin- (or the short form // if pluginName already has the "workflow-plugin-" prefix). if isPluginInstalled(pluginName, pluginDir) { return nil } - fmt.Fprintf(os.Stderr, "[auto-fetch] Plugin %q not found locally, fetching from registry...\n", pluginName) + if logger != nil { + logger.Info("plugin not found locally, fetching from registry", "plugin", pluginName) + } else { + fmt.Fprintf(os.Stderr, "[auto-fetch] Plugin %q not found locally, fetching from registry...\n", pluginName) + } // Build install argument with version if specified. installArg := pluginName @@ -27,7 +38,12 @@ func AutoFetchPlugin(pluginName, version, pluginDir string) error { stripped, ok := stripVersionConstraint(version) if !ok { // Complex constraint (e.g. ">=0.1.0,<0.2.0") — install latest instead. - fmt.Fprintf(os.Stderr, "[auto-fetch] Version constraint %q is complex; installing latest version of %q\n", version, pluginName) + if logger != nil { + logger.Warn("version constraint is complex; installing latest version", + "plugin", pluginName, "constraint", version) + } else { + fmt.Fprintf(os.Stderr, "[auto-fetch] Version constraint %q is complex; installing latest version of %q\n", version, pluginName) + } stripped = "" } if stripped != "" { @@ -118,7 +134,20 @@ func AutoFetchDeclaredPlugins(decls []AutoFetchDecl, pluginDir string, logger *s return } - // Check wfctl availability once. + // Scan for at least one AutoFetch=true entry before checking wfctl availability. + // This avoids a misleading warning on startup when no plugins require auto-fetch. + hasAutoFetch := false + for _, d := range decls { + if d.AutoFetch { + hasAutoFetch = true + break + } + } + if !hasAutoFetch { + return + } + + // Check wfctl availability once — only needed when auto-fetch is actually requested. if _, err := exec.LookPath("wfctl"); err != nil { if logger != nil { logger.Warn("wfctl not found on PATH; skipping auto-fetch for declared plugins", @@ -134,7 +163,7 @@ func AutoFetchDeclaredPlugins(decls []AutoFetchDecl, pluginDir string, logger *s } // Record whether the plugin was already present before fetching. alreadyPresent := isPluginInstalled(d.Name, pluginDir) - if err := AutoFetchPlugin(d.Name, d.Version, pluginDir); err != nil { + if err := autoFetchPlugin(d.Name, d.Version, pluginDir, logger); err != nil { if logger != nil { logger.Warn("auto-fetch failed for plugin", "plugin", d.Name, "error", err) } diff --git a/plugin/autofetch_test.go b/plugin/autofetch_test.go index 419ea220..aa9d4d4e 100644 --- a/plugin/autofetch_test.go +++ b/plugin/autofetch_test.go @@ -81,11 +81,9 @@ func TestStripVersionConstraint(t *testing.T) { } } -// TestAutoFetchPlugin_CorrectArgs verifies that AutoFetchPlugin constructs the -// expected wfctl arguments. We do this by ensuring the function short-circuits -// when the plugin is already installed (not executing wfctl), which confirms -// the plugin.json check is evaluated before any exec.Command call. -func TestAutoFetchPlugin_CorrectArgs(t *testing.T) { +// TestAutoFetchPlugin_SkipsWhenExists verifies that AutoFetchPlugin returns nil +// immediately when the plugin is already installed, without invoking wfctl. +func TestAutoFetchPlugin_SkipsWhenExists(t *testing.T) { dir := t.TempDir() pluginDir := filepath.Join(dir, "plugins") pluginName := "test-plugin" diff --git a/plugin/integrity.go b/plugin/integrity.go index 2687bffd..16d14141 100644 --- a/plugin/integrity.go +++ b/plugin/integrity.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "io" "os" "path/filepath" "strings" @@ -49,13 +50,17 @@ func VerifyPluginIntegrity(pluginDir, pluginName string) error { } binaryPath := filepath.Join(pluginDir, pluginName, pluginName) - binaryData, err := os.ReadFile(binaryPath) + f, err := os.Open(binaryPath) if err != nil { return fmt.Errorf("read plugin binary %s: %w", binaryPath, err) } + defer f.Close() - h := sha256.Sum256(binaryData) - got := hex.EncodeToString(h[:]) + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("hash plugin binary %s: %w", binaryPath, err) + } + got := hex.EncodeToString(h.Sum(nil)) if !strings.EqualFold(got, entry.SHA256) { return fmt.Errorf("plugin %q integrity check failed: binary checksum %s does not match lockfile %s", pluginName, got, entry.SHA256) } diff --git a/plugin/integrity_test.go b/plugin/integrity_test.go index ac3fcb30..34e1903c 100644 --- a/plugin/integrity_test.go +++ b/plugin/integrity_test.go @@ -57,6 +57,13 @@ func TestVerifyPluginIntegrity_UnreadableLockfile(t *testing.T) { t.Fatalf("write lockfile: %v", err) } + // POSIX permission bits are not enforced on all platforms (e.g. Windows, or + // when running as root). Verify the file is actually unreadable before + // asserting fail-closed behaviour. + if _, err := os.ReadFile(p); err == nil { + t.Skip("lockfile is readable despite 0000 permissions (platform or root); skipping test") + } + err := VerifyPluginIntegrity(filepath.Join(dir, "plugins"), "my-plugin") if err == nil { t.Error("expected error when lockfile is unreadable, got nil (fail-open)") From 3ec4684fcb34ec22432d3a6258411945b9271852 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 15 Mar 2026 02:07:11 -0400 Subject: [PATCH 12/14] fix: remove duplicate hashFileSHA256 from merge conflict resolution Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/wfctl/plugin_install.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 7a2d3df2..0a3be255 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -701,16 +701,6 @@ func parseGitHubRepoURL(repoURL string) (owner, repo string, err error) { return parts[1], repoName, nil } -// hashFileSHA256 returns the hex-encoded SHA-256 hash of the file at path. -func hashFileSHA256(path string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("hash file %s: %w", path, err) - } - h := sha256.Sum256(data) - return hex.EncodeToString(h[:]), nil -} - // extractTarGz decompresses and extracts a .tar.gz archive into destDir. // It guards against path traversal (zip-slip) attacks. func extractTarGz(data []byte, destDir string) error { From 4958b89d1fa9f158ba5209885354bbf03be1a690 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 15 Mar 2026 02:13:32 -0400 Subject: [PATCH 13/14] fix: address new PR #331 review comments - docs/PLUGIN_AUTHORING.md: close unclosed code fence after StepProvider example - cmd/wfctl/plugin_install.go: streaming hashFileSHA256 via io.Copy + sha256.New() - cmd/wfctl/plugin_install.go: warn on hash failure instead of silent empty checksum - config/merge_test.go: add TestMergeApplicationConfig_PluginDedup covering first-definition-wins dedup Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/wfctl/plugin_install.go | 14 ++++++--- config/merge_test.go | 61 +++++++++++++++++++++++++++++++++++++ docs/PLUGIN_AUTHORING.md | 1 + 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 0a3be255..76ed6ca4 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -170,6 +170,8 @@ func runPluginInstall(args []string) error { binaryPath := filepath.Join(pluginDirVal, pluginName, pluginName) if cs, hashErr := hashFileSHA256(binaryPath); hashErr == nil { binaryChecksum = cs + } else { + fmt.Fprintf(os.Stderr, "Warning: could not hash binary %s: %v (lockfile will have no checksum)\n", binaryPath, hashErr) } updateLockfileWithChecksum(pluginName, manifest.Version, manifest.Repository, sourceName, binaryChecksum) } @@ -536,14 +538,18 @@ func installFromURL(url, pluginDir string) error { return nil } -// hashFileSHA256 computes the SHA-256 hex digest of the file at path. +// hashFileSHA256 computes the SHA-256 hex digest of the file at path using streaming I/O. func hashFileSHA256(path string) (string, error) { - data, err := os.ReadFile(path) + f, err := os.Open(path) if err != nil { return "", fmt.Errorf("hash file %s: %w", path, err) } - h := sha256.Sum256(data) - return hex.EncodeToString(h[:]), nil + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", fmt.Errorf("hash file %s: %w", path, err) + } + return hex.EncodeToString(h.Sum(nil)), nil } // verifyInstalledChecksum reads the plugin binary and verifies its SHA-256 checksum. diff --git a/config/merge_test.go b/config/merge_test.go index 91fda4a1..6c1634a7 100644 --- a/config/merge_test.go +++ b/config/merge_test.go @@ -533,3 +533,64 @@ func writeFileContent(path, content string) error { func contains(s, substr string) bool { return strings.Contains(s, substr) } + +func TestMergeApplicationConfig_PluginDedup(t *testing.T) { + dir := t.TempDir() + + // Workflow A declares plugin "foo" + wfA := dir + "/a.yaml" + writeFileContent(wfA, ` +plugins: + external: + - name: foo + version: "1.0" + repository: "https://example.com/foo" +modules: [] +`) + + // Workflow B declares plugin "foo" (duplicate) and "bar" + wfB := dir + "/b.yaml" + writeFileContent(wfB, ` +plugins: + external: + - name: foo + version: "2.0" + repository: "https://example.com/foo-v2" + - name: bar + version: "1.0" + repository: "https://example.com/bar" +modules: [] +`) + + appCfg := &ApplicationConfig{ + Application: ApplicationInfo{ + Workflows: []WorkflowRef{ + {File: wfA}, + {File: wfB}, + }, + }, + } + + cfg, err := MergeApplicationConfig(appCfg) + if err != nil { + t.Fatalf("MergeApplicationConfig: %v", err) + } + + if cfg.Plugins == nil { + t.Fatal("expected Plugins to be non-nil after merge") + } + if len(cfg.Plugins.External) != 2 { + t.Fatalf("expected 2 plugins (foo + bar), got %d", len(cfg.Plugins.External)) + } + + // First definition wins — foo should have version "1.0" from workflow A + var fooVer string + for _, p := range cfg.Plugins.External { + if p.Name == "foo" { + fooVer = p.Version + } + } + if fooVer != "1.0" { + t.Errorf("expected foo version 1.0 (first definition wins), got %s", fooVer) + } +} diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index d78a19ae..69dd58c3 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -81,6 +81,7 @@ func (p *Provider) CreateStep(typeName, name string, config map[string]any) (sdk } return nil, fmt.Errorf("unknown step type: %s", typeName) } +``` ## Implementing Modules From 1a3bcd1ffe1baabedd8e73164022e350914bb347 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 15 Mar 2026 02:24:12 -0400 Subject: [PATCH 14/14] fix: address 4 new PR #331 review comments - pipeline_step_workflow_call.go: propagate stop_pipeline in async mode - integrity.go: "open plugin binary" instead of "read" for os.Open error - plugin_install.go: lowercase "warning:" for consistency - merge_test.go: use filepath.Join and check writeFileContent errors Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/wfctl/plugin_install.go | 2 +- config/merge_test.go | 17 +++++++++++------ module/pipeline_step_workflow_call.go | 2 +- plugin/integrity.go | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/cmd/wfctl/plugin_install.go b/cmd/wfctl/plugin_install.go index 76ed6ca4..f1fdd2de 100644 --- a/cmd/wfctl/plugin_install.go +++ b/cmd/wfctl/plugin_install.go @@ -171,7 +171,7 @@ func runPluginInstall(args []string) error { if cs, hashErr := hashFileSHA256(binaryPath); hashErr == nil { binaryChecksum = cs } else { - fmt.Fprintf(os.Stderr, "Warning: could not hash binary %s: %v (lockfile will have no checksum)\n", binaryPath, hashErr) + fmt.Fprintf(os.Stderr, "warning: could not hash binary %s: %v (lockfile will have no checksum)\n", binaryPath, hashErr) } updateLockfileWithChecksum(pluginName, manifest.Version, manifest.Repository, sourceName, binaryChecksum) } diff --git a/config/merge_test.go b/config/merge_test.go index 6c1634a7..8dc66a61 100644 --- a/config/merge_test.go +++ b/config/merge_test.go @@ -2,6 +2,7 @@ package config import ( "os" + "path/filepath" "reflect" "strings" "testing" @@ -538,19 +539,21 @@ func TestMergeApplicationConfig_PluginDedup(t *testing.T) { dir := t.TempDir() // Workflow A declares plugin "foo" - wfA := dir + "/a.yaml" - writeFileContent(wfA, ` + wfA := filepath.Join(dir, "a.yaml") + if err := writeFileContent(wfA, ` plugins: external: - name: foo version: "1.0" repository: "https://example.com/foo" modules: [] -`) +`); err != nil { + t.Fatalf("write a.yaml: %v", err) + } // Workflow B declares plugin "foo" (duplicate) and "bar" - wfB := dir + "/b.yaml" - writeFileContent(wfB, ` + wfB := filepath.Join(dir, "b.yaml") + if err := writeFileContent(wfB, ` plugins: external: - name: foo @@ -560,7 +563,9 @@ plugins: version: "1.0" repository: "https://example.com/bar" modules: [] -`) +`); err != nil { + t.Fatalf("write b.yaml: %v", err) + } appCfg := &ApplicationConfig{ Application: ApplicationInfo{ diff --git a/module/pipeline_step_workflow_call.go b/module/pipeline_step_workflow_call.go index fe6d2020..ed48de3b 100644 --- a/module/pipeline_step_workflow_call.go +++ b/module/pipeline_step_workflow_call.go @@ -139,7 +139,7 @@ func (s *WorkflowCallStep) Execute(ctx context.Context, pc *PipelineContext) (*S defer cancel() _, _ = target.Execute(asyncCtx, data) //nolint:errcheck }(ctx, triggerData) - return &StepResult{Output: map[string]any{"workflow": workflowName, "mode": "async", "dispatched": true}}, nil + return &StepResult{Output: map[string]any{"workflow": workflowName, "mode": "async", "dispatched": true}, Stop: s.stopPipeline}, nil } // Sync mode: apply timeout and wait for result diff --git a/plugin/integrity.go b/plugin/integrity.go index 16d14141..ecdd1218 100644 --- a/plugin/integrity.go +++ b/plugin/integrity.go @@ -52,7 +52,7 @@ func VerifyPluginIntegrity(pluginDir, pluginName string) error { binaryPath := filepath.Join(pluginDir, pluginName, pluginName) f, err := os.Open(binaryPath) if err != nil { - return fmt.Errorf("read plugin binary %s: %w", binaryPath, err) + return fmt.Errorf("open plugin binary %s: %w", binaryPath, err) } defer f.Close()