Skip to content

Commit 9dccf19

Browse files
intel352claude
andcommitted
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) <noreply@anthropic.com>
1 parent 1ba0a00 commit 9dccf19

8 files changed

Lines changed: 91 additions & 18 deletions

File tree

cmd/wfctl/registry_source.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ func (g *GitHubRegistrySource) ListPlugins() ([]string, error) {
6464
req.Header.Set("Authorization", "Bearer "+token)
6565
}
6666

67-
resp, err := http.DefaultClient.Do(req)
67+
resp, err := registryHTTPClient.Do(req)
6868
if err != nil {
6969
return nil, fmt.Errorf("list registry plugins from %s: %w", g.name, err)
7070
}
@@ -90,7 +90,11 @@ func (g *GitHubRegistrySource) FetchManifest(name string) (*RegistryManifest, er
9090
"https://raw.githubusercontent.com/%s/%s/%s/plugins/%s/manifest.json",
9191
g.owner, g.repo, g.branch, name,
9292
)
93-
resp, err := http.Get(url) //nolint:gosec // URL constructed from configured registry
93+
req, err := http.NewRequest(http.MethodGet, url, nil) //nolint:gosec // URL constructed from configured registry
94+
if err != nil {
95+
return nil, fmt.Errorf("build request: %w", err)
96+
}
97+
resp, err := registryHTTPClient.Do(req)
9498
if err != nil {
9599
return nil, fmt.Errorf("fetch manifest for %q from %s: %w", name, g.name, err)
96100
}
@@ -226,7 +230,8 @@ func (s *StaticRegistrySource) ListPlugins() ([]string, error) {
226230
return names, nil
227231
}
228232

229-
// registryHTTPClient is used for all registry HTTP requests with a reasonable timeout.
233+
// registryHTTPClient is used for all registry HTTP requests (both GitHub and static
234+
// sources) with a reasonable timeout to avoid hangs on network issues.
230235
var registryHTTPClient = &http.Client{Timeout: 30 * time.Second}
231236

232237
// fetch performs an HTTP GET with optional auth token.

config/config.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,25 @@ func MergeApplicationConfig(appCfg *ApplicationConfig) (*WorkflowConfig, error)
419419
}
420420

421421
combined.Modules = append(combined.Modules, wfCfg.Modules...)
422+
423+
// Merge external plugin declarations — deduplicate by name (first definition wins).
424+
if wfCfg.Plugins != nil && len(wfCfg.Plugins.External) > 0 {
425+
if combined.Plugins == nil {
426+
combined.Plugins = &PluginsConfig{}
427+
}
428+
existingPlugins := make(map[string]struct{}, len(combined.Plugins.External))
429+
for _, ep := range combined.Plugins.External {
430+
existingPlugins[ep.Name] = struct{}{}
431+
}
432+
for _, ep := range wfCfg.Plugins.External {
433+
if _, exists := existingPlugins[ep.Name]; exists {
434+
continue
435+
}
436+
combined.Plugins.External = append(combined.Plugins.External, ep)
437+
existingPlugins[ep.Name] = struct{}{}
438+
}
439+
}
440+
422441
for k, v := range wfCfg.Workflows {
423442
if existing, exists := combined.Workflows[k]; exists {
424443
// If the existing value is nil (e.g. `http:` with no body in YAML),

docs/PLUGIN_AUTHORING.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,17 @@ func (p *Provider) CreateModule(typeName, name string, config map[string]any) (s
101101

102102
## Plugin Manifest
103103

104-
The `plugin.json` declares what your plugin provides. The name should match what you passed to `wfctl plugin init`:
104+
The `plugin.json` at the project root declares what your plugin provides. The `name`
105+
field **must match the short name** you passed to `wfctl plugin init` (e.g. `my-plugin`).
106+
This is the name used by the engine for plugin discovery, the `requires.plugins` dependency
107+
check, and `wfctl plugin install`.
108+
109+
> **Note:** The scaffolded `internal/provider.go` returns a manifest with the name prefixed
110+
> as `workflow-plugin-<short-name>` (e.g. `workflow-plugin-my-plugin`). That longer form is
111+
> the canonical name used in the **public registry** (`workflow-registry`) and in release
112+
> artifact URLs. When referencing your plugin in a workflow config's `requires.plugins` or
113+
> `plugins.external`, use the same short name you put in `plugin.json` — the engine resolves
114+
> both forms automatically.
105115
106116
```json
107117
{

module/pipeline_step_workflow_call.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ func (s *WorkflowCallStep) Execute(ctx context.Context, pc *PipelineContext) (*S
139139
defer cancel()
140140
_, _ = target.Execute(asyncCtx, data) //nolint:errcheck
141141
}(ctx, triggerData)
142-
return &StepResult{Output: map[string]any{"workflow": s.workflow, "mode": "async", "dispatched": true}}, nil
142+
return &StepResult{Output: map[string]any{"workflow": workflowName, "mode": "async", "dispatched": true}}, nil
143143
}
144144

145145
// Sync mode: apply timeout and wait for result
@@ -148,7 +148,7 @@ func (s *WorkflowCallStep) Execute(ctx context.Context, pc *PipelineContext) (*S
148148

149149
childCtx, err := target.Execute(syncCtx, triggerData)
150150
if err != nil {
151-
return nil, fmt.Errorf("workflow_call step %q: workflow %q failed: %w", s.name, s.workflow, err)
151+
return nil, fmt.Errorf("workflow_call step %q: workflow %q failed: %w", s.name, workflowName, err)
152152
}
153153

154154
// Map outputs back to parent context

plugin/autofetch.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,37 @@ import (
1313
// It shells out to wfctl for the actual download/install logic.
1414
// version is an optional semver constraint (e.g., ">=0.1.0" or "0.2.0").
1515
func AutoFetchPlugin(pluginName, version, pluginDir string) error {
16+
return autoFetchPlugin(pluginName, version, pluginDir, nil)
17+
}
18+
19+
// autoFetchPlugin is the internal implementation that accepts an optional structured
20+
// logger. When logger is non-nil, status messages are emitted via slog instead of
21+
// writing directly to stderr.
22+
func autoFetchPlugin(pluginName, version, pluginDir string, logger *slog.Logger) error {
1623
// Check both pluginName and workflow-plugin-<pluginName> (or the short form
1724
// if pluginName already has the "workflow-plugin-" prefix).
1825
if isPluginInstalled(pluginName, pluginDir) {
1926
return nil
2027
}
2128

22-
fmt.Fprintf(os.Stderr, "[auto-fetch] Plugin %q not found locally, fetching from registry...\n", pluginName)
29+
if logger != nil {
30+
logger.Info("plugin not found locally, fetching from registry", "plugin", pluginName)
31+
} else {
32+
fmt.Fprintf(os.Stderr, "[auto-fetch] Plugin %q not found locally, fetching from registry...\n", pluginName)
33+
}
2334

2435
// Build install argument with version if specified.
2536
installArg := pluginName
2637
if version != "" {
2738
stripped, ok := stripVersionConstraint(version)
2839
if !ok {
2940
// Complex constraint (e.g. ">=0.1.0,<0.2.0") — install latest instead.
30-
fmt.Fprintf(os.Stderr, "[auto-fetch] Version constraint %q is complex; installing latest version of %q\n", version, pluginName)
41+
if logger != nil {
42+
logger.Warn("version constraint is complex; installing latest version",
43+
"plugin", pluginName, "constraint", version)
44+
} else {
45+
fmt.Fprintf(os.Stderr, "[auto-fetch] Version constraint %q is complex; installing latest version of %q\n", version, pluginName)
46+
}
3147
stripped = ""
3248
}
3349
if stripped != "" {
@@ -118,7 +134,20 @@ func AutoFetchDeclaredPlugins(decls []AutoFetchDecl, pluginDir string, logger *s
118134
return
119135
}
120136

121-
// Check wfctl availability once.
137+
// Scan for at least one AutoFetch=true entry before checking wfctl availability.
138+
// This avoids a misleading warning on startup when no plugins require auto-fetch.
139+
hasAutoFetch := false
140+
for _, d := range decls {
141+
if d.AutoFetch {
142+
hasAutoFetch = true
143+
break
144+
}
145+
}
146+
if !hasAutoFetch {
147+
return
148+
}
149+
150+
// Check wfctl availability once — only needed when auto-fetch is actually requested.
122151
if _, err := exec.LookPath("wfctl"); err != nil {
123152
if logger != nil {
124153
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
134163
}
135164
// Record whether the plugin was already present before fetching.
136165
alreadyPresent := isPluginInstalled(d.Name, pluginDir)
137-
if err := AutoFetchPlugin(d.Name, d.Version, pluginDir); err != nil {
166+
if err := autoFetchPlugin(d.Name, d.Version, pluginDir, logger); err != nil {
138167
if logger != nil {
139168
logger.Warn("auto-fetch failed for plugin", "plugin", d.Name, "error", err)
140169
}

plugin/autofetch_test.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,9 @@ func TestStripVersionConstraint(t *testing.T) {
8181
}
8282
}
8383

84-
// TestAutoFetchPlugin_CorrectArgs verifies that AutoFetchPlugin constructs the
85-
// expected wfctl arguments. We do this by ensuring the function short-circuits
86-
// when the plugin is already installed (not executing wfctl), which confirms
87-
// the plugin.json check is evaluated before any exec.Command call.
88-
func TestAutoFetchPlugin_CorrectArgs(t *testing.T) {
84+
// TestAutoFetchPlugin_SkipsWhenExists verifies that AutoFetchPlugin returns nil
85+
// immediately when the plugin is already installed, without invoking wfctl.
86+
func TestAutoFetchPlugin_SkipsWhenExists(t *testing.T) {
8987
dir := t.TempDir()
9088
pluginDir := filepath.Join(dir, "plugins")
9189
pluginName := "test-plugin"

plugin/integrity.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"crypto/sha256"
55
"encoding/hex"
66
"fmt"
7+
"io"
78
"os"
89
"path/filepath"
910
"strings"
@@ -49,13 +50,17 @@ func VerifyPluginIntegrity(pluginDir, pluginName string) error {
4950
}
5051

5152
binaryPath := filepath.Join(pluginDir, pluginName, pluginName)
52-
binaryData, err := os.ReadFile(binaryPath)
53+
f, err := os.Open(binaryPath)
5354
if err != nil {
5455
return fmt.Errorf("read plugin binary %s: %w", binaryPath, err)
5556
}
57+
defer f.Close()
5658

57-
h := sha256.Sum256(binaryData)
58-
got := hex.EncodeToString(h[:])
59+
h := sha256.New()
60+
if _, err := io.Copy(h, f); err != nil {
61+
return fmt.Errorf("hash plugin binary %s: %w", binaryPath, err)
62+
}
63+
got := hex.EncodeToString(h.Sum(nil))
5964
if !strings.EqualFold(got, entry.SHA256) {
6065
return fmt.Errorf("plugin %q integrity check failed: binary checksum %s does not match lockfile %s", pluginName, got, entry.SHA256)
6166
}

plugin/integrity_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ func TestVerifyPluginIntegrity_UnreadableLockfile(t *testing.T) {
5757
t.Fatalf("write lockfile: %v", err)
5858
}
5959

60+
// POSIX permission bits are not enforced on all platforms (e.g. Windows, or
61+
// when running as root). Verify the file is actually unreadable before
62+
// asserting fail-closed behaviour.
63+
if _, err := os.ReadFile(p); err == nil {
64+
t.Skip("lockfile is readable despite 0000 permissions (platform or root); skipping test")
65+
}
66+
6067
err := VerifyPluginIntegrity(filepath.Join(dir, "plugins"), "my-plugin")
6168
if err == nil {
6269
t.Error("expected error when lockfile is unreadable, got nil (fail-open)")

0 commit comments

Comments
 (0)