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>
328 lines
11 KiB
Go
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 }
|