Skip to content
Merged
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 go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 43 additions & 6 deletions internal/services/pluginsvc/pluginservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 --
Expand Down Expand Up @@ -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).
Expand Down
58 changes: 51 additions & 7 deletions internal/services/pluginsvc/pluginservice_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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"))
Expand All @@ -113,15 +113,15 @@ 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)
}
}

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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}
12 changes: 10 additions & 2 deletions internal/services/wiring/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,20 @@ import (
// (<data dir>/plugins/<id>/), 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
Expand Down
48 changes: 48 additions & 0 deletions internal/services/wiring/plugins_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 19 additions & 12 deletions userdocs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
31 changes: 19 additions & 12 deletions userdocs/reference/extending-the-canvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion userdocs/reference/install-a-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading