P3 item 4. Each of the five plugin CRITICALs in audit-2026-04-07.md was re-verified against the current Server/plugin/ code rather than the tracker: - #1 invokeCommand timeout — CLOSED. Per-call CPU budget (manifest → config → 100ms floor) + WithCloseOnContextDone + lazy re-instantiation. Landed in PR #1182 (7b178ff,b13adf2); pinned by the W1-1 test. - #2 storage key isolation — CLOSED. The premise did not hold: the namespace is the caller's Instance.ID and plugin_kv PRIMARY KEY (plugin_id, key). - #3 per-command ACL — CLOSED by the manifest `commands` ACL in3d2dd19. - #4 event rate limit — CLOSED as not reachable: EventSink.Dispatch invokes no guest code and has zero callers; the requirement is recorded as a SECURITY GATE at the point delivery would be wired. - #5 HTTP exfiltration — OPEN, accepted residual risk. An allowlisted host is by definition a permitted destination; closing it needs egress content policy and per-plugin allowlists, i.e. a runtime redesign, out of scope for P3. Because #5 stays open the standing rule fires as written: plugins ship default-disabled at the beta gate. Re-verified in config.DefaultConfig() — Plugins.Enabled false, HTTPAllowlist empty. Also records the structural mitigation covering #2/#4/#5: no host imports are wired into the wazero runtime, so command_dispatch and list_commands are the only guest-reachable entry points today. Mirrors the outcome in the §1 carried-over row of audit-2026-07-19.md, records decision D11 in plans/audit-2026-07-19-decisions.md, and notes in plans/slash-commands.md which slice of its manifest design already landed.
14 KiB
Plan: Slash command dispatcher in WS
Status: design only, not implemented Owner: TBD Tracks: deferred feature backlog (post-beta; see CHANGELOG "Deferred work") Estimated effort: 1–2 weeks of focused work
Why
Discord's command system is the primary surface every bot/app uses. OwnCord already ships:
- A plugin runtime (
Server/plugin/) with acommandscapability, plugin.Registry.RegisterCommandandplugin.Registry.DispatchCommand(Server/plugin/host_commands.go),- A V2 WS dispatcher (
Server/ws/handlers_chat.go,Server/ws/command.go) that already routes typedCommandstructs to pure handlers, - A
chat_sendpayload that flows throughMessageService.SendMessage.
…but chat_send content beginning with / is currently treated as a
literal message. There is no dispatcher that recognises /help, no
autocomplete, no per-command argument schema, and the
Registry.DispatchCommand plumbing is dead code.
This plan wires the existing pieces together and adds the surface that clients need to render slash commands the way Discord does.
Non-goals
- Not an OAuth2/app directory (Tier 2 work — separate plan).
- Not a hosted "verified bot" registry — local plugins only.
- Not modal interactions or buttons/select menus (component v2). These are a follow-up; the v1 surface is text reply + ephemeral reply.
- Not a re-implementation of
MessageService— slash commands take a separate path so they don't pollute the message table.
Surface design
Wire format
Two new client→server messages:
// 1. Command invocation (from /chat input or autocomplete pick)
{
"type": "command_invoke",
"req_id": "c-42",
"payload": {
"channel_id": 17,
"name": "kick",
"args": [
{"name": "user", "value": "12345"},
{"name": "reason", "value": "spam"}
]
}
}
// 2. Autocomplete query (sent on every keystroke after `/`)
{
"type": "command_autocomplete",
"req_id": "c-43",
"payload": {
"channel_id": 17,
"name": "kick",
"focused": "user",
"partial": "ali",
"args": [{"name": "user", "value": "ali"}]
}
}
Two new server→client messages:
// Successful invocation reply (ephemeral by default).
{
"type": "command_reply",
"req_id": "c-42",
"payload": {
"ephemeral": true,
"content": "Kicked alice for: spam.",
"embeds": [],
"broadcast": null
}
}
// Autocomplete suggestions.
{
"type": "command_autocomplete_result",
"req_id": "c-43",
"payload": {
"choices": [
{"label": "alice (id 12345)", "value": "12345"},
{"label": "alistair (id 67)", "value": "67"}
]
}
}
The ephemeral reply is delivered only to the invoking client (it never hits
MessageSentChannelEvent). The optional broadcast field, when non-null,
becomes a real chat_message posted by a synthetic Bot:<plugin-name>
identity so the channel timeline records it.
Manifest changes
Plugin manifests gain a commands block. The manifest is the source of
truth for the per-command schema; the runtime never trusts what the plugin
says at dispatch time. Example:
Partially landed 2026-07-20 (audit-2026-04-07 CRITICAL #3): the name-only slice of this block exists today —
plugin.jsonaccepts"commands": [{"name": "kick"}]andRegistry.RegisterCommandrefuses any command the manifest did not declare, solist_commandscan no longer bind names behind the admin's back.description/options/default_member_permissionsbelow are still design-only; unknown keys parse and are ignored, so manifests written against the full schema already load.
{
"name": "moderation-tools",
"version": "0.2.0",
"entrypoint": "mod.wasm",
"permissions": ["commands", "events"],
"commands": [
{
"name": "kick",
"description": "Remove a user from the server.",
"default_member_permissions": ["kick_members"],
"options": [
{
"name": "user",
"type": "user",
"description": "Who to kick.",
"required": true,
"autocomplete": true
},
{
"name": "reason",
"type": "string",
"description": "Optional reason recorded in audit log.",
"max_length": 256
}
]
}
]
}
Manifest.Validate (Server/plugin/manifest.go) gets a new
validateCommands() step:
name: same regex as plugin names (^[a-z][a-z0-9_-]{0,31}$), forced lowercase, no spaces, no slashes, max 32 chars.description: 1–100 chars, no NUL/control bytes.options: max 25, eachnameunique, recurses on nested groups.option.type: closed enum (string,int,bool,user,channel,role,mention,attachment,subcommand,subcommand_group).default_member_permissions: closed enum matched againstpermissions.Permissionconstants (so a typo fails at install, not at dispatch).
Same defense profile as the existing manifest validation: the closed-set
checks live in manifest.go next to validCapabilities.
Schema additions
A new table to support server-installed plugin commands without
duplicating the manifest — needed so the WS hub can answer
command_autocomplete requests without doing a JSON walk on every keystroke:
-- migrations/016_plugin_commands.sql
CREATE TABLE plugin_commands (
plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
name TEXT NOT NULL,
schema_json TEXT NOT NULL, -- the validated `commands[i]` blob
permissions INTEGER NOT NULL DEFAULT 0, -- packed permission bits
PRIMARY KEY (plugin_id, name)
);
CREATE UNIQUE INDEX plugin_commands_name_uq ON plugin_commands(name);
The unique index on name enforces that two enabled plugins can't both
own /ban — install of the second one fails. This is intentional: command
namespace collisions are confusing for users.
Code surface
| File | Change |
|---|---|
Server/ws/message_types.go |
Add MsgTypeCommandInvoke, MsgTypeCommandAutocomplete, MsgTypeCommandReply, MsgTypeCommandAutocompleteResult. |
Server/ws/command.go |
Add CommandInvokeCmd, CommandAutocompleteCmd structs and constructors. Validate name regex + arg count cap (25) at parse time so the dispatcher trusts its input. |
Server/ws/handlers_command.go |
New file. handleCommandInvokeV2, handleCommandAutocompleteV2. Pure handlers — return a Result like the existing chat handlers. |
Server/ws/handlers.go |
Register the new handlers via r.RegisterV2(MsgTypeCommandInvoke, handleCommandInvokeV2, deps). |
Server/ws/deps.go |
Add a CommandDeps carrying *plugin.Registry, service.PermissionService, and service.MessageService. |
Server/plugin/host_commands.go |
Extend DispatchCommand to take a typed arg map (map[string]any) instead of []string. Add Autocomplete(ctx, name, focused, partial). |
Server/plugin/manifest.go |
Add Commands []CommandSpec to Manifest, validateCommands(), and a Manifest.Command(name) lookup. |
Server/store/sqlite_plugin_commands.go |
New file. CRUD over the plugin_commands table. |
Server/migrations/016_plugin_commands.sql |
New migration. |
Client/tauri-client/src/state/commands.ts |
New module. Caches per-server command list (fetched at auth_ok time via a new commands_list REST endpoint), feeds the autocomplete UI. |
Client/tauri-client/src/components/Composer/SlashCommandPopup.tsx |
New component — autocomplete dropdown that opens when the message buffer starts with /. |
docs/protocol.md |
Document the four new wire messages. |
Permission model
default_member_permissions is enforced server-side in
handleCommandInvokeV2 before the plugin is invoked, by calling
PermissionService.HasChannelPerm for each declared permission. Plugins
do not get to decide who can use their commands; the manifest declares,
the host enforces.
Slash commands inherit the existing channel ACL: command_invoke for a
channel the user can't view_channel in returns ErrCodeForbidden with
no plugin invocation, no telemetry leak.
Built-in commands
Two slash commands ship in-tree (no plugin required), to validate the dispatcher and to give bare-metal deployments something useful:
| Command | Implementation | Why in-tree |
|---|---|---|
/me <text> |
Built-in handler in handlers_command.go |
Discord parity, IRC tradition. |
/shrug |
Built-in handler | Same. Trivial. |
A future PR can add /poll, /remind, /nick etc. — all should follow
the same handler shape so a plugin author can read the source as the
canonical example.
Concurrency & lifecycle
- Plugin command registration happens at
Registry.activateAlltime (already exists for the wazero build) and atinstallFromDiskfor the default build. plugin_commandsrows are written inside the same transaction aspluginsso a half-installed plugin can never have orphan rows.DispatchCommandruns the plugin handler off the WS goroutine with a 3 s context deadline (configurable viaconfig.yaml > plugins.command_deadline_ms). A slow command must not blockchat_sendfor the same client.- The 3 s budget is enforced by passing
context.WithTimeoutintoRegistry.DispatchCommand; the wazero runtime already accepts actxon every host call.
Failure modes & UX
| Failure | Server response | Client UX |
|---|---|---|
| No such command | command_reply ephemeral: Unknown command: /foo |
Red banner under composer. |
| Plugin runtime not built (default build) | Existing fallback in DispatchCommand returns the helpful error message |
Same banner, no crash. |
| Plugin handler timeout (>3s) | command_reply ephemeral: /foo timed out + audit log entry |
Banner + telemetry tag. |
| Plugin handler panics | Recovered in the runtime, ephemeral error, plugin auto-disabled after 3 panics in 60s | Banner + plugin marked unhealthy in admin panel. |
| Permission denied | command_invoke returns ErrCodeForbidden before invocation |
Banner: "You lack permission". |
| Argument validation fails | command_invoke returns ErrCodeBadPayload with the field name |
Composer highlights the bad option. |
Testing strategy
Unit:
manifest_test.go— extend with command validation (name regex, option type enum, max 25 options, max 100 char description).host_commands_test.go—DispatchCommandwith a stubInstance, arg-map round trip, auto-disable after panics.handlers_command_test.go— pure handler test using the existing V2 test pattern (stubMessageSvc,stubPermSvc).
Integration:
- Add a new in-tree test plugin under
Server/plugin/examples/echo(no wasm needed — installable via the default build) that registers/echoand is loaded insidews_integration_test.go.
Contract:
docs/protocol.mdround trip — JSON examples kept in sync with the parser via golden tests.
Telemetry
Three new counters under commands_*:
commands_invoked_total{name,plugin,result}commands_autocomplete_total{name,plugin}commands_duration_ms_bucket{name,plugin}(histogram)
Existing OTel skeleton (Server/telemetry/) gets a new
tracer.Start(ctx, "command.invoke") span around DispatchCommand.
Rollout
- Land schema migration + manifest validation behind the existing
default build. New plugins can declare commands but the WS dispatcher
still treats
/as plain text. - Land WS dispatcher +
/me//shrugbuilt-ins. Slash commands work for in-tree handlers, plugin commands still no-op. - Land the autocomplete RPC + client UI. Composer learns to open the
popup on
/. - Land
Registry.DispatchCommandwiring so plugin commands route through. Gate behind-tags wazerofor the actual invocation; the default build returns the existing helpful "runtime not built" message.
Each step is independently shippable.
Open questions
- Bot identity for broadcast replies. When a slash command produces a
broadcast, who is the author? Options: (a) a virtualBot:<name>user with a synthetic ID in a reserved namespace, (b) the invoking user (Discord style: "Used /poll"). I'm leaning (b) for simplicity — it avoids new identity rows — but it loses the visual distinction. TBD in review. - Component v2 (buttons / selects). Out of scope for v1 but the
wire-format reservations should leave room for a
componentsarray oncommand_reply. Worth adding the field as[]anynow even though nothing renders it, so v2 isn't a breaking change. - Cross-plugin command imports. Discord allows one app to use another's commands. We don't, and shouldn't until there's a real reason — the namespace flatness is a feature for a self-hosted product.
- DM-context commands. Some commands make sense in DMs (
/poll), some don't (/kick). Add a manifest fieldcontexts: ["channel", "dm"]defaulting to["channel"].
Files-to-touch checklist (for the implementing agent)
Server/migrations/016_plugin_commands.sqlServer/plugin/manifest.go—CommandSpec,validateCommandsServer/plugin/manifest_test.go— command validation tableServer/plugin/host_commands.go— typed args, autocompleteServer/store/sqlite_plugin_commands.goServer/ws/message_types.go— four new constantsServer/ws/command.go—CommandInvokeCmd,CommandAutocompleteCmdServer/ws/handlers_command.go— V2 handlers +/me+/shrugServer/ws/handlers.go— register new handlersServer/ws/deps.go—CommandDepsServer/ws/handlers_command_test.goServer/api/router.go—GET /api/v1/commands(cached schema dump)Client/tauri-client/src/state/commands.tsClient/tauri-client/src/components/Composer/SlashCommandPopup.tsxdocs/protocol.md— four new wire messagesCHANGELOG.md— Phase D entry