diff --git a/go.mod b/go.mod index 8ab6426c..c738aa8f 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/yuin/goldmark v1.8.5 github.com/zalando/go-keyring v0.2.8 golang.design/x/hotkey v0.6.1 + golang.org/x/mod v0.38.0 golang.org/x/net v0.58.0 golang.org/x/oauth2 v0.36.0 golang.org/x/text v0.41.0 @@ -75,7 +76,6 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/crypto v0.55.0 // indirect golang.org/x/image v0.43.0 // indirect - golang.org/x/mod v0.38.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/time v0.15.0 // indirect diff --git a/internal/services/pluginsvc/pluginservice.go b/internal/services/pluginsvc/pluginservice.go index 40f72b03..ca915df1 100644 --- a/internal/services/pluginsvc/pluginservice.go +++ b/internal/services/pluginsvc/pluginservice.go @@ -18,6 +18,8 @@ import ( "sort" "strings" + "golang.org/x/mod/semver" + "github.com/alicoding/mill/internal/adapters/windowing" "github.com/alicoding/mill/internal/services/guardrailsvc" ) @@ -89,15 +91,17 @@ var knownCapabilities = map[string]bool{ var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) // PluginService is Wails-bound. openURL is injected so tests never -// shell out to the real OS handler. +// shell out to the real OS handler. appVersion is the build-stamped +// Mill version minMillVersion enforcement compares against. type PluginService struct { - dir string - guardrail *guardrailsvc.GuardrailService - openURL func(url string) error + dir string + guardrail *guardrailsvc.GuardrailService + openURL func(url string) error + appVersion string } -func New(dir string, guardrail *guardrailsvc.GuardrailService) *PluginService { - return &PluginService{dir: dir, guardrail: guardrail, openURL: windowing.OpenURL} +func New(dir string, guardrail *guardrailsvc.GuardrailService, appVersion string) *PluginService { + return &PluginService{dir: dir, guardrail: guardrail, openURL: windowing.OpenURL, appVersion: appVersion} } // PluginsDir returns the directory plugins are installed into -- @@ -193,9 +197,42 @@ func (p *PluginService) scanOne(folder string) PluginInfo { if info.Error == "" { info.Error = validateContributes(m.Contributes) } + if info.Error == "" { + info.Error = checkMinMillVersion(m.MinMillVersion, p.appVersion) + } return info } +// checkMinMillVersion refuses a plugin that declares it needs a newer +// Mill (the converged app-plugin convention: plugins version against +// the APP's version, never a separate API number -- docs/goals/0245's +// stability contract). The app's prerelease/build tags are stripped +// before comparing: a beta is stamped against the NEXT release +// (main.go's build-stamp trio documents exactly this), so plain +// semver would rank it below that release's minimum forever. A +// malformed minimum fails closed like any other manifest error; an +// unparseable app version (an unstamped source build) skips +// enforcement rather than refusing every version-pinned plugin. +func checkMinMillVersion(minVersion, appVersion string) string { + if strings.TrimSpace(minVersion) == "" { + return "" + } + minV := "v" + strings.TrimPrefix(minVersion, "v") + if !semver.IsValid(minV) { + return fmt.Sprintf("the manifest minMillVersion %q must be a version like \"1.2.3\"", minVersion) + } + appV := "v" + strings.TrimPrefix(appVersion, "v") + if !semver.IsValid(appV) { + return "" + } + appV = strings.TrimSuffix(appV, semver.Build(appV)) + appV = strings.TrimSuffix(appV, semver.Prerelease(appV)) + if semver.Compare(appV, minV) < 0 { + return fmt.Sprintf("needs Mill %s or newer -- this is Mill %s", minVersion, appVersion) + } + return "" +} + // fileExtensionPattern pins a contributed extension claim to the // ".ext" shape the drop router compares against (unitRegistry's own // extensionOf yields a lowercased dot-prefixed extension). diff --git a/internal/services/pluginsvc/pluginservice_test.go b/internal/services/pluginsvc/pluginservice_test.go index 7368cfc0..aa2862ad 100644 --- a/internal/services/pluginsvc/pluginservice_test.go +++ b/internal/services/pluginsvc/pluginservice_test.go @@ -38,7 +38,7 @@ func TestListPlugins_ValidAndInvalidRows(t *testing.T) { writePlugin(t, root, "wrong-id", `{"id":"other","name":"X","version":"1"}`, nil) writePlugin(t, root, "bad-cap", `{"id":"bad-cap","name":"X","version":"1","capabilities":["format-disk"]}`, nil) - svc := New(root, nil) + svc := New(root, nil, "1.0.0") infos, err := svc.ListPlugins() if err != nil { t.Fatal(err) @@ -62,7 +62,7 @@ func TestListPlugins_ValidAndInvalidRows(t *testing.T) { } func TestListPlugins_MissingDirIsEmptyNotError(t *testing.T) { - svc := New(filepath.Join(t.TempDir(), "never-created"), nil) + svc := New(filepath.Join(t.TempDir(), "never-created"), nil, "1.0.0") infos, err := svc.ListPlugins() if err != nil || len(infos) != 0 { t.Fatalf("want empty, no error; got %d infos, err %v", len(infos), err) @@ -86,7 +86,7 @@ func TestAssetMiddleware_ServesOnlyValidPluginAllowlistedFiles(t *testing.T) { "secret.txt": "nope", }) writePlugin(t, root, "broken", `{not json`, nil) - svc := New(root, nil) + svc := New(root, nil, "1.0.0") if rec := serveThrough(t, svc, "/plugins/good-one/main.js"); rec.Code != http.StatusOK || !strings.Contains(rec.Header().Get("Content-Type"), "javascript") { t.Fatalf("main.js: code %d type %q", rec.Code, rec.Header().Get("Content-Type")) @@ -113,7 +113,7 @@ func TestRequestGuardedAction_UndeclaredCapabilityRefusedBeforeRules(t *testing. writePlugin(t, root, "quiet-one", `{"id":"quiet-one","name":"Q","version":"1","capabilities":[]}`, nil) // guardrail nil: proves the refusal happens BEFORE any rule // evaluation could run (a nil-deref here would fail the test). - svc := New(root, nil) + svc := New(root, nil, "1.0.0") _, err := svc.RequestGuardedAction("quiet-one", "open-url", map[string]string{"url": "https://example.com"}, "test") if err == nil || !strings.Contains(err.Error(), "does not declare") { t.Fatalf("want undeclared-capability refusal, got %v", err) @@ -121,7 +121,7 @@ func TestRequestGuardedAction_UndeclaredCapabilityRefusedBeforeRules(t *testing. } func TestPerform_OpenURLRejectsNonHTTP(t *testing.T) { - svc := New(t.TempDir(), nil) + svc := New(t.TempDir(), nil, "1.0.0") var opened string svc.openURL = func(u string) error { opened = u; return nil } if _, err := svc.perform("open-url", map[string]string{"url": "file:///etc/passwd"}); err == nil { @@ -142,7 +142,7 @@ func TestListPlugins_ValidatesContributes(t *testing.T) { writePlugin(t, root, "bad-kind", `{"id":"bad-kind","name":"C","version":"1","contributes":{"canvasObjects":[{"kind":"Not A Slug"}]}}`, nil) writePlugin(t, root, "bad-ext", `{"id":"bad-ext","name":"C","version":"1","contributes":{"canvasObjects":[{"kind":"thing","fileExtensions":["webloc"]}]}}`, nil) - svc := New(root, nil) + svc := New(root, nil, "1.0.0") infos, err := svc.ListPlugins() if err != nil { t.Fatal(err) @@ -170,9 +170,53 @@ func TestURLPasteClaims_ValidClaimersOnly(t *testing.T) { writePlugin(t, root, "no-claim", `{"id":"no-claim","name":"N","version":"1"}`, nil) writePlugin(t, root, "broken-claimer", `{"id":"broken-claimer","name":"X","version":"1","capabilities":["format-disk"],"contributes":{"canvasObjects":[{"kind":"thing","pastesURLs":true}]}}`, nil) - svc := New(root, nil) + svc := New(root, nil, "1.0.0") claims := svc.URLPasteClaims() if len(claims) != 1 || claims[0].PluginID != "bookmarker" || claims[0].Kind != "bookmark" { t.Fatalf("URLPasteClaims() = %+v, want exactly bookmarker/bookmark", claims) } } + +// minMillVersion enforcement (docs/goals/0245's stability contract): +// a plugin needing a newer Mill is refused with both versions named; +// a beta build counts as the release it is stamped against; a +// malformed minimum fails closed; an unstamped app skips enforcement. +func TestCheckMinMillVersion(t *testing.T) { + cases := []struct { + name, min, app, wantSubstr string + }{ + {"too new refused", "9.9.9", "1.0.0", "needs Mill 9.9.9"}, + {"equal loads", "1.0.0", "1.0.0", ""}, + {"older minimum loads", "0.5.0", "1.0.0", ""}, + {"beta counts as its stamped release", "1.0.0", "1.0.0-beta.7", ""}, + {"beta still refused below core", "1.1.0", "1.0.0-beta.7", "needs Mill 1.1.0"}, + {"malformed minimum fails closed", "not-a-version", "1.0.0", "must be a version"}, + {"empty minimum is no constraint", "", "1.0.0", ""}, + {"unstamped app skips enforcement", "1.0.0", "", ""}, + } + for _, c := range cases { + got := checkMinMillVersion(c.min, c.app) + if c.wantSubstr == "" && got != "" { + t.Errorf("%s: checkMinMillVersion(%q, %q) = %q, want valid", c.name, c.min, c.app, got) + } + if c.wantSubstr != "" && !strings.Contains(got, c.wantSubstr) { + t.Errorf("%s: checkMinMillVersion(%q, %q) = %q, want it to contain %q", c.name, c.min, c.app, got, c.wantSubstr) + } + } +} + +// The check runs inside the scan itself, so every valid-manifest-only +// consumer (loader, assets, ingestion claims, guarded actions) +// inherits the refusal. +func TestListPlugins_EnforcesMinMillVersion(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "too-new", `{"id":"too-new","name":"T","version":"1","minMillVersion":"99.0.0"}`, nil) + svc := New(root, nil, "1.0.0") + infos, err := svc.ListPlugins() + if err != nil || len(infos) != 1 { + t.Fatalf("ListPlugins() = %+v err=%v, want 1 row", infos, err) + } + if !strings.Contains(infos[0].Error, "needs Mill 99.0.0") { + t.Fatalf("Error = %q, want the version refusal naming the minimum", infos[0].Error) + } +} diff --git a/internal/services/wiring/plugins.go b/internal/services/wiring/plugins.go index c41d6f90..844fea28 100644 --- a/internal/services/wiring/plugins.go +++ b/internal/services/wiring/plugins.go @@ -19,12 +19,20 @@ import ( // (/plugins//), so MILL_SETTINGS_PATH isolation covers // plugins for free; MILL_PLUGINS_DIR overrides independently for // fixture-driven tests. -func NewPluginService(settingsPath string, guardrail *guardrailsvc.GuardrailService) *pluginsvc.PluginService { +func NewPluginService(settingsPath string, guardrail *guardrailsvc.GuardrailService, channel, appVersion string) *pluginsvc.PluginService { dir := os.Getenv("MILL_PLUGINS_DIR") if dir == "" { dir = filepath.Join(filepath.Dir(settingsPath), "plugins") } - return pluginsvc.New(dir, guardrail) + // A source build's version constant is the LAST release, not this + // build's real lineage (main.go's build-stamp trio: only beta/ + // stable builds get stamped) -- enforcing minMillVersion against + // it would refuse a pinned plugin on the freshest possible code, + // so an unstamped build skips enforcement entirely. + if channel == "source" { + appVersion = "" + } + return pluginsvc.New(dir, guardrail, appVersion) } // ComposedAssetMiddleware chains the remote-auth gate (server builds diff --git a/internal/services/wiring/plugins_test.go b/internal/services/wiring/plugins_test.go new file mode 100644 index 00000000..d230aab1 --- /dev/null +++ b/internal/services/wiring/plugins_test.go @@ -0,0 +1,48 @@ +package wiring + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeVersionPinnedPlugin(t *testing.T, root string) { + t.Helper() + dir := filepath.Join(root, "plugins", "pinned") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + manifest := `{"id":"pinned","name":"P","version":"1","minMillVersion":"9.9.9"}` + for name, content := range map[string]string{"manifest.json": manifest, "main.js": "export function activate() {}"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } +} + +// A source build's version constant is the last release, not this +// build's lineage -- minMillVersion enforcement applies only to +// stamped (beta/stable) builds, so a pinned plugin is never refused +// on the freshest possible code (docs/goals/0245). +func TestNewPluginService_SourceChannelSkipsMinVersionEnforcement(t *testing.T) { + root := t.TempDir() + writeVersionPinnedPlugin(t, root) + settingsPath := filepath.Join(root, "settings.json") + + srcInfos, err := NewPluginService(settingsPath, nil, "source", "0.5.0").ListPlugins() + if err != nil || len(srcInfos) != 1 { + t.Fatalf("source ListPlugins() = %+v err=%v, want 1 row", srcInfos, err) + } + if srcInfos[0].Error != "" { + t.Fatalf("source build refused the pinned plugin: %q", srcInfos[0].Error) + } + + betaInfos, err := NewPluginService(settingsPath, nil, "beta", "0.5.0").ListPlugins() + if err != nil || len(betaInfos) != 1 { + t.Fatalf("beta ListPlugins() = %+v err=%v, want 1 row", betaInfos, err) + } + if !strings.Contains(betaInfos[0].Error, "needs Mill 9.9.9") { + t.Fatalf("beta build Error = %q, want the version refusal", betaInfos[0].Error) + } +} diff --git a/main.go b/main.go index 2e1432bd..99cf039a 100644 --- a/main.go +++ b/main.go @@ -189,7 +189,7 @@ func main() { logger.Error("migrate legacy MCP pending writes", "error", err) } guardrailService := guardrailsvc.NewGuardrailService(settingsStore, compositionService) - pluginService := wiring.NewPluginService(settingsPath, guardrailService) + pluginService := wiring.NewPluginService(settingsPath, guardrailService, millChannel, millUpdateVersion) // docs/goals/0240 S1: the coding loop's Confirm-screen preview -- // read-only over guardrailService.Rules(). Its ExecutionService // dependency (goal 0240 S2, RunCommandBlock's own doc comment) is diff --git a/userdocs/llms-full.txt b/userdocs/llms-full.txt index 873c77a3..99222e77 100644 --- a/userdocs/llms-full.txt +++ b/userdocs/llms-full.txt @@ -1547,18 +1547,25 @@ structural change, and any drift is caught immediately — the suite breaks the moment a field's meaning or a renderer's contract changes underneath an existing tool. -**Not promised:** no semver, no deprecation window, and no -compatibility guarantee on the *runtime* API — `commit`'s own -signature shape, the exact set of `AtlasService` RPCs a tool may call, -or any shared renderer's internal behavior — until there is a second -adopter outside this repo to actually break. Today every "adopter" is -one of Mill's own nine files, changed in the same pull request as any -platform change that affects it, so nothing here has ever needed to -stay backward compatible. That's stated honestly now on purpose: a -platform that only discovers its own contract gaps after outside -adopters build against them pays for that omission for years. The -out-of-tree tier this page doesn't yet promise gets built once a real -outside adopter needs it, not before. +**Not promised for compiled-in tools:** no semver, no deprecation +window, and no compatibility guarantee on the *runtime* API — +`commit`'s own signature shape, the exact set of `AtlasService` RPCs a +tool may call, or any shared renderer's internal behavior. Every +compiled-in adopter is one of Mill's own files, changed in the same +pull request as any platform change that affects it, so nothing here +needs to stay backward compatible. + +**Promised for runtime plugins:** the plugin surface — the manifest +schema, the `activate(api)` shape and its argument's methods, the +`renderFace` contract, and the payload keys each `source` implies — is +versioned against **Mill's own version**, the way desktop app-plugin +ecosystems converge on versioning against the app rather than a +separate API number. A plugin pins the Mill it needs with +`minMillVersion` in its manifest; a Mill older than that refuses to +load it, saying so on its Extensions row, instead of half-running it +against a surface it predates. Within versions that satisfy the +minimum, an existing manifest field or `api` method keeps its meaning +— growth is additive. --- diff --git a/userdocs/reference/extending-the-canvas.md b/userdocs/reference/extending-the-canvas.md index 85e7ff15..e3664358 100644 --- a/userdocs/reference/extending-the-canvas.md +++ b/userdocs/reference/extending-the-canvas.md @@ -212,15 +212,22 @@ structural change, and any drift is caught immediately — the suite breaks the moment a field's meaning or a renderer's contract changes underneath an existing tool. -**Not promised:** no semver, no deprecation window, and no -compatibility guarantee on the *runtime* API — `commit`'s own -signature shape, the exact set of `AtlasService` RPCs a tool may call, -or any shared renderer's internal behavior — until there is a second -adopter outside this repo to actually break. Today every "adopter" is -one of Mill's own nine files, changed in the same pull request as any -platform change that affects it, so nothing here has ever needed to -stay backward compatible. That's stated honestly now on purpose: a -platform that only discovers its own contract gaps after outside -adopters build against them pays for that omission for years. The -out-of-tree tier this page doesn't yet promise gets built once a real -outside adopter needs it, not before. +**Not promised for compiled-in tools:** no semver, no deprecation +window, and no compatibility guarantee on the *runtime* API — +`commit`'s own signature shape, the exact set of `AtlasService` RPCs a +tool may call, or any shared renderer's internal behavior. Every +compiled-in adopter is one of Mill's own files, changed in the same +pull request as any platform change that affects it, so nothing here +needs to stay backward compatible. + +**Promised for runtime plugins:** the plugin surface — the manifest +schema, the `activate(api)` shape and its argument's methods, the +`renderFace` contract, and the payload keys each `source` implies — is +versioned against **Mill's own version**, the way desktop app-plugin +ecosystems converge on versioning against the app rather than a +separate API number. A plugin pins the Mill it needs with +`minMillVersion` in its manifest; a Mill older than that refuses to +load it, saying so on its Extensions row, instead of half-running it +against a surface it predates. Within versions that satisfy the +minimum, an existing manifest field or `api` method keeps its meaning +— growth is additive. diff --git a/userdocs/reference/install-a-plugin.md b/userdocs/reference/install-a-plugin.md index e7f9c217..317d83ca 100644 --- a/userdocs/reference/install-a-plugin.md +++ b/userdocs/reference/install-a-plugin.md @@ -16,7 +16,9 @@ installing one is copying that folder into Mill's plugins folder. A plugin that can't load shows exactly why on its row — a missing file, invalid manifest, or a capability Mill doesn't recognize — -instead of silently doing nothing. +instead of silently doing nothing. A plugin whose manifest sets +`minMillVersion` to a version newer than your Mill is refused the +same visible way: update Mill, then reload. ## Turning a plugin off