Skip to content

Commit d50670a

Browse files
alicodingclaude
andauthored
feat: minMillVersion is enforced — a plugin needing a newer Mill is refused, visibly (goal 0245) (#518)
* feat: minMillVersion is enforced — a plugin needing a newer Mill is refused, visibly (goal 0245) The stability contract's teeth: the plugin surface versions against Mill's own version (the converged app-plugin model), and the manifest's minMillVersion — parsed but never checked until now — is enforced at scan, so every valid-manifest-only consumer (loader, assets, ingestion claims, guarded actions) inherits the refusal. The Extensions row names both versions; a beta satisfies the release it is stamped against (prerelease stripped before compare); a malformed minimum fails closed; an unstamped source build skips enforcement. Stability promise recorded in the Extending-the-canvas docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq * fix: minMillVersion enforcement applies only to stamped builds — a source build's version const is the last release, not this build's lineage Caught by CI: the e2e/dev server builds unstamped (version const 0.5.0) and refused mill-bookmark's 0.9.0 pin -- the freshest possible code failing a minimum it satisfies. Channel-aware now: source skips, beta/ stable enforce; pinned-plugin behavior proven per channel in wiring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1036d21 commit d50670a

9 files changed

Lines changed: 195 additions & 42 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ require (
2626
github.com/yuin/goldmark v1.8.5
2727
github.com/zalando/go-keyring v0.2.8
2828
golang.design/x/hotkey v0.6.1
29+
golang.org/x/mod v0.38.0
2930
golang.org/x/net v0.58.0
3031
golang.org/x/oauth2 v0.36.0
3132
golang.org/x/text v0.41.0
@@ -75,7 +76,6 @@ require (
7576
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
7677
golang.org/x/crypto v0.55.0 // indirect
7778
golang.org/x/image v0.43.0 // indirect
78-
golang.org/x/mod v0.38.0 // indirect
7979
golang.org/x/sync v0.22.0 // indirect
8080
golang.org/x/sys v0.47.0 // indirect
8181
golang.org/x/time v0.15.0 // indirect

internal/services/pluginsvc/pluginservice.go

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"sort"
1919
"strings"
2020

21+
"golang.org/x/mod/semver"
22+
2123
"github.com/alicoding/mill/internal/adapters/windowing"
2224
"github.com/alicoding/mill/internal/services/guardrailsvc"
2325
)
@@ -89,15 +91,17 @@ var knownCapabilities = map[string]bool{
8991
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
9092

9193
// PluginService is Wails-bound. openURL is injected so tests never
92-
// shell out to the real OS handler.
94+
// shell out to the real OS handler. appVersion is the build-stamped
95+
// Mill version minMillVersion enforcement compares against.
9396
type PluginService struct {
94-
dir string
95-
guardrail *guardrailsvc.GuardrailService
96-
openURL func(url string) error
97+
dir string
98+
guardrail *guardrailsvc.GuardrailService
99+
openURL func(url string) error
100+
appVersion string
97101
}
98102

99-
func New(dir string, guardrail *guardrailsvc.GuardrailService) *PluginService {
100-
return &PluginService{dir: dir, guardrail: guardrail, openURL: windowing.OpenURL}
103+
func New(dir string, guardrail *guardrailsvc.GuardrailService, appVersion string) *PluginService {
104+
return &PluginService{dir: dir, guardrail: guardrail, openURL: windowing.OpenURL, appVersion: appVersion}
101105
}
102106

103107
// PluginsDir returns the directory plugins are installed into --
@@ -193,9 +197,42 @@ func (p *PluginService) scanOne(folder string) PluginInfo {
193197
if info.Error == "" {
194198
info.Error = validateContributes(m.Contributes)
195199
}
200+
if info.Error == "" {
201+
info.Error = checkMinMillVersion(m.MinMillVersion, p.appVersion)
202+
}
196203
return info
197204
}
198205

206+
// checkMinMillVersion refuses a plugin that declares it needs a newer
207+
// Mill (the converged app-plugin convention: plugins version against
208+
// the APP's version, never a separate API number -- docs/goals/0245's
209+
// stability contract). The app's prerelease/build tags are stripped
210+
// before comparing: a beta is stamped against the NEXT release
211+
// (main.go's build-stamp trio documents exactly this), so plain
212+
// semver would rank it below that release's minimum forever. A
213+
// malformed minimum fails closed like any other manifest error; an
214+
// unparseable app version (an unstamped source build) skips
215+
// enforcement rather than refusing every version-pinned plugin.
216+
func checkMinMillVersion(minVersion, appVersion string) string {
217+
if strings.TrimSpace(minVersion) == "" {
218+
return ""
219+
}
220+
minV := "v" + strings.TrimPrefix(minVersion, "v")
221+
if !semver.IsValid(minV) {
222+
return fmt.Sprintf("the manifest minMillVersion %q must be a version like \"1.2.3\"", minVersion)
223+
}
224+
appV := "v" + strings.TrimPrefix(appVersion, "v")
225+
if !semver.IsValid(appV) {
226+
return ""
227+
}
228+
appV = strings.TrimSuffix(appV, semver.Build(appV))
229+
appV = strings.TrimSuffix(appV, semver.Prerelease(appV))
230+
if semver.Compare(appV, minV) < 0 {
231+
return fmt.Sprintf("needs Mill %s or newer -- this is Mill %s", minVersion, appVersion)
232+
}
233+
return ""
234+
}
235+
199236
// fileExtensionPattern pins a contributed extension claim to the
200237
// ".ext" shape the drop router compares against (unitRegistry's own
201238
// extensionOf yields a lowercased dot-prefixed extension).

internal/services/pluginsvc/pluginservice_test.go

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func TestListPlugins_ValidAndInvalidRows(t *testing.T) {
3838
writePlugin(t, root, "wrong-id", `{"id":"other","name":"X","version":"1"}`, nil)
3939
writePlugin(t, root, "bad-cap", `{"id":"bad-cap","name":"X","version":"1","capabilities":["format-disk"]}`, nil)
4040

41-
svc := New(root, nil)
41+
svc := New(root, nil, "1.0.0")
4242
infos, err := svc.ListPlugins()
4343
if err != nil {
4444
t.Fatal(err)
@@ -62,7 +62,7 @@ func TestListPlugins_ValidAndInvalidRows(t *testing.T) {
6262
}
6363

6464
func TestListPlugins_MissingDirIsEmptyNotError(t *testing.T) {
65-
svc := New(filepath.Join(t.TempDir(), "never-created"), nil)
65+
svc := New(filepath.Join(t.TempDir(), "never-created"), nil, "1.0.0")
6666
infos, err := svc.ListPlugins()
6767
if err != nil || len(infos) != 0 {
6868
t.Fatalf("want empty, no error; got %d infos, err %v", len(infos), err)
@@ -86,7 +86,7 @@ func TestAssetMiddleware_ServesOnlyValidPluginAllowlistedFiles(t *testing.T) {
8686
"secret.txt": "nope",
8787
})
8888
writePlugin(t, root, "broken", `{not json`, nil)
89-
svc := New(root, nil)
89+
svc := New(root, nil, "1.0.0")
9090

9191
if rec := serveThrough(t, svc, "/plugins/good-one/main.js"); rec.Code != http.StatusOK || !strings.Contains(rec.Header().Get("Content-Type"), "javascript") {
9292
t.Fatalf("main.js: code %d type %q", rec.Code, rec.Header().Get("Content-Type"))
@@ -113,15 +113,15 @@ func TestRequestGuardedAction_UndeclaredCapabilityRefusedBeforeRules(t *testing.
113113
writePlugin(t, root, "quiet-one", `{"id":"quiet-one","name":"Q","version":"1","capabilities":[]}`, nil)
114114
// guardrail nil: proves the refusal happens BEFORE any rule
115115
// evaluation could run (a nil-deref here would fail the test).
116-
svc := New(root, nil)
116+
svc := New(root, nil, "1.0.0")
117117
_, err := svc.RequestGuardedAction("quiet-one", "open-url", map[string]string{"url": "https://example.com"}, "test")
118118
if err == nil || !strings.Contains(err.Error(), "does not declare") {
119119
t.Fatalf("want undeclared-capability refusal, got %v", err)
120120
}
121121
}
122122

123123
func TestPerform_OpenURLRejectsNonHTTP(t *testing.T) {
124-
svc := New(t.TempDir(), nil)
124+
svc := New(t.TempDir(), nil, "1.0.0")
125125
var opened string
126126
svc.openURL = func(u string) error { opened = u; return nil }
127127
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) {
142142
writePlugin(t, root, "bad-kind", `{"id":"bad-kind","name":"C","version":"1","contributes":{"canvasObjects":[{"kind":"Not A Slug"}]}}`, nil)
143143
writePlugin(t, root, "bad-ext", `{"id":"bad-ext","name":"C","version":"1","contributes":{"canvasObjects":[{"kind":"thing","fileExtensions":["webloc"]}]}}`, nil)
144144

145-
svc := New(root, nil)
145+
svc := New(root, nil, "1.0.0")
146146
infos, err := svc.ListPlugins()
147147
if err != nil {
148148
t.Fatal(err)
@@ -170,9 +170,53 @@ func TestURLPasteClaims_ValidClaimersOnly(t *testing.T) {
170170
writePlugin(t, root, "no-claim", `{"id":"no-claim","name":"N","version":"1"}`, nil)
171171
writePlugin(t, root, "broken-claimer", `{"id":"broken-claimer","name":"X","version":"1","capabilities":["format-disk"],"contributes":{"canvasObjects":[{"kind":"thing","pastesURLs":true}]}}`, nil)
172172

173-
svc := New(root, nil)
173+
svc := New(root, nil, "1.0.0")
174174
claims := svc.URLPasteClaims()
175175
if len(claims) != 1 || claims[0].PluginID != "bookmarker" || claims[0].Kind != "bookmark" {
176176
t.Fatalf("URLPasteClaims() = %+v, want exactly bookmarker/bookmark", claims)
177177
}
178178
}
179+
180+
// minMillVersion enforcement (docs/goals/0245's stability contract):
181+
// a plugin needing a newer Mill is refused with both versions named;
182+
// a beta build counts as the release it is stamped against; a
183+
// malformed minimum fails closed; an unstamped app skips enforcement.
184+
func TestCheckMinMillVersion(t *testing.T) {
185+
cases := []struct {
186+
name, min, app, wantSubstr string
187+
}{
188+
{"too new refused", "9.9.9", "1.0.0", "needs Mill 9.9.9"},
189+
{"equal loads", "1.0.0", "1.0.0", ""},
190+
{"older minimum loads", "0.5.0", "1.0.0", ""},
191+
{"beta counts as its stamped release", "1.0.0", "1.0.0-beta.7", ""},
192+
{"beta still refused below core", "1.1.0", "1.0.0-beta.7", "needs Mill 1.1.0"},
193+
{"malformed minimum fails closed", "not-a-version", "1.0.0", "must be a version"},
194+
{"empty minimum is no constraint", "", "1.0.0", ""},
195+
{"unstamped app skips enforcement", "1.0.0", "", ""},
196+
}
197+
for _, c := range cases {
198+
got := checkMinMillVersion(c.min, c.app)
199+
if c.wantSubstr == "" && got != "" {
200+
t.Errorf("%s: checkMinMillVersion(%q, %q) = %q, want valid", c.name, c.min, c.app, got)
201+
}
202+
if c.wantSubstr != "" && !strings.Contains(got, c.wantSubstr) {
203+
t.Errorf("%s: checkMinMillVersion(%q, %q) = %q, want it to contain %q", c.name, c.min, c.app, got, c.wantSubstr)
204+
}
205+
}
206+
}
207+
208+
// The check runs inside the scan itself, so every valid-manifest-only
209+
// consumer (loader, assets, ingestion claims, guarded actions)
210+
// inherits the refusal.
211+
func TestListPlugins_EnforcesMinMillVersion(t *testing.T) {
212+
root := t.TempDir()
213+
writePlugin(t, root, "too-new", `{"id":"too-new","name":"T","version":"1","minMillVersion":"99.0.0"}`, nil)
214+
svc := New(root, nil, "1.0.0")
215+
infos, err := svc.ListPlugins()
216+
if err != nil || len(infos) != 1 {
217+
t.Fatalf("ListPlugins() = %+v err=%v, want 1 row", infos, err)
218+
}
219+
if !strings.Contains(infos[0].Error, "needs Mill 99.0.0") {
220+
t.Fatalf("Error = %q, want the version refusal naming the minimum", infos[0].Error)
221+
}
222+
}

internal/services/wiring/plugins.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,20 @@ import (
1919
// (<data dir>/plugins/<id>/), so MILL_SETTINGS_PATH isolation covers
2020
// plugins for free; MILL_PLUGINS_DIR overrides independently for
2121
// fixture-driven tests.
22-
func NewPluginService(settingsPath string, guardrail *guardrailsvc.GuardrailService) *pluginsvc.PluginService {
22+
func NewPluginService(settingsPath string, guardrail *guardrailsvc.GuardrailService, channel, appVersion string) *pluginsvc.PluginService {
2323
dir := os.Getenv("MILL_PLUGINS_DIR")
2424
if dir == "" {
2525
dir = filepath.Join(filepath.Dir(settingsPath), "plugins")
2626
}
27-
return pluginsvc.New(dir, guardrail)
27+
// A source build's version constant is the LAST release, not this
28+
// build's real lineage (main.go's build-stamp trio: only beta/
29+
// stable builds get stamped) -- enforcing minMillVersion against
30+
// it would refuse a pinned plugin on the freshest possible code,
31+
// so an unstamped build skips enforcement entirely.
32+
if channel == "source" {
33+
appVersion = ""
34+
}
35+
return pluginsvc.New(dir, guardrail, appVersion)
2836
}
2937

3038
// ComposedAssetMiddleware chains the remote-auth gate (server builds
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package wiring
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func writeVersionPinnedPlugin(t *testing.T, root string) {
11+
t.Helper()
12+
dir := filepath.Join(root, "plugins", "pinned")
13+
if err := os.MkdirAll(dir, 0o750); err != nil {
14+
t.Fatal(err)
15+
}
16+
manifest := `{"id":"pinned","name":"P","version":"1","minMillVersion":"9.9.9"}`
17+
for name, content := range map[string]string{"manifest.json": manifest, "main.js": "export function activate() {}"} {
18+
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
19+
t.Fatal(err)
20+
}
21+
}
22+
}
23+
24+
// A source build's version constant is the last release, not this
25+
// build's lineage -- minMillVersion enforcement applies only to
26+
// stamped (beta/stable) builds, so a pinned plugin is never refused
27+
// on the freshest possible code (docs/goals/0245).
28+
func TestNewPluginService_SourceChannelSkipsMinVersionEnforcement(t *testing.T) {
29+
root := t.TempDir()
30+
writeVersionPinnedPlugin(t, root)
31+
settingsPath := filepath.Join(root, "settings.json")
32+
33+
srcInfos, err := NewPluginService(settingsPath, nil, "source", "0.5.0").ListPlugins()
34+
if err != nil || len(srcInfos) != 1 {
35+
t.Fatalf("source ListPlugins() = %+v err=%v, want 1 row", srcInfos, err)
36+
}
37+
if srcInfos[0].Error != "" {
38+
t.Fatalf("source build refused the pinned plugin: %q", srcInfos[0].Error)
39+
}
40+
41+
betaInfos, err := NewPluginService(settingsPath, nil, "beta", "0.5.0").ListPlugins()
42+
if err != nil || len(betaInfos) != 1 {
43+
t.Fatalf("beta ListPlugins() = %+v err=%v, want 1 row", betaInfos, err)
44+
}
45+
if !strings.Contains(betaInfos[0].Error, "needs Mill 9.9.9") {
46+
t.Fatalf("beta build Error = %q, want the version refusal", betaInfos[0].Error)
47+
}
48+
}

main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ func main() {
189189
logger.Error("migrate legacy MCP pending writes", "error", err)
190190
}
191191
guardrailService := guardrailsvc.NewGuardrailService(settingsStore, compositionService)
192-
pluginService := wiring.NewPluginService(settingsPath, guardrailService)
192+
pluginService := wiring.NewPluginService(settingsPath, guardrailService, millChannel, millUpdateVersion)
193193
// docs/goals/0240 S1: the coding loop's Confirm-screen preview --
194194
// read-only over guardrailService.Rules(). Its ExecutionService
195195
// dependency (goal 0240 S2, RunCommandBlock's own doc comment) is

userdocs/llms-full.txt

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1547,18 +1547,25 @@ structural change, and any drift is caught immediately — the suite
15471547
breaks the moment a field's meaning or a renderer's contract changes
15481548
underneath an existing tool.
15491549

1550-
**Not promised:** no semver, no deprecation window, and no
1551-
compatibility guarantee on the *runtime* API — `commit`'s own
1552-
signature shape, the exact set of `AtlasService` RPCs a tool may call,
1553-
or any shared renderer's internal behavior — until there is a second
1554-
adopter outside this repo to actually break. Today every "adopter" is
1555-
one of Mill's own nine files, changed in the same pull request as any
1556-
platform change that affects it, so nothing here has ever needed to
1557-
stay backward compatible. That's stated honestly now on purpose: a
1558-
platform that only discovers its own contract gaps after outside
1559-
adopters build against them pays for that omission for years. The
1560-
out-of-tree tier this page doesn't yet promise gets built once a real
1561-
outside adopter needs it, not before.
1550+
**Not promised for compiled-in tools:** no semver, no deprecation
1551+
window, and no compatibility guarantee on the *runtime* API —
1552+
`commit`'s own signature shape, the exact set of `AtlasService` RPCs a
1553+
tool may call, or any shared renderer's internal behavior. Every
1554+
compiled-in adopter is one of Mill's own files, changed in the same
1555+
pull request as any platform change that affects it, so nothing here
1556+
needs to stay backward compatible.
1557+
1558+
**Promised for runtime plugins:** the plugin surface — the manifest
1559+
schema, the `activate(api)` shape and its argument's methods, the
1560+
`renderFace` contract, and the payload keys each `source` implies — is
1561+
versioned against **Mill's own version**, the way desktop app-plugin
1562+
ecosystems converge on versioning against the app rather than a
1563+
separate API number. A plugin pins the Mill it needs with
1564+
`minMillVersion` in its manifest; a Mill older than that refuses to
1565+
load it, saying so on its Extensions row, instead of half-running it
1566+
against a surface it predates. Within versions that satisfy the
1567+
minimum, an existing manifest field or `api` method keeps its meaning
1568+
— growth is additive.
15621569

15631570
---
15641571

userdocs/reference/extending-the-canvas.md

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -212,15 +212,22 @@ structural change, and any drift is caught immediately — the suite
212212
breaks the moment a field's meaning or a renderer's contract changes
213213
underneath an existing tool.
214214

215-
**Not promised:** no semver, no deprecation window, and no
216-
compatibility guarantee on the *runtime* API — `commit`'s own
217-
signature shape, the exact set of `AtlasService` RPCs a tool may call,
218-
or any shared renderer's internal behavior — until there is a second
219-
adopter outside this repo to actually break. Today every "adopter" is
220-
one of Mill's own nine files, changed in the same pull request as any
221-
platform change that affects it, so nothing here has ever needed to
222-
stay backward compatible. That's stated honestly now on purpose: a
223-
platform that only discovers its own contract gaps after outside
224-
adopters build against them pays for that omission for years. The
225-
out-of-tree tier this page doesn't yet promise gets built once a real
226-
outside adopter needs it, not before.
215+
**Not promised for compiled-in tools:** no semver, no deprecation
216+
window, and no compatibility guarantee on the *runtime* API —
217+
`commit`'s own signature shape, the exact set of `AtlasService` RPCs a
218+
tool may call, or any shared renderer's internal behavior. Every
219+
compiled-in adopter is one of Mill's own files, changed in the same
220+
pull request as any platform change that affects it, so nothing here
221+
needs to stay backward compatible.
222+
223+
**Promised for runtime plugins:** the plugin surface — the manifest
224+
schema, the `activate(api)` shape and its argument's methods, the
225+
`renderFace` contract, and the payload keys each `source` implies — is
226+
versioned against **Mill's own version**, the way desktop app-plugin
227+
ecosystems converge on versioning against the app rather than a
228+
separate API number. A plugin pins the Mill it needs with
229+
`minMillVersion` in its manifest; a Mill older than that refuses to
230+
load it, saying so on its Extensions row, instead of half-running it
231+
against a surface it predates. Within versions that satisfy the
232+
minimum, an existing manifest field or `api` method keeps its meaning
233+
— growth is additive.

userdocs/reference/install-a-plugin.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ installing one is copying that folder into Mill's plugins folder.
1616

1717
A plugin that can't load shows exactly why on its row — a missing
1818
file, invalid manifest, or a capability Mill doesn't recognize —
19-
instead of silently doing nothing.
19+
instead of silently doing nothing. A plugin whose manifest sets
20+
`minMillVersion` to a version newer than your Mill is refused the
21+
same visible way: update Mill, then reload.
2022

2123
## Turning a plugin off
2224

0 commit comments

Comments
 (0)