core/plugin/wasmguest/describe.go
Alex Dunmow c35199f0bc feat(wasmguest): guest runtime shim for the wasm plugin ABI (WO-WZ-002)
The wasip1 half of the ABI: bn_alloc/bn_invoke/bn_free exports with a
live-pin map so the GC never frees host-visible buffers, the generic
`blockninja.host_call` import (single import decided over per-family
symbols; recorded in wasm-abi.md), and a dispatch table adapting an
unmodified plugin.PluginRegistration to all nine v1 hooks. Panics inside
plugin hooks come back as ABI_ERROR_CODE_INTERNAL — the instance stays
callable; traps stay reserved for runtime corruption.

DESCRIBE builds the PluginManifest from the registration's static funcs
plus a capture-only Register pass (block metas via the same
PluginBlockRegistry prefixing the .so loader applies, template/system/
page-template/email-wrapper keys), probes JobHandlers/ServiceHandlers/
Load with capture-only services for job types, RBAC roles, core-service
bindings, and RAG fetcher types. RenderContext values are rehydrated
through the exact core/blocks context keys, so existing block code
reading from ctx works unchanged.

Plugins build in REACTOR mode (go build -buildmode=c-shared): init()
calls wasmguest.Serve (non-blocking), main is never called, and the host
runs _initialize before any bn_invoke. Command mode deadlocks or exits
(verified against wazero v1.12.0) — documented prominently in
wasm-abi.md, which also now reconciles the import module namespace to
`blockninja` and requires bn_alloc'd buffers on both directions.

Dispatch/describe/context logic is buildable on every GOOS; only
exports.go and hostcalls.go carry the wasip1 tag. dispatch_test.go
covers describe, hook routing, envelope mismatch, decode failures,
template-override resolution, panic recovery, and lifecycle hooks
natively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:53:24 +08:00

328 lines
11 KiB
Go

package wasmguest
import (
"context"
"encoding/json"
"fmt"
"net/http"
"connectrpc.com/connect"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/ai"
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/rbac"
"github.com/google/uuid"
"google.golang.org/protobuf/proto"
)
// AbiVersion is the ABI major version this guest shim implements
// (PluginManifest.abi_version). See core/docs/wasm-abi.md for the
// compatibility rules.
const AbiVersion uint32 = 1
// describe handles HOOK_DESCRIBE: validate the caller's ABI version and
// return the static PluginManifest. Publish-time only — never called on a
// live instance.
func (g *guest) describe(payload []byte) (proto.Message, *abiv1.AbiError) {
req := &abiv1.DescribeRequest{}
if err := proto.Unmarshal(payload, req); err != nil {
return nil, decodeError("DescribeRequest", err)
}
// Symmetric version gate: a newer guest shim refuses a host it does not
// support, mirroring the host's manifest rejection.
if req.GetHostAbiVersion() != AbiVersion {
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: fmt.Sprintf("guest implements ABI major version %d; host declared %d",
AbiVersion, req.GetHostAbiVersion()),
}
}
return &abiv1.DescribeResponse{Manifest: g.buildManifest()}, nil
}
// buildManifest assembles the PluginManifest from the registration's static
// funcs plus the Register captures. Field-by-field mapping:
// core/docs/wasm-abi.md §"Manifest ↔ PluginRegistration mapping".
func (g *guest) buildManifest() *abiv1.PluginManifest {
reg := g.reg
m := &abiv1.PluginManifest{
AbiVersion: AbiVersion,
Name: reg.Name,
Version: reg.Version,
HasHttpHandler: reg.HTTPHandler != nil,
HasLoadHook: reg.Load != nil,
HasUnloadHook: reg.Unload != nil,
HasMediaHooks: reg.MediaHooks != nil,
HasProvisioner: reg.RegisterWithProvisioner != nil,
}
for _, d := range reg.Dependencies {
m.Dependencies = append(m.Dependencies, &abiv1.Dependency{
Plugin: d.Plugin, MinVersion: d.MinVersion, Required: d.Required,
})
}
// Register captures. BlockMeta.Source is omitted deliberately — the host
// assigns it from the manifest name at load time.
for _, meta := range g.blocks.metas {
m.Blocks = append(m.Blocks, &abiv1.BlockMeta{
Key: meta.Key,
Title: meta.Title,
Description: meta.Description,
Category: string(meta.Category),
HasInternalSlot: meta.HasInternalSlot,
Hidden: meta.Hidden,
EditorJs: meta.EditorJS,
})
}
for _, o := range g.blocks.overrides {
m.BlockTemplateOverrides = append(m.BlockTemplateOverrides, &abiv1.BlockTemplateOverride{
TemplateKey: o.templateKey, BlockKey: o.blockKey, Source: o.source,
})
}
m.TemplateKeys = append(m.TemplateKeys, g.templates.templateKeys...)
for _, st := range g.templates.systemTemplates {
m.SystemTemplates = append(m.SystemTemplates, &abiv1.SystemTemplateMeta{
Key: st.Key, Title: st.Title, Description: st.Description,
})
}
for _, pt := range g.templates.pageTemplates {
m.PageTemplates = append(m.PageTemplates, &abiv1.PageTemplateMeta{
SystemKey: pt.systemKey,
Key: pt.meta.Key,
Title: pt.meta.Title,
Description: pt.meta.Description,
Slots: pt.meta.Slots,
})
}
seenWrapper := make(map[string]bool)
for _, ew := range g.templates.emailWrappers {
if !seenWrapper[ew.systemKey] {
seenWrapper[ew.systemKey] = true
m.EmailWrapperSystemKeys = append(m.EmailWrapperSystemKeys, ew.systemKey)
}
}
// Static funcs.
if reg.AdminPages != nil {
for _, p := range reg.AdminPages() {
m.AdminPages = append(m.AdminPages, &abiv1.AdminPage{
Key: p.Key, Title: p.Title, Icon: p.Icon, Route: p.Route,
})
}
}
if reg.SettingsSchema != nil {
m.SettingsSchema = reg.SettingsSchema()
}
if reg.ThemePresets != nil {
m.ThemePresets = reg.ThemePresets()
}
if reg.BundledFonts != nil {
m.BundledFonts = reg.BundledFonts()
}
if reg.MasterPages != nil {
for _, mp := range reg.MasterPages() {
m.MasterPages = append(m.MasterPages, masterPageToProto(mp))
}
}
if reg.AIActions != nil {
for _, a := range reg.AIActions() {
m.AiActions = append(m.AiActions, &abiv1.AiAction{
Key: a.Key,
Title: a.Title,
Description: a.Description,
DefaultProvider: a.DefaultProvider,
DefaultModel: a.DefaultModel,
})
}
}
if reg.CSSManifest != nil {
if cm := reg.CSSManifest(); cm != nil {
m.CssManifest = &abiv1.CssManifest{
NpmPackages: cm.NPMPackages,
CssDirectives: cm.CSSDirectives,
InputCssAppend: cm.InputCSSAppend,
}
}
}
m.RequiredIconPacks = append(m.RequiredIconPacks, reg.RequiredIconPacks...)
if reg.DirectoryExtensions != nil {
if de := reg.DirectoryExtensions(); de != nil {
pde := &abiv1.DirectoryExtensions{
BooleanFilterFields: de.BooleanFilterFields,
SelectFilterFields: de.SelectFilterFields,
PanelSectionCount: uint32(len(de.PanelSections)),
PinDecoratorCount: uint32(len(de.PinDecorators)),
}
if len(de.BadgeLabels) > 0 {
pde.BadgeLabels = make(map[string]*abiv1.BadgeLabel, len(de.BadgeLabels))
for k, v := range de.BadgeLabels {
pde.BadgeLabels[k] = &abiv1.BadgeLabel{Positive: v[0], Negative: v[1]}
}
}
m.DirectoryExtensions = pde
}
}
if reg.SettingsPanel != nil {
m.SettingsPanel = reg.SettingsPanel()
}
// Function fields probed with capture-only CoreServices. These calls run
// without a live host, so panics from touching unwired capabilities are
// swallowed — whatever was declared before the panic is still captured.
m.JobTypes = g.captureJobTypes()
m.RagContentFetcherTypes = g.captureRagFetcherTypes()
g.captureServiceRegistration(m)
return m
}
// captureJobTypes enumerates JobHandlers keys with describe-time services.
func (g *guest) captureJobTypes() (types []string) {
if g.reg.JobHandlers == nil {
return nil
}
defer func() { recover() }() //nolint:errcheck // publish-time probe; partial capture is intended
handlers := g.reg.JobHandlers(describeServices())
types = sortedKeys(handlers)
return types
}
// captureRagFetcherTypes runs Load against capture-only services to record
// RAGService.RegisterContentFetcher declarations. DESCRIBE is publish-time
// only, so Load's runtime side effects have no host to land on; a panic
// (e.g. from an unwired capability) ends the probe but keeps prior
// registrations.
func (g *guest) captureRagFetcherTypes() (types []string) {
if g.reg.Load == nil {
return nil
}
rag := &guestRAGService{fetchers: make(map[string]plugin.ContentFetcher)}
svcs := describeServices()
svcs.RAGService = rag
func() {
defer func() { recover() }() //nolint:errcheck // publish-time probe
_ = g.reg.Load(svcs)
}()
return sortedKeys(rag.fetchers)
}
// captureServiceRegistration probes ServiceHandlers to record RBAC method
// roles and CoreServiceBindings.Bind declarations.
func (g *guest) captureServiceRegistration(m *abiv1.PluginManifest) {
if g.reg.ServiceHandlers == nil {
return
}
bindings := &captureCoreServiceBindings{}
svcs := describeServices()
svcs.CoreServiceBindings = bindings
var sr *plugin.ServiceRegistration
func() {
defer func() { recover() }() //nolint:errcheck // publish-time probe
reg, err := g.reg.ServiceHandlers(svcs)
if err == nil {
sr = reg
}
}()
m.CoreServiceBindings = append(m.CoreServiceBindings, bindings.bound...)
if sr == nil {
return
}
coreBound := make(map[string]bool, len(bindings.bound))
for _, b := range bindings.bound {
coreBound[b.GetServiceName()] = true
}
for _, svc := range sr.Services {
if coreBound[svc.Name()] {
continue // declared via core_service_bindings, host mounts it
}
for method, role := range svc.MethodRoles() {
if m.RbacMethodRoles == nil {
m.RbacMethodRoles = make(map[string]string)
}
m.RbacMethodRoles[method] = string(role)
}
}
}
// describeServices returns the CoreServices value used to probe
// registration funcs at publish time: only side-effect-free capture stubs
// are populated. Anything else a plugin touches during the probe panics and
// is recovered by the caller.
func describeServices() plugin.CoreServices {
return plugin.CoreServices{
ToolRegistry: noopToolRegistry{},
}
}
func masterPageToProto(mp plugin.MasterPageDefinition) *abiv1.MasterPageDefinition {
out := &abiv1.MasterPageDefinition{
Key: mp.Key,
Title: mp.Title,
PageTemplates: mp.PageTemplates,
}
for _, b := range mp.Blocks {
pb := &abiv1.MasterPageBlock{
BlockKey: b.BlockKey,
Title: b.Title,
Slot: b.Slot,
SortOrder: b.SortOrder,
HtmlContent: b.HtmlContent,
}
if b.Content != nil {
if raw, err := json.Marshal(b.Content); err == nil {
pb.ContentJson = raw
}
}
out.Blocks = append(out.Blocks, pb)
}
return out
}
// --- capture / no-op stubs used only during DESCRIBE probes ---
type captureCoreServiceBindings struct {
bound []*abiv1.CoreServiceBinding
}
func (c *captureCoreServiceBindings) Bind(serviceName string, methodRoles map[string]rbac.Role) (plugin.ConnectServiceBinding, error) {
roles := make(map[string]string, len(methodRoles))
for k, v := range methodRoles {
roles[k] = string(v)
}
c.bound = append(c.bound, &abiv1.CoreServiceBinding{ServiceName: serviceName, MethodRoles: roles})
return plugin.NewConnectServiceBinding(serviceName, struct{}{},
func(_ struct{}, _ ...connect.HandlerOption) (string, http.Handler) {
return "/" + serviceName + "/", http.NotFoundHandler()
}, methodRoles), nil
}
type noopToolRegistry struct{}
func (noopToolRegistry) Register(*ai.ToolDefinition) {}
// noopProvisioner satisfies plugin.Provisioner for the DESCRIBE-time
// Register pass; real provisioning runs host-side at load.
type noopProvisioner struct{}
func (noopProvisioner) EnsureDataTable(plugin.DataTableConfig) error { return nil }
func (noopProvisioner) MergeSiteSettings(map[string]any) error { return nil }
func (noopProvisioner) EnsureSetting(string, any) error { return nil }
func (noopProvisioner) EnsurePage(plugin.PageConfig) error { return nil }
func (noopProvisioner) OverrideSiteSettings(map[string]any) error { return nil }
func (noopProvisioner) EnsureMenuItem(string, plugin.MenuItemConfig) error { return nil }
func (noopProvisioner) RegisterEmbeddingConfig(plugin.EmbeddingConfigDef) error {
return nil
}
func (noopProvisioner) EnsureEmbed(plugin.EmbedConfig) error { return nil }
func (noopProvisioner) EnsureJobSchedule(plugin.JobScheduleConfig) error { return nil }
func (noopProvisioner) UpdateDataTableRowField(context.Context, uuid.UUID, string, any) error {
return nil
}
func (noopProvisioner) DisableOrphanedJobSchedules([]string) error { return nil }
func (noopProvisioner) EnsurePlugin(string) error { return nil }
func (noopProvisioner) EnsureCustomColor(plugin.CustomColorConfig) error { return nil }
func (noopProvisioner) EnsureMedia(plugin.MediaDeposit) error { return nil }