Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func init() {
RootCmd.PersistentFlags().Bool("all-commands", false, "display all available commands and their flags in tree format")
RootCmd.PersistentFlags().Bool("skip-auth", false, "skip authentication checks (for advanced users)")
RootCmd.PersistentFlags().Bool("force-interactive", false, "force setup wizards to run even if already completed")
RootCmd.PersistentFlags().Duration("plugin-discovery-timeout", 2*time.Second, "timeout for plugin discovery (0s disables)")
RootCmd.PersistentFlags().Duration("plugin-discovery-timeout", 3*time.Second, "timeout for plugin discovery (0s disables)")

// Make some of these flags available via Viper
_ = viper.BindPFlag("config", RootCmd.PersistentFlags().Lookup("config"))
Expand Down
2 changes: 1 addition & 1 deletion docs/development/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Plugins are deduplicated by `manifest.name` (not by filename). If multiple binar

### Timeouts

- Overall discovery is bounded by the global flag `--plugin-discovery-timeout` (default `2s`).
- Overall discovery is bounded by the global flag `--plugin-discovery-timeout` (default `3s`).
- Set to `0s` to disable plugin discovery entirely.
- Manifest retrieval is bounded by `plugin.manifest_timeout_ms` (default `500ms`).

Expand Down
2 changes: 1 addition & 1 deletion docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ dr templates list --verbose
dr templates list --debug
# Timeout for plugin discovery (0s disables discovery)
dr --plugin-discovery-timeout 2s --help
dr --plugin-discovery-timeout 3s --help
```

> [!WARNING]
Expand Down
173 changes: 173 additions & 0 deletions internal/plugin/cli_version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Copyright 2026 DataRobot, Inc. and its affiliates.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package plugin

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCompatibleCLIVersion(t *testing.T) {
tests := []struct {
name string
cliVersion string
constraint string
compatible bool
expectErr bool
}{
// No constraint - always compatible
{
name: "empty constraint is always compatible",
cliVersion: "1.0.0",
constraint: "",
compatible: true,
},
// Dev version - always compatible
{
name: "dev CLI version is always compatible",
cliVersion: "dev",
constraint: ">=1.0.0",
compatible: true,
},
// Latest CLI + plugin with no CLI constraints
{
name: "latest CLI with no constraints",
cliVersion: "2.0.0",
constraint: "",
compatible: true,
},
// Latest CLI + plugin with CLI min version (using >= constraint)
{
name: "latest CLI satisfies min version constraint",
cliVersion: "2.0.0",
constraint: ">=1.0.0",
compatible: true,
},
// Latest CLI + plugin with CLI max version (using < constraint)
{
name: "latest CLI within max version constraint",
cliVersion: "1.5.0",
constraint: "< 2.0.0",
compatible: true,
},
{
name: "latest CLI exceeds max version constraint",
cliVersion: "2.0.0",
constraint: "< 2.0.0",
compatible: false,
},
// Old CLI + plugin with no CLI constraints
{
name: "old CLI with no constraints",
cliVersion: "0.1.0",
constraint: "",
compatible: true,
},
// Old CLI + plugin with CLI min version → incompatible
{
name: "old CLI below min version constraint",
cliVersion: "0.1.0",
constraint: ">=1.0.0",
compatible: false,
},
// Old CLI + plugin with CLI max version
{
name: "old CLI within max version constraint",
cliVersion: "0.1.0",
constraint: "< 2.0.0",
compatible: true,
},
// Semver constraint patterns
{
name: "caret constraint compatible",
cliVersion: "1.5.0",
constraint: "^1.0.0",
compatible: true,
},
{
name: "caret constraint incompatible major bump",
cliVersion: "2.0.0",
constraint: "^1.0.0",
compatible: false,
},
{
name: "tilde constraint compatible",
cliVersion: "1.0.5",
constraint: "~1.0.0",
compatible: true,
},
{
name: "tilde constraint incompatible minor bump",
cliVersion: "1.1.0",
constraint: "~1.0.0",
compatible: false,
},
{
name: "exact version match",
cliVersion: "1.2.3",
constraint: "1.2.3",
compatible: true,
},
{
name: "exact version mismatch",
cliVersion: "1.2.4",
constraint: "1.2.3",
compatible: false,
},
{
name: "range constraint compatible",
cliVersion: "1.5.0",
constraint: ">= 1.0.0, < 2.0.0",
compatible: true,
},
{
name: "range constraint incompatible",
cliVersion: "2.0.0",
constraint: ">= 1.0.0, < 2.0.0",
compatible: false,
},
// Error cases
{
name: "invalid constraint syntax",
cliVersion: "1.0.0",
constraint: "@invalid",
compatible: false,
expectErr: true,
},
{
name: "invalid CLI version",
cliVersion: "not-semver",
constraint: ">=1.0.0",
compatible: false,
expectErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := compatibleCLIVersion(tt.cliVersion, tt.constraint)

if tt.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}

assert.Equal(t, tt.compatible, result)
})
}
}
54 changes: 52 additions & 2 deletions internal/plugin/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import (
"strings"
"time"

"github.com/Masterminds/semver/v3"
"github.com/datarobot/cli/internal/log"
"github.com/datarobot/cli/internal/repo"
"github.com/datarobot/cli/internal/version"
"github.com/spf13/viper"
)

Expand Down Expand Up @@ -160,6 +162,21 @@ func loadManagedPlugin(dir, name string, seen map[string]bool) (*DiscoveredPlugi
return nil, nil
}

compatible, err := compatibleCLIVersion(version.Version, manifest.CLIVersion)
if err != nil {
log.Warn("Cannot parse cli version constraint for plugin",
"name", manifest.Name,
"installed", version.Version,
"constraint", manifest.CLIVersion)
} else if !compatible {
log.Warn("Plugin is incompatible with DataRobot CLI version",
"name", manifest.Name,
"installed", version.Version,
"constraint", manifest.CLIVersion)

return nil, nil
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parse error in version check silently loads plugin

Medium Severity

When compatibleCLIVersion returns an error (e.g., malformed constraint like @invalid), the if err != nil branch only logs a warning and falls through, so the plugin is still loaded. The else if !compatible branch that skips the plugin is never reached when err != nil. This means compatibleCLIVersion returns false on error, but the callers ignore that false value and load the plugin anyway. Both loadManagedPlugin and discoverInDir have this same pattern, making constraint enforcement ineffective for malformed constraints.

Additional Locations (1)

Fix in Cursor Fix in Web


executable, err := resolvePlatformExecutable(pluginDir, &manifest)
if err != nil {
return nil, err
Expand Down Expand Up @@ -205,7 +222,7 @@ func errMissingManifestField(field string) error {
return errors.New("plugin manifest missing required field: " + field)
}

func discoverInDir(dir string, seen map[string]bool) ([]DiscoveredPlugin, []error) {
func discoverInDir(dir string, seen map[string]bool) ([]DiscoveredPlugin, []error) { //nolint: cyclop
plugins := make([]DiscoveredPlugin, 0)

var errors []error
Expand Down Expand Up @@ -242,7 +259,6 @@ func discoverInDir(dir string, seen map[string]bool) ([]DiscoveredPlugin, []erro
manifest, err := getManifest(fullPath)
if err != nil {
errors = append(errors, err)

continue
}

Expand All @@ -255,6 +271,21 @@ func discoverInDir(dir string, seen map[string]bool) ([]DiscoveredPlugin, []erro
continue
}

compatible, err := compatibleCLIVersion(version.Version, manifest.CLIVersion)
if err != nil {
log.Warn("Cannot parse cli version constraint for plugin",
"name", manifest.Name,
"installed", version.Version,
"constraint", manifest.CLIVersion)
} else if !compatible {
log.Warn("Plugin is incompatible with DataRobot CLI version",
"name", manifest.Name,
"installed", version.Version,
"constraint", manifest.CLIVersion)

continue
}

seen[manifest.Name] = true

plugins = append(plugins, DiscoveredPlugin{
Expand Down Expand Up @@ -299,3 +330,22 @@ func getManifest(executable string) (*PluginManifest, error) {

return &manifest, nil
}

func compatibleCLIVersion(cliVersion, constraint string) (bool, error) {
if cliVersion == "dev" || constraint == "" {
return true, nil
}

c, err := semver.NewConstraint(constraint)
if err != nil {
return false, err
}

v, err := semver.NewVersion(cliVersion)
if err != nil {
return false, err
}

// Check if the version meets the constraints.
return c.Check(v), nil
}
6 changes: 3 additions & 3 deletions internal/plugin/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ func TestLivePluginManifests(t *testing.T) {
// Validate version format
assert.Regexp(t, `^v?\d+\.\d+\.\d+`, manifest.Version, "Manifest %s version must be semver", manifestPath)

// Validate minCLIVersion format if present
if manifest.MinCLIVersion != "" {
assert.Regexp(t, `^v?\d+\.\d+\.\d+`, manifest.MinCLIVersion, "Manifest %s minCLIVersion must be semver", manifestPath)
// Validate CLIVersion format if present
if manifest.CLIVersion != "" {
assert.Regexp(t, `^v?\d+\.\d+\.\d+`, manifest.CLIVersion, "Manifest %s CLIVersion must be semver", manifestPath)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test regex rejects valid semver constraint syntax

Medium Severity

The validation regex ^v?\d+\.\d+\.\d+ for CLIVersion only accepts plain version strings (e.g., 0.2.0). Since CLIVersion now supports arbitrary semver constraints (e.g., >=1.0.0, ^1.2.0, < 2.0.0), this test will fail whenever a plugin manifest uses constraint syntax. The regex was carried over from when the field was minCLIVersion (always a bare version), but it no longer matches the new field's semantics.

Fix in Cursor Fix in Web

}
})
}
Expand Down
1 change: 0 additions & 1 deletion internal/plugin/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import (
"time"

"github.com/Masterminds/semver/v3"

"github.com/codeclysm/extract/v4"
"github.com/datarobot/cli/internal/config"
"github.com/datarobot/cli/internal/log"
Expand Down
4 changes: 2 additions & 2 deletions internal/plugin/remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func TestPluginManifestSchema(t *testing.T) {
"name":"test",
"version":"1.0.0",
"description":"Test plugin",
"minCLIVersion":"0.2.0",
"cliVersion":"0.2.0",
"authentication":true,
"scripts":{
"posix":"scripts/test.sh",
Expand All @@ -120,7 +120,7 @@ func TestPluginManifestSchema(t *testing.T) {
assert.Equal(t, "test", m.Name)
assert.Equal(t, "1.0.0", m.Version)
assert.Equal(t, "Test plugin", m.Description)
assert.Equal(t, "0.2.0", m.MinCLIVersion)
assert.Equal(t, "0.2.0", m.CLIVersion)
assert.True(t, m.Authentication)
require.NotNil(t, m.Scripts)
assert.Equal(t, "scripts/test.sh", m.Scripts.Posix)
Expand Down
4 changes: 2 additions & 2 deletions internal/plugin/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ type BasicPluginManifest struct {
// Embeds BasicPluginManifest and adds additional fields for managed plugins.
type PluginManifest struct {
BasicPluginManifest
Scripts *PluginScripts `json:"scripts,omitempty"` // Platform-specific script paths
MinCLIVersion string `json:"minCLIVersion,omitempty"` // Minimum CLI version required
Scripts *PluginScripts `json:"scripts,omitempty"` // Platform-specific script paths
CLIVersion string `json:"cliVersion,omitempty"` // Minimum CLI version required
Comment on lines 42 to +44
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is backwards incompatible, but I'm not seeing an update in the plugin that would be emitting the wrong version. So either we should support both names (good ole backwards compat), or at least fix the assist plugin in this PR to use the new one with a new version.

}

// RegistryVersion represents a specific version in the plugin registry
Expand Down
6 changes: 3 additions & 3 deletions internal/plugin/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import (
)

// ValidatePluginScript validates that a plugin script outputs a manifest matching the expected manifest.
// All fields must match exactly, including Scripts and MinCLIVersion for managed plugins.
// All fields must match exactly, including Scripts and CLIVersion for managed plugins.
func ValidatePluginScript(pluginDir string, expectedManifest PluginManifest) error {
if err := ValidateLicense(pluginDir); err != nil {
return err
Expand Down Expand Up @@ -70,9 +70,9 @@ func ValidateLicense(pluginDir string) error {
// validateManifests compares two manifests and returns an error if they differ.
func validateManifests(expected, actual PluginManifest) error {
opts := cmp.Options{
// Ignore Scripts and MinCLIVersion - they're optional managed plugin fields
// Ignore Scripts and CLIVersion - they're optional managed plugin fields
cmp.FilterPath(func(p cmp.Path) bool {
return p.String() == "Scripts" || p.String() == "MinCLIVersion"
return p.String() == "Scripts" || p.String() == "CLIVersion"
}, cmp.Ignore()),
}

Expand Down
Loading
Loading