Compare commits
3 Commits
71d005f1fb
...
2b015a56d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b015a56d5 | ||
|
|
d3e2bc65a2 | ||
|
|
9e802e005b |
@ -17,10 +17,10 @@ import (
|
|||||||
|
|
||||||
core "git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
core "git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||||
|
|
||||||
|
v1 "git.dev.alexdunmow.com/block/cli/internal/api/orchestrator/v1"
|
||||||
"git.dev.alexdunmow.com/block/cli/internal/bnp"
|
"git.dev.alexdunmow.com/block/cli/internal/bnp"
|
||||||
"git.dev.alexdunmow.com/block/cli/internal/creds"
|
"git.dev.alexdunmow.com/block/cli/internal/creds"
|
||||||
"git.dev.alexdunmow.com/block/cli/internal/orchclient"
|
"git.dev.alexdunmow.com/block/cli/internal/orchclient"
|
||||||
v1 "git.dev.alexdunmow.com/block/cli/internal/api/orchestrator/v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newPluginCmd() *cobra.Command {
|
func newPluginCmd() *cobra.Command {
|
||||||
@ -1144,9 +1144,15 @@ func writeMod(path string, m *core.ModFile) error {
|
|||||||
if m.Plugin.Sitemap {
|
if m.Plugin.Sitemap {
|
||||||
b.WriteString("sitemap = true\n")
|
b.WriteString("sitemap = true\n")
|
||||||
}
|
}
|
||||||
if m.Compatibility != nil {
|
if m.Compatibility != nil &&
|
||||||
|
(m.Compatibility.BlockCore != "" || m.Compatibility.AdminAPI != "") {
|
||||||
b.WriteString("\n[compatibility]\n")
|
b.WriteString("\n[compatibility]\n")
|
||||||
fmt.Fprintf(&b, "block_core = %q\n", m.Compatibility.BlockCore)
|
if m.Compatibility.BlockCore != "" {
|
||||||
|
fmt.Fprintf(&b, "block_core = %q\n", m.Compatibility.BlockCore)
|
||||||
|
}
|
||||||
|
if m.Compatibility.AdminAPI != "" {
|
||||||
|
fmt.Fprintf(&b, "admin_api = %q\n", m.Compatibility.AdminAPI)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, r := range m.Requires {
|
for _, r := range m.Requires {
|
||||||
b.WriteString("\n[[requires]]\n")
|
b.WriteString("\n[[requires]]\n")
|
||||||
|
|||||||
@ -364,6 +364,55 @@ func TestWriteMod_PrivateFalseOmitted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteMod_CompatibilityFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
compat *core.ModCompat
|
||||||
|
want []string
|
||||||
|
doNotWant []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "admin api only",
|
||||||
|
compat: &core.ModCompat{AdminAPI: ">=0.1.2"},
|
||||||
|
want: []string{"[compatibility]", `admin_api = ">=0.1.2"`},
|
||||||
|
doNotWant: []string{"block_core"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty compatibility omitted",
|
||||||
|
compat: &core.ModCompat{},
|
||||||
|
doNotWant: []string{"[compatibility]", "block_core", "admin_api"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "plugin.mod")
|
||||||
|
m := &core.ModFile{
|
||||||
|
Plugin: core.ModPlugin{Name: "myplugin", Version: "0.1.0"},
|
||||||
|
Compatibility: test.compat,
|
||||||
|
}
|
||||||
|
if err := writeMod(path, m); err != nil {
|
||||||
|
t.Fatalf("writeMod: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
got := string(data)
|
||||||
|
for _, want := range test.want {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("missing %q:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, doNotWant := range test.doNotWant {
|
||||||
|
if strings.Contains(got, doNotWant) {
|
||||||
|
t.Errorf("unexpected %q:\n%s", doNotWant, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestWriteMod_RoundTripsFirstClassFields guards the writeMod ⇄ ParseModFull
|
// TestWriteMod_RoundTripsFirstClassFields guards the writeMod ⇄ ParseModFull
|
||||||
// round-trip for the first-class fields the hand-rolled writer must emit: a
|
// round-trip for the first-class fields the hand-rolled writer must emit: a
|
||||||
// bump/init/tags edit re-serializes plugin.mod, and any field the writer forgets
|
// bump/init/tags edit re-serializes plugin.mod, and any field the writer forgets
|
||||||
@ -373,19 +422,25 @@ func TestWriteMod_PrivateFalseOmitted(t *testing.T) {
|
|||||||
func TestWriteMod_RoundTripsFirstClassFields(t *testing.T) {
|
func TestWriteMod_RoundTripsFirstClassFields(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "plugin.mod")
|
path := filepath.Join(dir, "plugin.mod")
|
||||||
m := &core.ModFile{Plugin: core.ModPlugin{
|
m := &core.ModFile{
|
||||||
Name: "myplugin",
|
Plugin: core.ModPlugin{
|
||||||
Scope: "ninja",
|
Name: "myplugin",
|
||||||
Version: "1.2.3",
|
Scope: "ninja",
|
||||||
AllowedHosts: []string{"api.cal.com", "*.example.com"},
|
Version: "1.2.3",
|
||||||
MaxResponseMB: 25,
|
AllowedHosts: []string{"api.cal.com", "*.example.com"},
|
||||||
RequiredIconPacks: []string{"tabler"},
|
MaxResponseMB: 25,
|
||||||
PublicRoutes: []core.ModPublicRoute{
|
RequiredIconPacks: []string{"tabler"},
|
||||||
{Path: "/area", Prefix: true},
|
PublicRoutes: []core.ModPublicRoute{
|
||||||
{Path: "/api/semantic-search"},
|
{Path: "/area", Prefix: true},
|
||||||
|
{Path: "/api/semantic-search"},
|
||||||
|
},
|
||||||
|
Sitemap: true,
|
||||||
},
|
},
|
||||||
Sitemap: true,
|
Compatibility: &core.ModCompat{
|
||||||
}}
|
BlockCore: ">=0.3.4",
|
||||||
|
AdminAPI: ">=0.1.2",
|
||||||
|
},
|
||||||
|
}
|
||||||
if err := writeMod(path, m); err != nil {
|
if err := writeMod(path, m); err != nil {
|
||||||
t.Fatalf("writeMod: %v", err)
|
t.Fatalf("writeMod: %v", err)
|
||||||
}
|
}
|
||||||
@ -414,6 +469,15 @@ func TestWriteMod_RoundTripsFirstClassFields(t *testing.T) {
|
|||||||
if !back.Plugin.Sitemap {
|
if !back.Plugin.Sitemap {
|
||||||
t.Errorf("sitemap not preserved\n%s", data)
|
t.Errorf("sitemap not preserved\n%s", data)
|
||||||
}
|
}
|
||||||
|
if back.Compatibility == nil {
|
||||||
|
t.Fatalf("compatibility not preserved\n%s", data)
|
||||||
|
}
|
||||||
|
if back.Compatibility.BlockCore != ">=0.3.4" {
|
||||||
|
t.Errorf("block_core not preserved: %q\n%s", back.Compatibility.BlockCore, data)
|
||||||
|
}
|
||||||
|
if back.Compatibility.AdminAPI != ">=0.1.2" {
|
||||||
|
t.Errorf("admin_api not preserved: %q\n%s", back.Compatibility.AdminAPI, data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePrivateCoord(t *testing.T) {
|
func TestParsePrivateCoord(t *testing.T) {
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
# Plugin MCP artifacts are validated before packing
|
||||||
|
|
||||||
|
Plugin SDK v0.3.2 serializes Connect service descriptors into
|
||||||
|
`PluginManifest.mcp_tools`. Without a CLI gate, an artifact could carry
|
||||||
|
inconsistent procedures, missing RBAC coverage, invalid schemas, unstable
|
||||||
|
names, or no runtime HTTP handler and fail only after installation.
|
||||||
|
|
||||||
|
Decision: both `ninja plugin build` and `ninja plugin verify` validate the MCP
|
||||||
|
surface. Descriptor procedures must agree with service and method fields, be
|
||||||
|
unique, map to supported RBAC roles, contain object input schemas, and derive a
|
||||||
|
scoped name no longer than 128 bytes. The protobuf package must end in an API
|
||||||
|
major such as `v1`, `plugin.mod` must provide scope and plugin identity, and any
|
||||||
|
descriptor-bearing manifest must set `has_http_handler`. The CLI pins Plugin
|
||||||
|
SDK v0.3.2 so builds preserve the typed descriptor field.
|
||||||
|
|
||||||
|
Relying only on the CMS loader was rejected because it lets known-invalid
|
||||||
|
artifacts reach the registry. Validating only during build was rejected because
|
||||||
|
`verify` must independently gate externally supplied artifacts.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- Invalid MCP artifacts fail before upload or installation.
|
||||||
|
- The CLI and CMS enforce the same descriptor and runtime invariants.
|
||||||
|
- Wiki artifacts built with this CLI retain all typed MCP descriptors.
|
||||||
|
|
||||||
|
Keywords: ninja plugin build, ninja plugin verify, Plugin SDK v0.3.2,
|
||||||
|
MCPToolDescriptor, mcp_tools, has_http_handler, plugin.mod scope, RBAC, JSON
|
||||||
|
Schema, plugin_ninja_wiki_v1
|
||||||
20
docs/adr/0002-keyed-load-once-manifests-are-validated.md
Normal file
20
docs/adr/0002-keyed-load-once-manifests-are-validated.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Keyed load-once manifests are validated before packing
|
||||||
|
|
||||||
|
The plugin SDK and CMS host support keyed `LoadOnce` callbacks whose durable
|
||||||
|
identity is the `(plugin name, key)` pair. A malformed or duplicate key would
|
||||||
|
make execution ambiguous and should not survive until production installation.
|
||||||
|
|
||||||
|
Decision: both `ninja plugin build` and `ninja plugin verify` validate every
|
||||||
|
`load_once_keys` manifest entry with the SDK's canonical rules. Keys are 1–128
|
||||||
|
bytes, start alphanumeric, and contain only lower-case letters, digits, `.`,
|
||||||
|
`_`, or `-`; duplicates are rejected. Codeless artifacts reject load-once keys
|
||||||
|
because they have no guest callback to execute.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- Build and verification fail before an invalid artifact can be published.
|
||||||
|
- CLI and host use the same SDK validator instead of drifting grammars.
|
||||||
|
- Changing a valid key remains the deliberate way to define a new one-time
|
||||||
|
operation.
|
||||||
|
|
||||||
|
Keywords: plugin CLI, LoadOnce, load_once_keys, BNP, validation, codeless
|
||||||
2
go.mod
2
go.mod
@ -5,7 +5,7 @@ go 1.26.4
|
|||||||
require (
|
require (
|
||||||
connectrpc.com/connect v1.20.0
|
connectrpc.com/connect v1.20.0
|
||||||
git.dev.alexdunmow.com/block/core v0.18.2
|
git.dev.alexdunmow.com/block/core v0.18.2
|
||||||
git.dev.alexdunmow.com/block/pluginsdk v0.2.7
|
git.dev.alexdunmow.com/block/pluginsdk v0.3.6
|
||||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
|
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
|
||||||
github.com/chromedp/chromedp v0.15.1
|
github.com/chromedp/chromedp v0.15.1
|
||||||
github.com/klauspost/compress v1.18.6
|
github.com/klauspost/compress v1.18.6
|
||||||
|
|||||||
4
go.sum
4
go.sum
@ -2,8 +2,8 @@ connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
|||||||
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
||||||
git.dev.alexdunmow.com/block/core v0.18.2 h1:+3OfZ424yoc1k1CucXYARk8TBkh8Z693HRkhf5Ci2wU=
|
git.dev.alexdunmow.com/block/core v0.18.2 h1:+3OfZ424yoc1k1CucXYARk8TBkh8Z693HRkhf5Ci2wU=
|
||||||
git.dev.alexdunmow.com/block/core v0.18.2/go.mod h1:GGuUu826AoJepC/hKLGJ7BX3PQaDss9ueCT0se6Ao2w=
|
git.dev.alexdunmow.com/block/core v0.18.2/go.mod h1:GGuUu826AoJepC/hKLGJ7BX3PQaDss9ueCT0se6Ao2w=
|
||||||
git.dev.alexdunmow.com/block/pluginsdk v0.2.7 h1:iL6Qvg2xHqHtii/0ZF9TIjjDzMrYkmxvLwqU300nmac=
|
git.dev.alexdunmow.com/block/pluginsdk v0.3.6 h1:wK5gUAmK1gFrCWLqR4zRaq+aSAPgNmpTKHDK8nPX4OI=
|
||||||
git.dev.alexdunmow.com/block/pluginsdk v0.2.7/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM=
|
git.dev.alexdunmow.com/block/pluginsdk v0.3.6/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM=
|
||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
|
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
|
||||||
|
|||||||
@ -12,8 +12,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
||||||
core "git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/egress"
|
"git.dev.alexdunmow.com/block/pluginsdk/egress"
|
||||||
|
core "git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -165,6 +165,12 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
|
|||||||
if err := applyManifestYAML(dir, manifest); err != nil {
|
if err := applyManifestYAML(dir, manifest); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := ValidateLoadOnceManifest(manifest); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ValidateMCPManifest(manifest, mod.Plugin.Scope, mod.Plugin.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
manifestBytes, err := proto.Marshal(manifest)
|
manifestBytes, err := proto.Marshal(manifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -317,6 +323,9 @@ func hooksPresent(m *abiv1.PluginManifest) []string {
|
|||||||
if m.GetHasLoadHook() {
|
if m.GetHasLoadHook() {
|
||||||
hooks = append(hooks, "load")
|
hooks = append(hooks, "load")
|
||||||
}
|
}
|
||||||
|
if len(m.GetLoadOnceKeys()) > 0 {
|
||||||
|
hooks = append(hooks, "load-once")
|
||||||
|
}
|
||||||
if m.GetHasUnloadHook() {
|
if m.GetHasUnloadHook() {
|
||||||
hooks = append(hooks, "unload")
|
hooks = append(hooks, "unload")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,8 +37,8 @@ func codelessFixture(t *testing.T) string {
|
|||||||
"templates/fixture/landing.ninjatpl": `<main>{{ content }}</main>`,
|
"templates/fixture/landing.ninjatpl": `<main>{{ content }}</main>`,
|
||||||
"templates/overrides/fixture/heading.ninjatpl": `<h2 class="deco">{{ text }}</h2>`,
|
"templates/overrides/fixture/heading.ninjatpl": `<h2 class="deco">{{ text }}</h2>`,
|
||||||
"templates/email/fixture.ninjatpl": `<table><tr><td>{{ body|safe }}</td></tr></table>`,
|
"templates/email/fixture.ninjatpl": `<table><tr><td>{{ body|safe }}</td></tr></table>`,
|
||||||
"presets.json": `{"presets":[{"key":"default"}]}`,
|
"presets.json": `{"presets":[{"key":"default"}]}`,
|
||||||
"master_pages.json": `[{"key":"landing","title":"Landing","blocks":[{"block_key":"html","title":"Hero","content":{"x":1},"slot":"main","sort_order":1}]}]`,
|
"master_pages.json": `[{"key":"landing","title":"Landing","blocks":[{"block_key":"html","title":"Hero","content":{"x":1},"slot":"main","sort_order":1}]}]`,
|
||||||
"blocks/blocks.yaml": "blocks:\n - key: hero\n title: Hero\n category: content\n" +
|
"blocks/blocks.yaml": "blocks:\n - key: hero\n title: Hero\n category: content\n" +
|
||||||
" schema: hero.schema.json\n template: hero.ninjatpl\n providers: [site]\n",
|
" schema: hero.schema.json\n template: hero.ninjatpl\n providers: [site]\n",
|
||||||
"blocks/hero.schema.json": `{"type":"object"}`,
|
"blocks/hero.schema.json": `{"type":"object"}`,
|
||||||
@ -147,8 +147,8 @@ func TestCodelessBuildRejectsBadDeclarations(t *testing.T) {
|
|||||||
"manifest.yaml": "template_overrides:\n - template: x\n block: heading\n",
|
"manifest.yaml": "template_overrides:\n - template: x\n block: heading\n",
|
||||||
}},
|
}},
|
||||||
{"override missing block key", map[string]string{
|
{"override missing block key", map[string]string{
|
||||||
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
|
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
|
||||||
"manifest.yaml": "template_overrides:\n - template: x\n",
|
"manifest.yaml": "template_overrides:\n - template: x\n",
|
||||||
"templates/overrides/x/heading.ninjatpl": `<h1>{{ text }}</h1>`,
|
"templates/overrides/x/heading.ninjatpl": `<h1>{{ text }}</h1>`,
|
||||||
}},
|
}},
|
||||||
{"email wrapper missing template file", map[string]string{
|
{"email wrapper missing template file", map[string]string{
|
||||||
@ -177,6 +177,7 @@ func TestCodelessHookViolation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
bad := []*abiv1.PluginManifest{
|
bad := []*abiv1.PluginManifest{
|
||||||
{Codeless: true, HasHttpHandler: true},
|
{Codeless: true, HasHttpHandler: true},
|
||||||
|
{Codeless: true, LoadOnceKeys: []string{"defaults.v1"}},
|
||||||
{Codeless: true, JobTypes: []string{"j"}},
|
{Codeless: true, JobTypes: []string{"j"}},
|
||||||
{Codeless: true, DeclaredTags: []string{"t"}},
|
{Codeless: true, DeclaredTags: []string{"t"}},
|
||||||
{Codeless: true, Blocks: []*abiv1.BlockMeta{{Key: "k"}}},
|
{Codeless: true, Blocks: []*abiv1.BlockMeta{{Key: "k"}}},
|
||||||
|
|||||||
17
internal/bnp/load_once.go
Normal file
17
internal/bnp/load_once.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package bnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
||||||
|
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateLoadOnceManifest enforces the stable keyed lifecycle contract before
|
||||||
|
// an artifact can be packed or accepted by ninja plugin verify.
|
||||||
|
func ValidateLoadOnceManifest(manifest *abiv1.PluginManifest) error {
|
||||||
|
if err := plugin.ValidateLoadOnceKeys(manifest.GetLoadOnceKeys()); err != nil {
|
||||||
|
return fmt.Errorf("bnp: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
65
internal/bnp/load_once_test.go
Normal file
65
internal/bnp/load_once_test.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package bnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHooksPresentIncludesLoadOnce(t *testing.T) {
|
||||||
|
hooks := hooksPresent(&abiv1.PluginManifest{LoadOnceKeys: []string{"defaults.v1"}})
|
||||||
|
if !slices.Contains(hooks, "load-once") {
|
||||||
|
t.Fatalf("hooksPresent() = %v, want load-once", hooks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLoadOnceManifest(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
keys []string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{name: "valid", keys: []string{"documentation-main-menu.v1"}, ok: true},
|
||||||
|
{name: "uppercase", keys: []string{"DocumentationMenu.v1"}},
|
||||||
|
{name: "duplicate", keys: []string{"menu.v1", "menu.v1"}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
err := ValidateLoadOnceManifest(&abiv1.PluginManifest{LoadOnceKeys: test.keys})
|
||||||
|
if test.ok && err != nil {
|
||||||
|
t.Fatalf("ValidateLoadOnceManifest() error = %v", err)
|
||||||
|
}
|
||||||
|
if !test.ok && err == nil {
|
||||||
|
t.Fatal("ValidateLoadOnceManifest() error = nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejectsDuplicateLoadOnceKeys(t *testing.T) {
|
||||||
|
manifestBytes, err := proto.Marshal(&abiv1.PluginManifest{
|
||||||
|
AbiVersion: 1,
|
||||||
|
Name: "fixture",
|
||||||
|
Version: "1.0.0",
|
||||||
|
LoadOnceKeys: []string{"defaults.v1", "defaults.v1"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal manifest: %v", err)
|
||||||
|
}
|
||||||
|
out := filepath.Join(t.TempDir(), "fixture.bnp")
|
||||||
|
_, err = packArtifact(out, []packEntry{
|
||||||
|
{ArtifactPath: fileWasm, Data: []byte("wasm")},
|
||||||
|
{ArtifactPath: fileMod, Data: []byte("[plugin]\nname = \"fixture\"\nversion = \"1.0.0\"\n")},
|
||||||
|
{ArtifactPath: fileManifest, Data: manifestBytes},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pack artifact: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Verify(out); err == nil || !strings.Contains(err.Error(), "duplicate load-once key") {
|
||||||
|
t.Fatalf("Verify() error = %v, want duplicate key rejection", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
160
internal/bnp/mcp_tools.go
Normal file
160
internal/bnp/mcp_tools.go
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
package bnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateMCPManifest enforces the descriptor/runtime contract shared with the
|
||||||
|
// CMS before an artifact can be packed or accepted by `ninja plugin verify`.
|
||||||
|
func ValidateMCPManifest(manifest *abiv1.PluginManifest, registryScope, pluginName string) error {
|
||||||
|
descriptors := manifest.GetMcpTools()
|
||||||
|
if len(descriptors) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !manifest.GetHasHttpHandler() {
|
||||||
|
return fmt.Errorf("bnp: MCP descriptors require has_http_handler so tools are callable at runtime")
|
||||||
|
}
|
||||||
|
|
||||||
|
seenProcedures := make(map[string]bool, len(descriptors))
|
||||||
|
seenToolNames := make(map[string]bool, len(descriptors))
|
||||||
|
for _, descriptor := range descriptors {
|
||||||
|
if descriptor == nil {
|
||||||
|
return fmt.Errorf("bnp: MCP descriptor is nil")
|
||||||
|
}
|
||||||
|
procedure := strings.TrimSpace(descriptor.GetProcedure())
|
||||||
|
serviceName, methodName, ok := splitConnectProcedure(procedure)
|
||||||
|
if !ok || serviceName != descriptor.GetServiceFullName() || methodName != descriptor.GetMethodName() {
|
||||||
|
return fmt.Errorf("bnp: MCP descriptor has inconsistent procedure %q", procedure)
|
||||||
|
}
|
||||||
|
if seenProcedures[procedure] {
|
||||||
|
return fmt.Errorf("bnp: duplicate MCP procedure %q", procedure)
|
||||||
|
}
|
||||||
|
seenProcedures[procedure] = true
|
||||||
|
|
||||||
|
role, mapped := manifest.GetRbacMethodRoles()[procedure]
|
||||||
|
if !mapped || !supportedMCPRole(role) {
|
||||||
|
return fmt.Errorf("bnp: MCP procedure %q has no supported RBAC role", procedure)
|
||||||
|
}
|
||||||
|
if !validMCPObjectSchema(descriptor.GetInputSchemaJson()) {
|
||||||
|
return fmt.Errorf("bnp: MCP procedure %q has an invalid input schema", procedure)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolName, err := mcpToolName(registryScope, pluginName, serviceName, methodName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bnp: MCP procedure %q: %w", procedure, err)
|
||||||
|
}
|
||||||
|
if seenToolNames[toolName] {
|
||||||
|
return fmt.Errorf("bnp: MCP procedures produce duplicate tool name %q", toolName)
|
||||||
|
}
|
||||||
|
seenToolNames[toolName] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportedMCPRole(role string) bool {
|
||||||
|
switch role {
|
||||||
|
case "", "public", "viewer", "admin", "superadmin":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validMCPObjectSchema(raw []byte) bool {
|
||||||
|
var schema map[string]any
|
||||||
|
if json.Unmarshal(raw, &schema) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
typeName, _ := schema["type"].(string)
|
||||||
|
return typeName == "object"
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitConnectProcedure(procedure string) (serviceName, methodName string, ok bool) {
|
||||||
|
trimmed := strings.TrimPrefix(strings.TrimSpace(procedure), "/")
|
||||||
|
serviceName, methodName, ok = strings.Cut(trimmed, "/")
|
||||||
|
return serviceName, methodName, ok && serviceName != "" && methodName != "" && !strings.Contains(methodName, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpToolName(registryScope, pluginName, serviceFullName, methodName string) (string, error) {
|
||||||
|
scope := mcpIdentifier(strings.TrimPrefix(strings.TrimSpace(registryScope), "@"))
|
||||||
|
plugin := mcpIdentifier(pluginName)
|
||||||
|
if scope == "" || plugin == "" {
|
||||||
|
return "", fmt.Errorf("registry scope and plugin name must be non-empty MCP identifiers")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(serviceFullName, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", fmt.Errorf("service %q has no protobuf package", serviceFullName)
|
||||||
|
}
|
||||||
|
apiMajor := parts[len(parts)-2]
|
||||||
|
if !validAPIMajor(apiMajor) {
|
||||||
|
return "", fmt.Errorf("service %q protobuf package must end in an API major such as v1", serviceFullName)
|
||||||
|
}
|
||||||
|
service := mcpIdentifier(strings.TrimSuffix(parts[len(parts)-1], "Service"))
|
||||||
|
if service == "" {
|
||||||
|
return "", fmt.Errorf("service %q has no service qualifier", serviceFullName)
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := "plugin_" + scope + "_" + plugin + "_" + apiMajor
|
||||||
|
if service != plugin {
|
||||||
|
domain += "_" + service
|
||||||
|
}
|
||||||
|
toolName := domain + "_" + mcpIdentifier(methodName)
|
||||||
|
if len(toolName) > 128 {
|
||||||
|
return "", fmt.Errorf("tool name %q exceeds MCP's 128-byte limit", toolName)
|
||||||
|
}
|
||||||
|
return toolName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAPIMajor(value string) bool {
|
||||||
|
if len(value) < 2 || value[0] != 'v' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value[1:] {
|
||||||
|
if character < '0' || character > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpIdentifier(value string) string {
|
||||||
|
value = snakeIdentifier(strings.TrimSpace(value))
|
||||||
|
var builder strings.Builder
|
||||||
|
previousSeparator := false
|
||||||
|
for _, character := range value {
|
||||||
|
if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') {
|
||||||
|
builder.WriteRune(character)
|
||||||
|
previousSeparator = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if builder.Len() > 0 && !previousSeparator {
|
||||||
|
builder.WriteByte('_')
|
||||||
|
previousSeparator = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(builder.String(), "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func snakeIdentifier(value string) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
runes := []rune(value)
|
||||||
|
for index, current := range runes {
|
||||||
|
if unicode.IsUpper(current) {
|
||||||
|
previousLower := index > 0 && unicode.IsLower(runes[index-1])
|
||||||
|
nextLower := index+1 < len(runes) && unicode.IsLower(runes[index+1])
|
||||||
|
if index > 0 && (previousLower || nextLower) {
|
||||||
|
builder.WriteByte('_')
|
||||||
|
}
|
||||||
|
builder.WriteRune(unicode.ToLower(current))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
builder.WriteRune(current)
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
110
internal/bnp/mcp_tools_test.go
Normal file
110
internal/bnp/mcp_tools_test.go
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
package bnp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func wikiMCPManifest(hasHTTPHandler bool) *abiv1.PluginManifest {
|
||||||
|
procedure := "/wiki.v1.WikiService/SaveArticle"
|
||||||
|
return &abiv1.PluginManifest{
|
||||||
|
AbiVersion: 1,
|
||||||
|
Name: "wiki",
|
||||||
|
Version: "0.1.12",
|
||||||
|
HasHttpHandler: hasHTTPHandler,
|
||||||
|
RbacMethodRoles: map[string]string{procedure: "admin"},
|
||||||
|
McpTools: []*abiv1.MCPToolDescriptor{{
|
||||||
|
Procedure: procedure,
|
||||||
|
ServiceFullName: "wiki.v1.WikiService",
|
||||||
|
MethodName: "SaveArticle",
|
||||||
|
InputSchemaJson: []byte(`{"type":"object","properties":{"article":{"type":"object"}}}`),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMCPManifestAcceptsCallableWikiDescriptor(t *testing.T) {
|
||||||
|
manifest := wikiMCPManifest(true)
|
||||||
|
if err := ValidateMCPManifest(manifest, "@ninja", "wiki"); err != nil {
|
||||||
|
t.Fatalf("ValidateMCPManifest() error = %v", err)
|
||||||
|
}
|
||||||
|
name, err := mcpToolName("@ninja", "wiki", "wiki.v1.WikiService", "SaveArticle")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mcpToolName() error = %v", err)
|
||||||
|
}
|
||||||
|
if name != "plugin_ninja_wiki_v1_save_article" {
|
||||||
|
t.Fatalf("tool name = %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMCPManifestRejectsMissingRuntimeHandler(t *testing.T) {
|
||||||
|
err := ValidateMCPManifest(wikiMCPManifest(false), "@ninja", "wiki")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "has_http_handler") {
|
||||||
|
t.Fatalf("error = %v, want has_http_handler rejection", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMCPManifestRejectsMalformedDescriptors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*abiv1.PluginManifest)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unmapped procedure",
|
||||||
|
mutate: func(manifest *abiv1.PluginManifest) {
|
||||||
|
manifest.RbacMethodRoles = nil
|
||||||
|
},
|
||||||
|
want: "RBAC role",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid schema",
|
||||||
|
mutate: func(manifest *abiv1.PluginManifest) {
|
||||||
|
manifest.McpTools[0].InputSchemaJson = []byte(`{"type":"string"}`)
|
||||||
|
},
|
||||||
|
want: "input schema",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing API major",
|
||||||
|
mutate: func(manifest *abiv1.PluginManifest) {
|
||||||
|
descriptor := manifest.McpTools[0]
|
||||||
|
descriptor.Procedure = "/wiki.WikiService/SaveArticle"
|
||||||
|
descriptor.ServiceFullName = "wiki.WikiService"
|
||||||
|
manifest.RbacMethodRoles = map[string]string{descriptor.Procedure: "admin"}
|
||||||
|
},
|
||||||
|
want: "API major",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
manifest := wikiMCPManifest(true)
|
||||||
|
test.mutate(manifest)
|
||||||
|
err := ValidateMCPManifest(manifest, "@ninja", "wiki")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejectsMCPDescriptorsWithoutRuntimeHandler(t *testing.T) {
|
||||||
|
manifestBytes, err := proto.Marshal(wikiMCPManifest(false))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal manifest: %v", err)
|
||||||
|
}
|
||||||
|
out := filepath.Join(t.TempDir(), "wiki.bnp")
|
||||||
|
_, err = packArtifact(out, []packEntry{
|
||||||
|
{ArtifactPath: fileWasm, Data: []byte("wasm")},
|
||||||
|
{ArtifactPath: fileMod, Data: []byte("[plugin]\nname = \"wiki\"\nscope = \"@ninja\"\nversion = \"0.1.12\"\n")},
|
||||||
|
{ArtifactPath: fileManifest, Data: manifestBytes},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pack artifact: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Verify(out); err == nil || !strings.Contains(err.Error(), "has_http_handler") {
|
||||||
|
t.Fatalf("Verify() error = %v, want has_http_handler rejection", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -121,6 +121,12 @@ func Verify(bnpPath string) (*VerifyResult, error) {
|
|||||||
if modName != name {
|
if modName != name {
|
||||||
return nil, fmt.Errorf("bnp: manifest name %q != plugin.mod name %q", name, modName)
|
return nil, fmt.Errorf("bnp: manifest name %q != plugin.mod name %q", name, modName)
|
||||||
}
|
}
|
||||||
|
if err := ValidateLoadOnceManifest(manifest); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ValidateMCPManifest(manifest, parseModString(modBytes, "scope"), name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &VerifyResult{
|
return &VerifyResult{
|
||||||
Name: name,
|
Name: name,
|
||||||
@ -173,6 +179,8 @@ func CodelessHookViolation(m *abiv1.PluginManifest) error {
|
|||||||
return viol("media hooks")
|
return viol("media hooks")
|
||||||
case m.GetHasProvisioner():
|
case m.GetHasProvisioner():
|
||||||
return viol("a provisioner hook")
|
return viol("a provisioner hook")
|
||||||
|
case len(m.GetLoadOnceKeys()) > 0:
|
||||||
|
return viol("load-once hooks")
|
||||||
case len(m.GetJobTypes()) > 0:
|
case len(m.GetJobTypes()) > 0:
|
||||||
return viol("job handlers")
|
return viol("job handlers")
|
||||||
case len(m.GetRagContentFetcherTypes()) > 0:
|
case len(m.GetRagContentFetcherTypes()) > 0:
|
||||||
@ -332,9 +340,13 @@ func dirExists(p string) bool {
|
|||||||
// mirroring the reader's tolerant line scan (works whether or not the key sits
|
// mirroring the reader's tolerant line scan (works whether or not the key sits
|
||||||
// under a [plugin] table).
|
// under a [plugin] table).
|
||||||
func parseModName(data []byte) string {
|
func parseModName(data []byte) string {
|
||||||
|
return parseModString(data, "name")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseModString(data []byte, key string) string {
|
||||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||||
for sc.Scan() {
|
for sc.Scan() {
|
||||||
after, ok := strings.CutPrefix(strings.TrimSpace(sc.Text()), "name")
|
after, ok := strings.CutPrefix(strings.TrimSpace(sc.Text()), key)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user