diff --git a/Server/plugin/audit_closure_test.go b/Server/plugin/audit_closure_test.go new file mode 100644 index 00000000..744b15c1 --- /dev/null +++ b/Server/plugin/audit_closure_test.go @@ -0,0 +1,207 @@ +// Audit 2026-04-07 plugin CRITICAL closure invariants. +// +// Each test here pins a property the closure table in +// docs/audit-2026-04-07.md cites as the reason a CRITICAL is closed. They live +// in the default-build file set on purpose so they run on every +// `go test ./...`, not only under -tags wazero. + +package plugin + +import ( + "context" + "errors" + "strings" + "testing" +) + +// TestRegisterCommandRequiresManifestDeclaration locks finding #3 (per-command +// ACL). Holding the `commands` capability is not enough: only names the +// manifest's `commands` block declares may bind, so a guest module cannot +// widen its own command surface via list_commands. +func TestRegisterCommandRequiresManifestDeclaration(t *testing.T) { + mem := openPluginTestDB(t) + reg, err := NewRegistry(Config{Directory: t.TempDir(), Store: mem}) + if err != nil { + t.Fatalf("NewRegistry: %v", err) + } + t.Cleanup(func() { _ = reg.Close(context.Background()) }) + + manifest, err := ParseManifest([]byte( + `{"name":"claimer","version":"0.1.0","entrypoint":"p.wasm","permissions":["commands"],"commands":[{"name":"declared"}]}`)) + if err != nil { + t.Fatalf("ParseManifest: %v", err) + } + inst := &Instance{ID: 1, Manifest: manifest} + + if err := reg.RegisterCommand("declared", inst); err != nil { + t.Fatalf("declared command must bind: %v", err) + } + // "/DECLARED" normalizes to the declared name and must still bind. + if err := reg.RegisterCommand("/DECLARED", inst); err != nil { + t.Fatalf("normalized declared command must bind: %v", err) + } + for _, undeclared := range []string{"undeclared", "ban", "declared2"} { + if err := reg.RegisterCommand(undeclared, inst); !errors.Is(err, ErrCommandNotDeclared) { + t.Fatalf("RegisterCommand(%q) = %v, want ErrCommandNotDeclared", undeclared, err) + } + reg.mu.RLock() + _, bound := reg.commands[undeclared] + reg.mu.RUnlock() + if bound { + t.Fatalf("undeclared command %q must not be bound", undeclared) + } + } +} + +func TestManifestCommandsValidation(t *testing.T) { + bad := map[string]string{ + "no commands capability": `{"name":"x","version":"1","entrypoint":"x.wasm","commands":[{"name":"a"}]}`, + "uppercase name": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"Ban"}]}`, + "leading slash": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"/ban"}]}`, + "empty name": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":""}]}`, + "whitespace in name": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"a b"}]}`, + "duplicate name": `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"a"},{"name":"a"}]}`, + } + for label, body := range bad { + t.Run(label, func(t *testing.T) { + if _, err := ParseManifest([]byte(body)); err == nil { + t.Fatalf("expected rejection for %s", label) + } + }) + } + + // Extra per-command fields from the richer schema in + // docs/plans/slash-commands.md must still parse (forward compatibility). + m, err := ParseManifest([]byte( + `{"name":"x","version":"1","entrypoint":"x.wasm","permissions":["commands"],"commands":[{"name":"kick","description":"d","options":[]}]}`)) + if err != nil { + t.Fatalf("rich command spec must parse: %v", err) + } + if !m.DeclaresCommand("kick") || m.DeclaresCommand("ban") { + t.Fatalf("DeclaresCommand wrong: %+v", m.Commands) + } +} + +// TestStorageKeysIsolatedPerPlugin locks finding #2 (per-plugin key +// isolation). The namespace is the caller's Instance.ID, which no plugin- +// supplied input can influence, and plugin_kv's PRIMARY KEY (plugin_id, key) +// keeps identical keys from two plugins in separate rows. +func TestStorageKeysIsolatedPerPlugin(t *testing.T) { + ctx := context.Background() + mem := openPluginTestDB(t) + reg, err := NewRegistry(Config{Store: mem}) + if err != nil { + t.Fatal(err) + } + newInst := func(name string) *Instance { + id, instErr := mem.InstallPlugin(ctx, name, "0.1", "{}") + if instErr != nil { + t.Fatalf("InstallPlugin(%s): %v", name, instErr) + } + return &Instance{ + ID: id, + Manifest: &Manifest{Name: name, Permissions: []string{string(CapStorage)}}, + } + } + alice, mallory := newInst("alice"), newInst("mallory") + + if err := reg.StoragePut(ctx, alice, "secret", []byte("alice-value")); err != nil { + t.Fatalf("StoragePut alice: %v", err) + } + // Same key, different plugin: the writes must not collide. + if err := reg.StoragePut(ctx, mallory, "secret", []byte("mallory-value")); err != nil { + t.Fatalf("StoragePut mallory: %v", err) + } + got, err := reg.StorageGet(ctx, alice, "secret") + if err != nil || string(got) != "alice-value" { + t.Fatalf("alice's value was clobbered: got %q err %v", got, err) + } + + // Scan is namespaced too: an empty prefix returns only the caller's keys. + all, err := reg.StorageScan(ctx, mallory, "", 0) + if err != nil { + t.Fatalf("StorageScan: %v", err) + } + if len(all) != 1 || string(all["secret"]) != "mallory-value" { + t.Fatalf("scan leaked across plugin namespaces: %v", all) + } + + // Deleting from one namespace leaves the other intact. + if err := reg.StorageDelete(ctx, mallory, "secret"); err != nil { + t.Fatalf("StorageDelete: %v", err) + } + if got, err := reg.StorageGet(ctx, alice, "secret"); err != nil || string(got) != "alice-value" { + t.Fatalf("alice's key was deleted by mallory: got %q err %v", got, err) + } +} + +func TestStorageRejectsOversizedKeyAndValue(t *testing.T) { + ctx := context.Background() + mem := openPluginTestDB(t) + reg, err := NewRegistry(Config{Store: mem}) + if err != nil { + t.Fatal(err) + } + id, err := mem.InstallPlugin(ctx, "bloat", "0.1", "{}") + if err != nil { + t.Fatal(err) + } + inst := &Instance{ID: id, Manifest: &Manifest{Name: "bloat", Permissions: []string{string(CapStorage)}}} + + if err := reg.StoragePut(ctx, inst, "", []byte("v")); err == nil { + t.Fatal("empty key must be rejected") + } + if err := reg.StoragePut(ctx, inst, strings.Repeat("k", maxPluginKeyBytes+1), []byte("v")); err == nil { + t.Fatal("oversized key must be rejected") + } + if err := reg.StoragePut(ctx, inst, "k", make([]byte, maxPluginValueBytes+1)); err == nil { + t.Fatal("oversized value must be rejected") + } + if err := reg.StoragePut(ctx, inst, strings.Repeat("k", maxPluginKeyBytes), make([]byte, maxPluginValueBytes)); err != nil { + t.Fatalf("at-limit key/value must be accepted: %v", err) + } +} + +// TestEventDeliveryHasNoGuestPath locks finding #4. The finding (a plugin +// slowing the server by handling events slowly) is not reachable today: +// Subscribe requires the capability and Dispatch invokes no guest code in +// either build. If this test has to change because Dispatch grew a real +// delivery path, that change must also bring the per-plugin rate limit — see +// the SECURITY GATE comment on EventSink.Dispatch. +func TestEventDeliveryHasNoGuestPath(t *testing.T) { + sink := NewEventSink() + noCap := &Instance{ID: 1, Manifest: &Manifest{Name: "nocap"}} + if err := sink.Subscribe("message_send", noCap); !errors.Is(err, ErrCapabilityNotGranted) { + t.Fatalf("Subscribe without the events capability = %v, want ErrCapabilityNotGranted", err) + } + + sub := &Instance{ID: 2, Manifest: &Manifest{Name: "sub", Permissions: []string{string(CapEvents)}}} + if err := sink.Subscribe("message_send", sub); err != nil { + t.Fatalf("Subscribe: %v", err) + } + // inst.module is nil here, so a real guest call would panic or error. + sink.Dispatch(context.Background(), "message_send", []byte(`{}`)) + + sink.UnsubscribeAll(sub) + sink.mu.Lock() + remaining := len(sink.subs) + sink.mu.Unlock() + if remaining != 0 { + t.Fatalf("UnsubscribeAll left %d topics behind", remaining) + } +} + +// TestEmptyAllowlistDeniesEveryHost locks the standing mitigation for finding +// #5 (HTTP exfiltration to allowlisted hosts): the shipped default +// `plugins.http_allowlist` is empty (config.DefaultConfig), and an empty +// allowlist must deny every destination rather than fall open. +func TestEmptyAllowlistDeniesEveryHost(t *testing.T) { + for _, allowlist := range [][]string{nil, {}, {"", " "}} { + reg := newTestRegistry(allowlist) + for _, host := range []string{"example.com", "api.github.com", "attacker.test", "localhost"} { + if reg.hostAllowed(host) { + t.Fatalf("allowlist %v must not permit %q", allowlist, host) + } + } + } +} diff --git a/Server/plugin/errors.go b/Server/plugin/errors.go index fbec2ba3..2be9ae36 100644 --- a/Server/plugin/errors.go +++ b/Server/plugin/errors.go @@ -14,3 +14,9 @@ var ErrPluginNotFound = errors.New("plugin not found") // ErrCapabilityNotGranted is returned when a host API call would require a // capability the plugin's manifest did not declare. var ErrCapabilityNotGranted = errors.New("plugin capability not granted") + +// ErrCommandNotDeclared is returned when a plugin tries to bind a slash +// command its manifest did not list in `commands`. The manifest — not the +// guest module — is the authority on which commands a plugin may own, so an +// admin can see the full command surface before enabling the plugin. +var ErrCommandNotDeclared = errors.New("plugin command not declared in manifest") diff --git a/Server/plugin/examples/hello/README.md b/Server/plugin/examples/hello/README.md index 4769f792..68b8ead0 100644 --- a/Server/plugin/examples/hello/README.md +++ b/Server/plugin/examples/hello/README.md @@ -8,6 +8,11 @@ Phase C Step 9 — proof-of-life plugin used by `Server/plugin/plugin_test.go`. The manifest is the only file the default (no-`-tags wazero`) build needs — the registry persists it into the plugins table without executing the .wasm. +The `commands` block is the per-command ACL: activation binds only the names +listed there, so `list_commands` returning anything else is ignored. Keep +`plugin.json`'s list and `listCommandsJSON` in `main.go` in sync — a name in +the WASM but not the manifest simply never binds. + ## Building the WASM `main.go` in this directory implements the full plugin ABI diff --git a/Server/plugin/examples/hello/plugin.json b/Server/plugin/examples/hello/plugin.json index 7ed04242..1ea9c5fa 100644 --- a/Server/plugin/examples/hello/plugin.json +++ b/Server/plugin/examples/hello/plugin.json @@ -5,6 +5,9 @@ "description": "Trivial proof-of-life plugin: registers /hello and echoes message_send events.", "entrypoint": "hello.wasm", "permissions": ["commands", "events", "storage"], + "commands": [ + { "name": "hello" } + ], "resources": { "max_memory_mb": 16, "cpu_budget_ms": 50 diff --git a/Server/plugin/host_commands.go b/Server/plugin/host_commands.go index fd9b972b..c7efc7cc 100644 --- a/Server/plugin/host_commands.go +++ b/Server/plugin/host_commands.go @@ -31,6 +31,14 @@ func (r *Registry) RegisterCommand(cmd string, inst *Instance) error { if !inst.Manifest.HasCapability(CapCommands) { return ErrCapabilityNotGranted } + // Per-command ACL. The `commands` capability alone used to bind whatever + // the guest module returned from list_commands, so an admin enabling a + // plugin could not know which commands it would claim. The manifest is now + // the authority: only declared names bind, and this is the single choke + // point both auto-registration and direct registration route through. + if !inst.Manifest.DeclaresCommand(cmd) { + return fmt.Errorf("%w: %s/%s", ErrCommandNotDeclared, inst.Manifest.Name, cmd) + } r.mu.Lock() defer r.mu.Unlock() // Ownership is compared by plugin identity (manifest name — unique per diff --git a/Server/plugin/host_events.go b/Server/plugin/host_events.go index 2c3c887e..8857af5a 100644 --- a/Server/plugin/host_events.go +++ b/Server/plugin/host_events.go @@ -87,8 +87,22 @@ func (s *EventSink) UnsubscribeAll(inst *Instance) { } } -// Dispatch invokes every subscriber's on_event for topic. The default build -// is a no-op; the wazero-tagged build calls into the WASM module. +// Dispatch invokes every subscriber's on_event for topic. +// +// SECURITY GATE (audit 2026-04-07 finding #4 — "no rate limit on event +// delivery to plugins"). Guest delivery is NOT implemented in either build: +// the loop below touches no module, and nothing in the server calls Dispatch, +// so a plugin cannot slow the hub by handling events slowly. Wiring the +// guest call is what makes the finding real, so whoever does it must land, in +// the same change: +// +// - a per-plugin delivery rate limit (drop, never block the caller), and +// - the same per-call CPU-budget deadline invokeCommand applies +// (sandbox_wazero.go), and +// - delivery off the hub's broadcast goroutine so a slow guest cannot +// backpressure fan-out to WS clients. +// +// Until then this stays inert on purpose. func (s *EventSink) Dispatch(ctx context.Context, topic string, payload []byte) { s.mu.Lock() subs := append([]*Instance(nil), s.subs[topic]...) diff --git a/Server/plugin/host_storage.go b/Server/plugin/host_storage.go index ae31831f..6666beb9 100644 --- a/Server/plugin/host_storage.go +++ b/Server/plugin/host_storage.go @@ -11,7 +11,14 @@ import ( "fmt" ) +// Key isolation is structural rather than checked: every call below passes +// inst.ID as the namespace and there is no parameter by which a caller (let +// alone a guest module) can name a different plugin's namespace. The +// plugin_kv table's PRIMARY KEY (plugin_id, key) — migrations/015_plugins.sql +// — makes the same split the storage layout, so two plugins using the same +// key never collide. Audit 2026-04-07 finding #2. const ( + maxPluginKeyBytes = 256 // 256 B per key maxPluginValueBytes = 64 * 1024 // 64 KB per value maxPluginScanLimit = 1000 // hard cap on PluginKVScan results ) @@ -21,6 +28,12 @@ func (r *Registry) StoragePut(ctx context.Context, inst *Instance, key string, v if !inst.Manifest.HasCapability(CapStorage) { return ErrCapabilityNotGranted } + if key == "" { + return fmt.Errorf("plugin storage: key must not be empty") + } + if len(key) > maxPluginKeyBytes { + return fmt.Errorf("plugin storage: key exceeds %d bytes", maxPluginKeyBytes) + } if len(value) > maxPluginValueBytes { return fmt.Errorf("plugin storage: value exceeds %d bytes", maxPluginValueBytes) } diff --git a/Server/plugin/manifest.go b/Server/plugin/manifest.go index 8eae6faf..922b21a1 100644 --- a/Server/plugin/manifest.go +++ b/Server/plugin/manifest.go @@ -36,18 +36,39 @@ import ( // with a letter or digit. var pluginNameRegexp = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) +// pluginCommandRegexp restricts declared slash-command names to the same +// lowercase charset the dispatcher normalizes to (RegisterCommand lowercases +// and strips a leading "/"), so a declaration always compares equal to the +// name a client can actually invoke. +var pluginCommandRegexp = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`) + +// maxManifestCommands caps how many commands one plugin may claim. +const maxManifestCommands = 64 + // Manifest is the parsed plugin metadata declared in plugin.json (or // plugin.toml in the wazero-tagged build). The on-disk schema is intentionally // flat so the default JSON parser handles it without a TOML dependency. type Manifest struct { - Name string `json:"name"` - Version string `json:"version"` - Author string `json:"author"` - Description string `json:"description"` - Entrypoint string `json:"entrypoint"` // relative .wasm path - Permissions []string `json:"permissions"` - Resources Resources `json:"resources"` - UI UISpec `json:"ui"` + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Entrypoint string `json:"entrypoint"` // relative .wasm path + Permissions []string `json:"permissions"` + Commands []CommandSpec `json:"commands"` + Resources Resources `json:"resources"` + UI UISpec `json:"ui"` +} + +// CommandSpec is one slash command the plugin declares. The declaration is +// the per-command ACL: activation only binds names listed here, so a guest +// module cannot claim commands its manifest never advertised. +// +// Only Name is enforced today. docs/plans/slash-commands.md extends this +// object with description/options/permission fields later; unknown JSON keys +// are ignored, so manifests written against that richer schema still parse. +type CommandSpec struct { + Name string `json:"name"` } // Resources caps the plugin's runtime budget. Zero means "use the runtime @@ -130,6 +151,9 @@ func (m *Manifest) Validate() error { return fmt.Errorf("plugin manifest: unknown permission %q", p) } } + if err := m.validateCommands(); err != nil { + return err + } if m.Resources.MaxMemoryMB < 0 || m.Resources.CPUBudgetMs < 0 { return fmt.Errorf("plugin manifest: resources must be non-negative") } @@ -180,6 +204,43 @@ func validateRelativePath(p string) error { return nil } +// validateCommands enforces the per-command ACL schema: declaring commands +// requires the `commands` capability, names must be canonical (the form the +// dispatcher normalizes to), and the list is bounded and duplicate-free. +func (m *Manifest) validateCommands() error { + if len(m.Commands) == 0 { + return nil + } + if !m.HasCapability(CapCommands) { + return fmt.Errorf("plugin manifest: commands declared without the %q permission", CapCommands) + } + if len(m.Commands) > maxManifestCommands { + return fmt.Errorf("plugin manifest: too many commands (%d, max %d)", len(m.Commands), maxManifestCommands) + } + seen := make(map[string]bool, len(m.Commands)) + for i, c := range m.Commands { + if !pluginCommandRegexp.MatchString(c.Name) { + return fmt.Errorf("plugin manifest: commands[%d].name %q must match %s", i, c.Name, pluginCommandRegexp.String()) + } + if seen[c.Name] { + return fmt.Errorf("plugin manifest: duplicate command %q", c.Name) + } + seen[c.Name] = true + } + return nil +} + +// DeclaresCommand reports whether the manifest listed cmd in its `commands` +// block. cmd is expected in normalized form (lowercase, no leading "/"). +func (m *Manifest) DeclaresCommand(cmd string) bool { + for _, c := range m.Commands { + if c.Name == cmd { + return true + } + } + return false +} + // HasCapability reports whether the manifest declared cap. func (m *Manifest) HasCapability(cap Capability) bool { for _, p := range m.Permissions { diff --git a/Server/plugin/plugin_test.go b/Server/plugin/plugin_test.go index 4bd6bb47..d93420bb 100644 --- a/Server/plugin/plugin_test.go +++ b/Server/plugin/plugin_test.go @@ -158,7 +158,7 @@ func TestReinstallRebindsCommands(t *testing.T) { t.Cleanup(func() { _ = reg.Close(context.Background()) }) ctx := context.Background() - manifest, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.1.0","entrypoint":"p.wasm","permissions":["commands"]}`)) + manifest, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.1.0","entrypoint":"p.wasm","permissions":["commands"],"commands":[{"name":"greet"}]}`)) if err != nil { t.Fatalf("ParseManifest: %v", err) } @@ -173,7 +173,7 @@ func TestReinstallRebindsCommands(t *testing.T) { } // In-place upgrade: same plugin name, fresh instance. - manifest2, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.2.0","entrypoint":"p.wasm","permissions":["commands"]}`)) + manifest2, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.2.0","entrypoint":"p.wasm","permissions":["commands"],"commands":[{"name":"greet"}]}`)) if err != nil { t.Fatalf("ParseManifest v2: %v", err) } @@ -197,7 +197,7 @@ func TestReinstallRebindsCommands(t *testing.T) { } // A different plugin still cannot hijack an owned command. - other, err := ParseManifest([]byte(`{"name":"other","version":"0.1.0","entrypoint":"o.wasm","permissions":["commands"]}`)) + other, err := ParseManifest([]byte(`{"name":"other","version":"0.1.0","entrypoint":"o.wasm","permissions":["commands"],"commands":[{"name":"greet"}]}`)) if err != nil { t.Fatalf("ParseManifest other: %v", err) } diff --git a/Server/plugin/sandbox_wazero.go b/Server/plugin/sandbox_wazero.go index 508ac585..bf11ee5d 100644 --- a/Server/plugin/sandbox_wazero.go +++ b/Server/plugin/sandbox_wazero.go @@ -131,7 +131,10 @@ func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst * // the cross-plugin command-hijack hole. RegisterCommand acquires r.mu // itself, so it is called outside the lock below to avoid re-entrant // locking. The plugin must also have declared the `commands` capability in - // its manifest, otherwise no binding happens. + // its manifest, otherwise no binding happens — and RegisterCommand refuses + // any name the manifest's `commands` block did not declare, so a guest + // module cannot widen its own command surface by returning extra names + // from list_commands. r.mu.Lock() if inst.module != nil { // Lost a concurrent activation race (e.g. two dispatches both saw a diff --git a/Server/plugin/sandbox_wazero_test.go b/Server/plugin/sandbox_wazero_test.go index af5077b2..2f96e126 100644 --- a/Server/plugin/sandbox_wazero_test.go +++ b/Server/plugin/sandbox_wazero_test.go @@ -81,7 +81,7 @@ func TestWazeroRegistryCreatesRuntime(t *testing.T) { func TestWazeroActivateCompilesModule(t *testing.T) { dir := t.TempDir() - manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}` + manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"hello"}]}` writeTestPlugin(t, dir, "hello", manifest, addWASM) reg, mem := newWazeroTestRegistry(t, dir) @@ -116,7 +116,7 @@ func TestWazeroActivateCompilesModule(t *testing.T) { func TestWazeroDispatchCommandMissingExport(t *testing.T) { dir := t.TempDir() - manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}` + manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"hello"}]}` writeTestPlugin(t, dir, "hello", manifest, addWASM) reg, mem := newWazeroTestRegistry(t, dir) @@ -147,7 +147,7 @@ func TestWazeroDispatchCommandMissingExport(t *testing.T) { func TestWazeroCloseTearsDownRuntime(t *testing.T) { dir := t.TempDir() - manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}` + manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"hello"}]}` writeTestPlugin(t, dir, "hello", manifest, addWASM) reg, mem := newWazeroTestRegistry(t, dir) @@ -174,7 +174,7 @@ func TestWazeroCloseTearsDownRuntime(t *testing.T) { func TestWazeroDisablePluginFreesModule(t *testing.T) { dir := t.TempDir() - manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}` + manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"hello"}]}` writeTestPlugin(t, dir, "hello", manifest, addWASM) reg, mem := newWazeroTestRegistry(t, dir) @@ -270,7 +270,7 @@ var spinWASM = []byte{ // admin disable/enable cycle or server restart. func TestWazeroCPUBudgetOverrunDoesNotBrickPlugin(t *testing.T) { dir := t.TempDir() - manifest := `{"name":"spinner","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}` + manifest := `{"name":"spinner","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"spin"}]}` writeTestPlugin(t, dir, "spinner", manifest, spinWASM) reg, mem := newWazeroTestRegistry(t, dir)