feat(bnp): codeless .bnp artifacts — declarative plugins with no wasm (WO-WZ-020)

PluginManifest gains `codeless` (additive, buf-breaking clean): an artifact
with NO plugin.wasm that the host runs entirely. `ninja plugin build`
classifies by repo shape — no Go source → codeless (manifest synthesized from
plugin.mod + optional manifest.yaml: theme presets, fonts, settings schema,
master pages, CSS, icon packs, deps); Go source → wasm as always. New
`--codeless` flag asserts the expectation.

Both artifact kinds now pack the declarative dirs: blocks/ (the cms
blocks.yaml manifest-FS layout — definition-backed blocks), templates/, and
seed/ (seed.json: settings/media/pages/menu items, applied host-side via the
WO-WZ-019 provisioner). The packer structurally validates blocks.yaml and
seed.json (schema JSON validity, file presence, traversal safety); full
semantic validation stays host-side at install.

`ninja plugin verify`: plugin.wasm is required exactly when NOT codeless; a
codeless manifest declaring any computing hook (http/jobs/load/unload/media/
RAG/tags/filters/services/guest blocks/data_dir) is rejected —
CodelessHookViolation mirrors the cms reader check (lockstep duplication).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-04 11:30:00 +08:00
parent 925b6051a3
commit a3b261dbe4
8 changed files with 709 additions and 7 deletions

View File

@ -113,6 +113,18 @@ message PluginManifest {
// name the host registers a pongo2 filter that invokes the guest via
// HOOK_APPLY_FILTER. Captured by DESCRIBE from the blocks filter registry.
repeated string declared_filters = 32;
// codeless marks a declarative artifact with NO plugin.wasm (WO-WZ-020):
// the host runs it entirely block definitions from the artifact's
// blocks/ dir (blocks.yaml manifest-FS layout), seed data from seed/,
// assets/migrations as usual and never instantiates a guest. A codeless
// manifest MUST NOT declare any computing hook (http handler, jobs,
// load/unload, RAG fetchers, media hooks, tags/filters, provisioner,
// service bindings, directory callbacks); the packer and the host reader
// both reject the combination. Not produced by DESCRIBE (there is no guest
// to describe): `ninja plugin build` synthesizes the manifest from
// plugin.mod + the artifact's declarative files.
bool codeless = 33;
}
// Dependency mirrors plugin.Dependency.

View File

@ -112,8 +112,19 @@ type PluginManifest struct {
// name the host registers a pongo2 filter that invokes the guest via
// HOOK_APPLY_FILTER. Captured by DESCRIBE from the blocks filter registry.
DeclaredFilters []string `protobuf:"bytes,32,rep,name=declared_filters,json=declaredFilters,proto3" json:"declared_filters,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// codeless marks a declarative artifact with NO plugin.wasm (WO-WZ-020):
// the host runs it entirely — block definitions from the artifact's
// blocks/ dir (blocks.yaml manifest-FS layout), seed data from seed/,
// assets/migrations as usual — and never instantiates a guest. A codeless
// manifest MUST NOT declare any computing hook (http handler, jobs,
// load/unload, RAG fetchers, media hooks, tags/filters, provisioner,
// service bindings, directory callbacks); the packer and the host reader
// both reject the combination. Not produced by DESCRIBE (there is no guest
// to describe): `ninja plugin build` synthesizes the manifest from
// plugin.mod + the artifact's declarative files.
Codeless bool `protobuf:"varint,33,opt,name=codeless,proto3" json:"codeless,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PluginManifest) Reset() {
@ -370,6 +381,13 @@ func (x *PluginManifest) GetDeclaredFilters() []string {
return nil
}
func (x *PluginManifest) GetCodeless() bool {
if x != nil {
return x.Codeless
}
return false
}
// Dependency mirrors plugin.Dependency.
type Dependency struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -1282,7 +1300,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor
const file_v1_manifest_proto_rawDesc = "" +
"\n" +
"\x11v1/manifest.proto\x12\x06abi.v1\"\xf2\f\n" +
"\x11v1/manifest.proto\x12\x06abi.v1\"\x8e\r\n" +
"\x0ePluginManifest\x12\x1f\n" +
"\vabi_version\x18\x01 \x01(\rR\n" +
"abiVersion\x12\x12\n" +
@ -1319,7 +1337,8 @@ const file_v1_manifest_proto_rawDesc = "" +
"\x15core_service_bindings\x18\x1d \x03(\v2\x1a.abi.v1.CoreServiceBindingR\x13coreServiceBindings\x12\x19\n" +
"\bdata_dir\x18\x1e \x01(\bR\adataDir\x12#\n" +
"\rdeclared_tags\x18\x1f \x03(\tR\fdeclaredTags\x12)\n" +
"\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x1aB\n" +
"\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x12\x1a\n" +
"\bcodeless\x18! \x01(\bR\bcodeless\x1aB\n" +
"\x14RbacMethodRolesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"a\n" +

View File

@ -12,6 +12,7 @@ import (
func newPluginBuildCmd() *cobra.Command {
var dir, output string
var codeless bool
cmd := &cobra.Command{
Use: "build",
Short: "Compile a plugin to wasm and pack a .bnp artifact",
@ -23,7 +24,22 @@ schemas/, assets/, web/dist when present).
No Docker or podman: the whole pipeline is the local Go toolchain (>= 1.24)
plus wazero. This replaces the in-container .so compile.`,
RunE: func(c *cobra.Command, _ []string) error {
res, err := bnp.Build(context.Background(), bnp.BuildOptions{Dir: dir, Output: output})
// Classify by repo shape (WO-WZ-020): no Go source → codeless
// (declarative, no wasm); Go source → wasm. --codeless asserts
// the expectation and fails loudly on a mismatch.
isCodeless, err := bnp.IsCodelessRepo(dir)
if err != nil {
return err
}
if codeless && !isCodeless {
return fmt.Errorf("--codeless asserted but %s contains Go source — a codeless plugin has no code (delete the Go or drop the flag)", dir)
}
var res *bnp.BuildResult
if isCodeless {
res, err = bnp.BuildCodeless(context.Background(), bnp.BuildOptions{Dir: dir, Output: output})
} else {
res, err = bnp.Build(context.Background(), bnp.BuildOptions{Dir: dir, Output: output})
}
if err != nil {
return err
}
@ -33,6 +49,7 @@ plus wazero. This replaces the in-container .so compile.`,
}
cmd.Flags().StringVar(&dir, "dir", ".", "Plugin repo directory")
cmd.Flags().StringVarP(&output, "output", "o", "", "Output .bnp path (default <name>-<version>.bnp)")
cmd.Flags().BoolVar(&codeless, "codeless", false, "Assert the repo builds a codeless (no-wasm) artifact; fail if it contains Go source")
return cmd
}
@ -66,7 +83,11 @@ func printBuildSummary(r *bnp.BuildResult) {
if len(r.IncludedDirs) > 0 {
dirs = strings.Join(r.IncludedDirs, ", ")
}
fmt.Printf("Built %s@%s → %s\n", r.Name, r.Version, r.OutputPath)
kind := "wasm"
if r.Codeless {
kind = "codeless"
}
fmt.Printf("Built %s@%s (%s) → %s\n", r.Name, r.Version, kind, r.OutputPath)
fmt.Println(" ┌───────────────────────────────────────────")
fmt.Printf(" │ artifact size %s (%s uncompressed)\n", humanBytes(r.ArtifactBytes), humanBytes(r.UncompBytes))
fmt.Printf(" │ plugin.wasm %s\n", humanBytes(r.WasmBytes))

View File

@ -44,6 +44,8 @@ type BuildResult struct {
OutputPath string
Name string
Version string
// Codeless marks a declarative artifact (no plugin.wasm, WO-WZ-020).
Codeless bool
WasmBytes int64
ManifestBytes int64
ArtifactBytes int64 // on-disk .bnp size
@ -136,11 +138,22 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
// dir-name → source subpath. web ships the Module Federation build output
// (web/dist) flattened under web/ so the reader's dirExists("web") fires.
optional := []struct{ artifact, src string }{
{dirBlocks, dirBlocks},
{dirTemplates, dirTemplates},
{dirSeed, dirSeed},
{"migrations", "migrations"},
{"schemas", "schemas"},
{"assets", "assets"},
{"web", filepath.Join("web", "dist")},
}
// Declarative dirs ride along on wasm artifacts too (a reduced plugin may
// mix definition-backed blocks with logic); validate them identically.
if _, err := validateBlocksDir(dir); err != nil {
return nil, err
}
if err := validateSeedDir(dir); err != nil {
return nil, err
}
for _, o := range optional {
dirEntries, has, dErr := collectDir(filepath.Join(dir, o.src), o.artifact)
if dErr != nil {

View File

@ -0,0 +1,429 @@
package bnp
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
core "git.dev.alexdunmow.com/block/core/plugin"
"google.golang.org/protobuf/proto"
"gopkg.in/yaml.v3"
)
// Codeless artifacts (WO-WZ-020): a .bnp with NO plugin.wasm — pure
// declaration the host runs. Classification is by repo shape: a plugin repo
// with no Go source builds codeless; a repo with Go builds wasm as always
// (a converted repo DELETES its Go — that's the point). Both artifact kinds
// may carry blocks/, templates/, and seed/; codeless just has nothing else.
// Declarative artifact dirs shared by both build paths.
const (
dirBlocks = "blocks"
dirTemplates = "templates"
dirSeed = "seed"
)
// manifestYAMLName is the optional root-level declarative manifest source for
// codeless plugins (the counterpart of what DESCRIBE captures from Go code).
const manifestYAMLName = "manifest.yaml"
// manifestYAML mirrors the declarative PluginManifest fields a codeless
// plugin can set. File-valued keys are paths relative to the repo root.
type manifestYAML struct {
ThemePresets string `yaml:"theme_presets"` // JSON file
BundledFonts string `yaml:"bundled_fonts"` // JSON file
SettingsSchema string `yaml:"settings_schema"` // JSON file
MasterPages string `yaml:"master_pages"` // JSON file ([]masterPageJSON)
RequiredIconPacks []string `yaml:"required_icon_packs"`
CSS *struct {
NpmPackages map[string]string `yaml:"npm_packages"`
CSSDirectives []string `yaml:"css_directives"`
InputCSSAppend string `yaml:"input_css_append"`
} `yaml:"css"`
Dependencies []struct {
Plugin string `yaml:"plugin"`
MinVersion string `yaml:"min_version"`
Required bool `yaml:"required"`
} `yaml:"dependencies"`
}
// masterPageJSON mirrors abiv1.MasterPageDefinition for the declarative file.
type masterPageJSON struct {
Key string `json:"key"`
Title string `json:"title"`
PageTemplates []string `json:"page_templates"`
Blocks []struct {
BlockKey string `json:"block_key"`
Title string `json:"title"`
Content map[string]any `json:"content"`
HTMLContent *string `json:"html_content"`
Slot string `json:"slot"`
SortOrder int32 `json:"sort_order"`
} `json:"blocks"`
}
// blocksYAML is the structural mirror of the CMS blocks.yaml manifest
// (cms blocks.LoadManifest) — a deliberate light duplication, same as the
// Verify↔reader pairing: the packer validates shape and file presence; full
// semantic validation (schema/template/provider checks) runs at install.
type blocksYAML struct {
Blocks []struct {
Key string `yaml:"key"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Category string `yaml:"category"`
Schema string `yaml:"schema"`
Template string `yaml:"template"`
SampleData string `yaml:"sample_data"`
Providers []string `yaml:"providers"`
RequiredTags []string `yaml:"required_tags"`
Aliases []string `yaml:"aliases"`
} `yaml:"blocks"`
}
// seedJSON is the declarative seed schema (seed/seed.json). Applied by the
// host at load via the WO-WZ-019 provisioner (idempotent).
type seedJSON struct {
Settings *struct {
Merge map[string]any `json:"merge"`
Override map[string]any `json:"override"`
Ensure map[string]any `json:"ensure"`
} `json:"settings"`
Media []struct {
ID string `json:"id"` // UUID, required (deterministic media key)
File string `json:"file"`
Alt string `json:"alt"`
Folder string `json:"folder"`
} `json:"media"`
Pages []struct {
Slug string `json:"slug"`
ParentSlug string `json:"parent_slug"`
Title string `json:"title"`
TemplateKey string `json:"template_key"`
ReconcileBlocks bool `json:"reconcile_blocks"`
ReconcileTemplate bool `json:"reconcile_template"`
Blocks []struct {
BlockKey string `json:"block_key"`
Title string `json:"title"`
Content map[string]any `json:"content"`
HTMLContent *string `json:"html_content"`
Slot string `json:"slot"`
SortOrder int32 `json:"sort_order"`
} `json:"blocks"`
} `json:"pages"`
MenuItems []struct {
Menu string `json:"menu"`
Label string `json:"label"`
URL string `json:"url"`
PageSlug string `json:"page_slug"`
SortOrder int32 `json:"sort_order"`
} `json:"menu_items"`
}
// IsCodelessRepo reports whether dir is a declarative plugin repo: it has a
// plugin.mod but no Go source at the root (the wasm main package's home).
func IsCodelessRepo(dir string) (bool, error) {
if _, err := os.Stat(filepath.Join(dir, fileMod)); err != nil {
return false, fmt.Errorf("read plugin.mod (is %s a plugin repo?): %w", dir, err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return false, err
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") {
return false, nil
}
}
return true, nil
}
// BuildCodeless synthesizes manifest.pb from plugin.mod + the declarative
// files and packs a codeless .bnp (no plugin.wasm, no DESCRIBE probe).
func BuildCodeless(_ context.Context, opts BuildOptions) (*BuildResult, error) {
dir := opts.Dir
if dir == "" {
dir = "."
}
dir, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("resolve dir: %w", err)
}
modPath := filepath.Join(dir, fileMod)
modBytes, err := os.ReadFile(modPath)
if err != nil {
return nil, fmt.Errorf("read plugin.mod: %w", err)
}
mod, err := core.ParseModFull(modBytes)
if err != nil {
return nil, fmt.Errorf("parse plugin.mod: %w", err)
}
if mod.Plugin.Name == "" || mod.Plugin.Version == "" {
return nil, fmt.Errorf("plugin.mod must set both name and version")
}
if mod.Plugin.DataDir {
return nil, fmt.Errorf("codeless plugin cannot request data_dir — there is no code to use it")
}
manifest := &abiv1.PluginManifest{
AbiVersion: hostAbiVersion,
Name: mod.Plugin.Name,
Version: mod.Plugin.Version,
Codeless: true,
}
blockCount, err := validateBlocksDir(dir)
if err != nil {
return nil, err
}
if err := validateSeedDir(dir); err != nil {
return nil, err
}
if err := applyManifestYAML(dir, manifest); err != nil {
return nil, err
}
manifestBytes, err := proto.Marshal(manifest)
if err != nil {
return nil, fmt.Errorf("marshal manifest.pb: %w", err)
}
entries := []packEntry{
{ArtifactPath: fileMod, Source: modPath},
{ArtifactPath: fileManifest, Data: manifestBytes},
}
var includedDirs []string
optional := []struct{ artifact, src string }{
{dirBlocks, dirBlocks},
{dirTemplates, dirTemplates},
{dirSeed, dirSeed},
{"migrations", "migrations"},
{"schemas", "schemas"},
{"assets", "assets"},
}
for _, o := range optional {
dirEntries, has, dErr := collectDir(filepath.Join(dir, o.src), o.artifact)
if dErr != nil {
return nil, dErr
}
if has {
entries = append(entries, dirEntries...)
includedDirs = append(includedDirs, o.artifact)
}
}
outPath := opts.Output
if outPath == "" {
outPath = fmt.Sprintf("%s-%s.bnp", mod.Plugin.Name, mod.Plugin.Version)
}
uncomp, err := packArtifact(outPath, entries)
if err != nil {
return nil, err
}
artInfo, err := os.Stat(outPath)
if err != nil {
return nil, fmt.Errorf("stat artifact: %w", err)
}
return &BuildResult{
OutputPath: outPath,
Name: manifest.GetName(),
Version: manifest.GetVersion(),
Codeless: true,
ManifestBytes: int64(len(manifestBytes)),
ArtifactBytes: artInfo.Size(),
UncompBytes: uncomp,
BlockCount: blockCount,
IncludedDirs: includedDirs,
}, nil
}
// validateBlocksDir structurally validates blocks/blocks.yaml when present:
// every entry has key/title/schema/template, referenced files exist within
// blocks/, and schemas are valid JSON. Returns the block count (0 when the
// dir is absent).
func validateBlocksDir(dir string) (int, error) {
root := filepath.Join(dir, dirBlocks)
manifestPath := filepath.Join(root, "blocks.yaml")
if _, err := os.Stat(root); os.IsNotExist(err) {
return 0, nil
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
return 0, fmt.Errorf("blocks/ present but blocks.yaml unreadable: %w", err)
}
var bf blocksYAML
if err := yaml.Unmarshal(raw, &bf); err != nil {
return 0, fmt.Errorf("parse blocks/blocks.yaml: %w", err)
}
seen := map[string]bool{}
for i, b := range bf.Blocks {
if b.Key == "" {
return 0, fmt.Errorf("blocks.yaml: entry %d has no key", i)
}
if seen[b.Key] {
return 0, fmt.Errorf("blocks.yaml: duplicate key %q", b.Key)
}
seen[b.Key] = true
if b.Title == "" || b.Schema == "" || b.Template == "" {
return 0, fmt.Errorf("blocks.yaml: %q must declare title, schema, and template", b.Key)
}
for _, f := range []string{b.Schema, b.Template, b.SampleData} {
if f == "" {
continue
}
if err := fileWithin(root, f); err != nil {
return 0, fmt.Errorf("blocks.yaml: %q: %w", b.Key, err)
}
}
schemaRaw, err := os.ReadFile(filepath.Join(root, b.Schema))
if err != nil {
return 0, fmt.Errorf("blocks.yaml: %q schema: %w", b.Key, err)
}
if !json.Valid(schemaRaw) {
return 0, fmt.Errorf("blocks.yaml: %q schema %s is not valid JSON", b.Key, b.Schema)
}
}
return len(bf.Blocks), nil
}
// validateSeedDir validates seed/seed.json when the dir is present: it
// parses, media entries carry an id + an existing file, pages carry slugs.
func validateSeedDir(dir string) error {
root := filepath.Join(dir, dirSeed)
if _, err := os.Stat(root); os.IsNotExist(err) {
return nil
}
raw, err := os.ReadFile(filepath.Join(root, "seed.json"))
if err != nil {
return fmt.Errorf("seed/ present but seed.json unreadable: %w", err)
}
var sf seedJSON
if err := json.Unmarshal(raw, &sf); err != nil {
return fmt.Errorf("parse seed/seed.json: %w", err)
}
for i, m := range sf.Media {
if m.ID == "" || m.File == "" {
return fmt.Errorf("seed.json: media entry %d needs both id and file", i)
}
if err := fileWithin(root, m.File); err != nil {
return fmt.Errorf("seed.json: media %q: %w", m.ID, err)
}
}
for i, p := range sf.Pages {
if p.Slug == "" || p.Title == "" {
return fmt.Errorf("seed.json: page entry %d needs slug and title", i)
}
}
for i, mi := range sf.MenuItems {
if mi.Menu == "" || mi.Label == "" {
return fmt.Errorf("seed.json: menu item %d needs menu and label", i)
}
}
return nil
}
// applyManifestYAML folds the optional root manifest.yaml into the manifest.
func applyManifestYAML(dir string, m *abiv1.PluginManifest) error {
raw, err := os.ReadFile(filepath.Join(dir, manifestYAMLName))
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read %s: %w", manifestYAMLName, err)
}
var my manifestYAML
if err := yaml.Unmarshal(raw, &my); err != nil {
return fmt.Errorf("parse %s: %w", manifestYAMLName, err)
}
readJSONFile := func(rel, what string) ([]byte, error) {
if rel == "" {
return nil, nil
}
if err := fileWithin(dir, rel); err != nil {
return nil, fmt.Errorf("%s: %s: %w", manifestYAMLName, what, err)
}
b, err := os.ReadFile(filepath.Join(dir, rel))
if err != nil {
return nil, fmt.Errorf("%s: %s: %w", manifestYAMLName, what, err)
}
if !json.Valid(b) {
return nil, fmt.Errorf("%s: %s %s is not valid JSON", manifestYAMLName, what, rel)
}
return b, nil
}
if m.ThemePresets, err = readJSONFile(my.ThemePresets, "theme_presets"); err != nil {
return err
}
if m.BundledFonts, err = readJSONFile(my.BundledFonts, "bundled_fonts"); err != nil {
return err
}
if m.SettingsSchema, err = readJSONFile(my.SettingsSchema, "settings_schema"); err != nil {
return err
}
m.RequiredIconPacks = my.RequiredIconPacks
if my.CSS != nil {
m.CssManifest = &abiv1.CssManifest{
NpmPackages: my.CSS.NpmPackages,
CssDirectives: my.CSS.CSSDirectives,
InputCssAppend: my.CSS.InputCSSAppend,
}
}
for _, d := range my.Dependencies {
m.Dependencies = append(m.Dependencies, &abiv1.Dependency{
Plugin: d.Plugin, MinVersion: d.MinVersion, Required: d.Required,
})
}
if my.MasterPages != "" {
mpRaw, err := readJSONFile(my.MasterPages, "master_pages")
if err != nil {
return err
}
var mps []masterPageJSON
if err := json.Unmarshal(mpRaw, &mps); err != nil {
return fmt.Errorf("%s: master_pages %s: %w", manifestYAMLName, my.MasterPages, err)
}
for _, mp := range mps {
def := &abiv1.MasterPageDefinition{
Key: mp.Key, Title: mp.Title, PageTemplates: mp.PageTemplates,
}
for _, b := range mp.Blocks {
contentJSON, err := json.Marshal(b.Content)
if err != nil {
return fmt.Errorf("master page %q block %q: %w", mp.Key, b.BlockKey, err)
}
def.Blocks = append(def.Blocks, &abiv1.MasterPageBlock{
BlockKey: b.BlockKey, Title: b.Title, ContentJson: contentJSON,
HtmlContent: b.HTMLContent, Slot: b.Slot, SortOrder: b.SortOrder,
})
}
m.MasterPages = append(m.MasterPages, def)
}
}
return nil
}
// fileWithin ensures rel names an existing regular file inside root (no
// traversal escapes).
func fileWithin(root, rel string) error {
clean := filepath.Clean(rel)
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
return fmt.Errorf("path %q escapes the plugin directory", rel)
}
fi, err := os.Stat(filepath.Join(root, clean))
if err != nil {
return fmt.Errorf("referenced file %q: %w", rel, err)
}
if !fi.Mode().IsRegular() {
return fmt.Errorf("referenced path %q is not a regular file", rel)
}
return nil
}

View File

@ -0,0 +1,152 @@
package bnp
import (
"context"
"os"
"path/filepath"
"testing"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
)
// writeFixture lays out a minimal codeless plugin repo.
func writeFixture(t *testing.T, dir string, files map[string]string) {
t.Helper()
for rel, content := range files {
path := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
}
func codelessFixture(t *testing.T) string {
t.Helper()
dir := t.TempDir()
writeFixture(t, dir, map[string]string{
"plugin.mod": "[plugin]\nname = \"fixture-theme\"\nversion = \"0.1.0\"\nkind = \"theme\"\n",
"manifest.yaml": "theme_presets: presets.json\nrequired_icon_packs: [lucide]\n" +
"master_pages: master_pages.json\n",
"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}]}]`,
"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",
"blocks/hero.schema.json": `{"type":"object"}`,
"blocks/hero.ninjatpl": `<h1>{{ title }}</h1>`,
"seed/seed.json": `{"settings":{"ensure":{"welcome":true}},` +
`"pages":[{"slug":"/","title":"Home","template_key":"landing"}],` +
`"menu_items":[{"menu":"main","label":"Home","page_slug":"/","sort_order":1}]}`,
"assets/logo.svg": `<svg/>`,
})
return dir
}
func TestCodelessBuildAndVerifyRoundTrip(t *testing.T) {
dir := codelessFixture(t)
isCodeless, err := IsCodelessRepo(dir)
if err != nil || !isCodeless {
t.Fatalf("IsCodelessRepo = %v, %v; want true", isCodeless, err)
}
out := filepath.Join(t.TempDir(), "fixture-theme-0.1.0.bnp")
res, err := BuildCodeless(context.Background(), BuildOptions{Dir: dir, Output: out})
if err != nil {
t.Fatalf("BuildCodeless: %v", err)
}
if !res.Codeless || res.BlockCount != 1 {
t.Errorf("result = %+v; want codeless with 1 block", res)
}
v, err := Verify(out)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !v.Codeless || !v.HasBlocks || !v.HasSeed || !v.HasAssets || v.Name != "fixture-theme" {
t.Errorf("verify = %+v", v)
}
m, err := ReadManifest(out)
if err != nil {
t.Fatal(err)
}
if !m.GetCodeless() || len(m.GetMasterPages()) != 1 || len(m.GetThemePresets()) == 0 {
t.Errorf("manifest = codeless:%v masters:%d presets:%dB",
m.GetCodeless(), len(m.GetMasterPages()), len(m.GetThemePresets()))
}
if len(m.GetRequiredIconPacks()) != 1 || m.GetRequiredIconPacks()[0] != "lucide" {
t.Errorf("icon packs = %v", m.GetRequiredIconPacks())
}
}
func TestIsCodelessRepoFalseWithGoSource(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"main.go": "package main\nfunc main() {}\n",
})
isCodeless, err := IsCodelessRepo(dir)
if err != nil || isCodeless {
t.Fatalf("IsCodelessRepo = %v, %v; want false", isCodeless, err)
}
}
func TestCodelessBuildRejectsBadDeclarations(t *testing.T) {
cases := []struct {
name string
files map[string]string
}{
{"bad blocks yaml", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"blocks/blocks.yaml": "blocks:\n - key: hero\n", // missing title/schema/template
}},
{"missing template file", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"blocks/blocks.yaml": "blocks:\n - key: hero\n title: Hero\n" +
" schema: hero.schema.json\n template: missing.ninjatpl\n",
"blocks/hero.schema.json": `{}`,
}},
{"seed media without id", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"seed/seed.json": `{"media":[{"file":"a.jpg"}]}`,
"seed/a.jpg": "x",
}},
{"data_dir grant", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\ndata_dir = true\n",
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, tc.files)
_, err := BuildCodeless(context.Background(), BuildOptions{
Dir: dir, Output: filepath.Join(t.TempDir(), "x.bnp"),
})
if err == nil {
t.Fatal("BuildCodeless succeeded; want error")
}
})
}
}
func TestCodelessHookViolation(t *testing.T) {
ok := &abiv1.PluginManifest{Codeless: true, Name: "x"}
if err := CodelessHookViolation(ok); err != nil {
t.Fatalf("clean manifest flagged: %v", err)
}
bad := []*abiv1.PluginManifest{
{Codeless: true, HasHttpHandler: true},
{Codeless: true, JobTypes: []string{"j"}},
{Codeless: true, DeclaredTags: []string{"t"}},
{Codeless: true, Blocks: []*abiv1.BlockMeta{{Key: "k"}}},
{Codeless: true, DataDir: true},
}
for i, m := range bad {
if err := CodelessHookViolation(m); err == nil {
t.Errorf("case %d not flagged", i)
}
}
}

View File

@ -37,12 +37,16 @@ type VerifyResult struct {
Name string
Version string
ABIVersion uint32
Codeless bool
DataDir bool
BlockCount int
HasMigrations bool
HasSchemas bool
HasAssets bool
HasWeb bool
HasBlocks bool
HasTemplates bool
HasSeed bool
}
// Verify extracts bnpPath into a temp dir and applies the reader's checks:
@ -67,7 +71,7 @@ func Verify(bnpPath string) (*VerifyResult, error) {
return nil, err
}
for _, req := range []string{fileWasm, fileMod, fileManifest} {
for _, req := range []string{fileMod, fileManifest} {
if !fileRegular(filepath.Join(destDir, req)) {
return nil, fmt.Errorf("bnp: artifact missing required %s", req)
}
@ -81,6 +85,19 @@ func Verify(bnpPath string) (*VerifyResult, error) {
if err := proto.Unmarshal(manifestBytes, manifest); err != nil {
return nil, fmt.Errorf("bnp: decode manifest.pb: %w", err)
}
// plugin.wasm is required exactly when the manifest is NOT codeless; a
// codeless artifact must additionally declare no computing hooks
// (WO-WZ-020 — the classifier should have caught this at build).
if manifest.GetCodeless() {
if fileRegular(filepath.Join(destDir, fileWasm)) {
return nil, errors.New("bnp: codeless manifest but plugin.wasm present — rebuild without the wasm or drop codeless")
}
if err := CodelessHookViolation(manifest); err != nil {
return nil, err
}
} else if !fileRegular(filepath.Join(destDir, fileWasm)) {
return nil, fmt.Errorf("bnp: artifact missing required %s", fileWasm)
}
if v := manifest.GetAbiVersion(); v != supportedABIMajor {
return nil, fmt.Errorf("bnp: unsupported abi_version %d (host supports %d)", v, supportedABIMajor)
}
@ -105,15 +122,53 @@ func Verify(bnpPath string) (*VerifyResult, error) {
Name: name,
Version: manifest.GetVersion(),
ABIVersion: manifest.GetAbiVersion(),
Codeless: manifest.GetCodeless(),
DataDir: manifest.GetDataDir(),
BlockCount: len(manifest.GetBlocks()),
HasMigrations: dirExists(filepath.Join(destDir, "migrations")),
HasSchemas: dirExists(filepath.Join(destDir, "schemas")),
HasAssets: dirExists(filepath.Join(destDir, "assets")),
HasWeb: dirExists(filepath.Join(destDir, "web")),
HasBlocks: dirExists(filepath.Join(destDir, dirBlocks)),
HasTemplates: dirExists(filepath.Join(destDir, dirTemplates)),
HasSeed: dirExists(filepath.Join(destDir, dirSeed)),
}, nil
}
// CodelessHookViolation returns a named error when a codeless manifest
// declares any capability that requires guest code. Mirrored by the CMS
// reader (lockstep duplication, same as the rest of this file).
func CodelessHookViolation(m *abiv1.PluginManifest) error {
viol := func(what string) error {
return fmt.Errorf("bnp: codeless manifest declares %s — that requires guest code; build a wasm plugin instead", what)
}
switch {
case m.GetHasHttpHandler():
return viol("an HTTP handler")
case m.GetHasLoadHook() || m.GetHasUnloadHook():
return viol("load/unload hooks")
case m.GetHasMediaHooks():
return viol("media hooks")
case m.GetHasProvisioner():
return viol("a provisioner hook")
case len(m.GetJobTypes()) > 0:
return viol("job handlers")
case len(m.GetRagContentFetcherTypes()) > 0:
return viol("RAG content fetchers")
case len(m.GetDeclaredTags()) > 0 || len(m.GetDeclaredFilters()) > 0:
return viol("template tags/filters (express pure snippets as template partials)")
case len(m.GetRbacMethodRoles()) > 0 || len(m.GetCoreServiceBindings()) > 0:
return viol("Connect services")
case len(m.GetBlocks()) > 0 || len(m.GetTemplateKeys()) > 0 || len(m.GetSystemTemplates()) > 0 || len(m.GetPageTemplates()) > 0:
return viol("guest-rendered blocks/templates (codeless blocks live in blocks/blocks.yaml)")
case m.GetDirectoryExtensions().GetPanelSectionCount() > 0 || m.GetDirectoryExtensions().GetPinDecoratorCount() > 0:
return viol("directory extension callbacks")
case m.GetDataDir():
return viol("a data_dir grant")
}
return nil
}
// ReadManifest extracts a .bnp into a temp dir (with the same path-safety and
// size caps as Verify) and returns its decoded manifest.pb. It does not enforce
// the name-match / abi checks — use Verify for the full gate.

1
go.mod
View File

@ -15,6 +15,7 @@ require (
github.com/tetratelabs/wazero v1.12.0
golang.org/x/mod v0.34.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
require (