e4eb278f21
Co-authored-by: Kandev Agent <agent@kandev.dev>
294 lines
20 KiB
Markdown
294 lines
20 KiB
Markdown
---
|
|
status: draft
|
|
system: plugins
|
|
requirements:
|
|
- REQ-PLUGINS-PLUGINS-001
|
|
created: 2026-04-26
|
|
owners:
|
|
- cfl
|
|
---
|
|
# Plugin System System Design Part 3
|
|
|
|
## Purpose and boundaries
|
|
|
|
This design preserves the technical source detail for `REQ-PLUGINS-PLUGINS-001` during migration.
|
|
|
|
## Requirement mapping
|
|
|
|
| Requirement | Design section |
|
|
| --- | --- |
|
|
| `REQ-PLUGINS-PLUGINS-001` | [Migrated source detail](#migrated-source-detail) |
|
|
|
|
## Migrated source detail
|
|
|
|
## Frontend plugin runtime (native JS UI plugins)
|
|
|
|
Plugins may extend the SPA with **native** React UI — routes, nav items, slot
|
|
components, and WebSocket handlers that run inside the kandev frontend (the
|
|
Mattermost-webapp model), not iframes. The full contract lives in
|
|
`docs/plans/plugins/PLUGIN-API.md`; summary:
|
|
|
|
- **Manifest:** a plugin declares `ui.bundle` (a root-relative path inside the
|
|
extracted package, e.g. `/ui/bundle.js`) and optional root-relative `ui.styles`.
|
|
- **Bundle delivery:** kandev serves the bundle at `GET /api/plugins/{id}/bundle`
|
|
(and any assets under `GET /api/plugins/{id}/ui/*`) directly from the extracted
|
|
package directory on local disk, forcing `Content-Type: text/javascript` and
|
|
stripping frame-blocking headers. No reverse proxy and no live upstream process are
|
|
involved in serving these assets — the plugin subprocess only needs to be running
|
|
to serve gRPC calls, not the UI bundle.
|
|
- **Boot payload:** the SPA boot payload carries
|
|
`plugins: [{ id, name, bundleUrl, styleUrls, repositoryProviderIds? }]` for every
|
|
**active** plugin that declares a bundle (gated on the `plugins` feature flag).
|
|
`repositoryProviderIds` is the JSON projection of manifest
|
|
`repository_providers`; before `initialize`, the loader supplies it to the registry
|
|
as the plugin's provider/review ownership allowlist. Omission keeps older payloads
|
|
compatible but grants no invented manifest declaration.
|
|
- **Loading:** on boot (and on runtime enable), the frontend host dynamically
|
|
`import()`s each `bundleUrl`. The bundle calls
|
|
`window.registerKandevPlugin(id, { initialize(registry, host), destroy? })`.
|
|
`host` shares the kandev React instance, the app store, a plugin-scoped
|
|
`api.fetch`, a curated `@kandev/ui` subset, and the theme — so a plugin can build
|
|
a page indistinguishable from first-party UI (e.g. a native `/jira` page).
|
|
Activation is transactional: failure or timeout revokes every partial registration,
|
|
aborts owned work, and fences callbacks that arrive after the attempt ended.
|
|
- **Registry surface:** `registerRoute(path, C)`, `registerNavItem(item)`,
|
|
`registerSettingsRoute(path, C)`,
|
|
`registerIntegrationSettings({ id, label, description, icon?, Component })`,
|
|
`registerComponent(slot, C)` (including `app-status-bar-left` and
|
|
`app-status-bar-right`), and `registerWsHandler(action, fn)`. Integration-settings
|
|
contributions appear in the native global integrations index, workspace settings
|
|
navigation, and global/workspace settings routes. An optional integration action
|
|
receives the routed workspace and a `surface` value for the detail header or index
|
|
card. IDs are URL-safe, unique among active plugins, and cannot shadow host
|
|
integrations; unload revokes the contribution.
|
|
Status-slot components receive the exact `AppStatusBarSlotProps` contract in
|
|
`PLUGIN-API.md`: current path/context plus placement and presentation. The host
|
|
renders one responsive presentation at once — 24 px bar on tablet/desktop or
|
|
phone Status drawer — so a plugin must tolerate remounting and adapt its own UI.
|
|
A status slot chooses the contribution's default side; portable user order may
|
|
move either contribution across the desktop spacer and determines drawer order.
|
|
Registrations are namespaced per plugin and bulk-revoked on disable/uninstall.
|
|
An exact plugin-detail settings route replaces the native detail content but
|
|
retains the host-owned personal shortcut card; nested settings routes remain
|
|
fully plugin-owned.
|
|
- **Isolation (v1):** only active, operator-installed plugins load; a failing
|
|
bundle/`initialize` is caught and never breaks boot; slot components render behind
|
|
error boundaries. Plugin JS otherwise runs with full in-origin store access —
|
|
hard sandboxing (workers/realms) is future work (see Out of scope).
|
|
- **Keybindings:** a plugin declares `ui.keybindings: [{ id, default, description }]`
|
|
in its manifest (`default` is a combo string like `mod+shift+k`, validated at
|
|
registration time). `registry.registerKeybinding(id, handler)` binds a JS handler
|
|
to a declared id; the host resolves the effective combo from the user's override
|
|
or the manifest `default` and dispatches it globally, skipping editable targets
|
|
the same way core app shortcuts do. User overrides are namespaced
|
|
`plugin:{pluginId}:{id}` so they survive independently per plugin. The
|
|
configuration location, user/operator boundary, and cross-surface conflict
|
|
behavior are governed by [Plugin Shortcut Settings](plugin-shortcut-settings.md).
|
|
- **Modals and drawers:** `host.openModal({ title?, description?, content, size?, dismissible?, presentation? })` imperatively
|
|
opens a modal rendered by the host's `<PluginModalHost/>` (mounted once inside the
|
|
authenticated `AppShell` theme/tooltip/toast provider tree and isolated behind its
|
|
own error boundary) and returns `{ close() }` to close
|
|
that instance. `content` reuses the slot-component contract (rendered with the
|
|
host React instance). `presentation: "drawer"` renders the contribution in a
|
|
safe-area-aware host drawer for phone actions; omitted/`dialog` keeps desktop modal
|
|
presentation. Independent of keybindings — any plugin code path may call it.
|
|
- **Task change-request links:** `host.openTaskLinkDialog(...)` renders the same
|
|
one-field dialog anatomy, inline validation, Cancel/Save footer, submitting state,
|
|
success toast, and close behavior as Kandev's GitHub task-link flow. Code-host
|
|
plugins supply provider copy and an authenticated
|
|
`onSubmit(reference, signal)` callback; closing, unmounting, or revoking the plugin
|
|
aborts that signal and suppresses late feedback;
|
|
they do not build this workflow inside `openModal`. Link submenu children name only
|
|
the target because the parent already supplies the verb.
|
|
- **Provider registrations:** `registry.registerRepositoryProvider(...)` contributes
|
|
repository listing, optional coarse URL matching, structured URL inspection, and
|
|
branch listing. `matchesURL` is only a synchronous performance hint; workspace-scoped
|
|
`inspectURL` is authoritative, returns `null` when the configured provider does not
|
|
own the URL, and must produce exactly one winner across active providers. `inspectURL` returns
|
|
a complete credential-free provider/repository/pull-request descriptor with an HTTPS
|
|
clone URL. The host
|
|
persists that descriptor rather than parsing plugin URLs. Once persisted, native task
|
|
branch requests are routed to the active manifest owner through its declared
|
|
workspace-scoped `repositories.branches` action using only that stored descriptor.
|
|
`registerTaskAction(...)`
|
|
contributes children of the native **Link** submenu with visibility and a read-only
|
|
task/workspace/repository context. Unsupported top-level action placements are
|
|
rejected at compile/registration time rather than accepted without a consumer.
|
|
`registerReviewProvider(...)` supplies
|
|
normalized review summaries, an external-store task-item source, and a plugin
|
|
`ReviewPanel` rendered in native desktop/mobile review surfaces. All are
|
|
manifest-owned, lifecycle-revocable registrations; duplicate active provider
|
|
ownership is rejected.
|
|
- **Task panels:** `registerTaskPanel({ id, title, icon?, Component, mobileEnabled? })`
|
|
adds a row to the task workspace's "+" (add panel) menu; selecting it opens a
|
|
dockview panel rendering `Component` with `{ panelId, taskId, sessionId,
|
|
presentation }`. Every plugin panel shares one generic `"plugin-panel"` dockview
|
|
component (identity in `params.pluginId`/`params.panelKey`), so a saved layout
|
|
round-trips even when the owning plugin is later uninstalled — the layout manager
|
|
drops an unresolvable reference instead of throwing, and `Settings > Layouts`
|
|
renders a placeholder box for it. On phones, one bounded **Panels**
|
|
bottom-navigation entry opens a touch-native picker containing every
|
|
`mobileEnabled: true` registration; choosing one renders the same `Component`
|
|
full-height with `presentation: "mobile"`. Plugin loading and reloading are
|
|
authoritative lifecycle states, not elapsed-time guesses: a temporarily missing
|
|
registration never deletes an open or saved panel while its plugin is loading.
|
|
A successful initialization that omits a previously registered panel, or a
|
|
definitive disable/uninstall, closes that panel. If the removed panel is focused
|
|
on a phone, the session deterministically returns to Chat. A `Component` that
|
|
throws renders a per-panel error-boundary fallback without affecting the rest of
|
|
the layout. Decision:
|
|
ADR-2026-08-04-plugin-contribution-lifecycle-authority.
|
|
- **Task-menu and row contributions:** `registerTaskMenuAction({ id, label, icon?,
|
|
group: "edit" | "primary", visible?, run })` adds `"edit"` actions to the kanban card's `Edit`
|
|
submenu (the flat `Edit` item becomes `Edit > Edit task` once any plugin
|
|
registers one); `run(context)` receives `{ workspaceId, taskId, taskTitle,
|
|
workflowStepId, presentation }`, and a rejected `run` is caught and logged
|
|
without blocking the menu from closing. `"primary"` actions render on cards
|
|
and desktop/mobile task-row menus. `registerComponent("task-card-indicators",
|
|
C)` renders `C` beside the PR status icon on every kanban card, receiving
|
|
`{ taskId, workspaceId, workflowStepId }` as `slotProps`.
|
|
`registerComponent("task-card-tags", C)` renders `C` in its own row on every
|
|
kanban card (below the badges row), receiving the same
|
|
`{ taskId, workspaceId, workflowStepId }` shape — for a contribution too wide
|
|
for the cramped title-row `task-card-indicators` spot, e.g. a row of tag chips.
|
|
`registerComponent("task-row-metadata", C)` renders plugin-agnostic,
|
|
read-only metadata on sidebar and `/tasks` rows. It receives
|
|
`{ taskId, workspaceId, workflowStepId, surface }`. An empty slot adds no
|
|
wrapper or spacing. Decision: ADR-2026-08-18-plugin-task-row-metadata.
|
|
- **Sidebar workspace actions:** `registerComponent("sidebar-workspace-actions", C)`
|
|
renders `C` after Quick Terminal/Quick Chat in the desktop sidebar's New Task
|
|
row and in the shared phone navigation sheet, forwarding
|
|
`{ workspaceId, workspaceLabel?, presentation }`. The host uses
|
|
`presentation: "desktop"` or `"mobile"`; mobile actions must expose a
|
|
touch-sized, accessible control.
|
|
- **`host.storage`:** authenticated, per-user key/value storage
|
|
(`get`/`set`/`delete`/`list`/`subscribe`), backed by the `plugin_user_state`
|
|
table (separate from the plugin-backend-only `plugin_state` table — no gRPC/proto
|
|
change) and requiring `capabilities.user_state: true`. A successful
|
|
`set`/`delete` publishes `plugin.user-state.updated` to the writing user's own
|
|
WS connections only (payload carries keys, never the value); `host.storage.subscribe`
|
|
filters to the plugin's own events and suppresses the writing tab's own echo via a
|
|
per-tab `writerId`. See `docs/decisions/2026-08-01-per-user-plugin-storage.md` and
|
|
the full contract in `PLUGIN-API.md`.
|
|
- **`host.ui.RichTextEditor` / `host.ui.RichTextReadOnly`:** narrow wrappers over the
|
|
Plan panel's tiptap markdown editor, pixel-identical to the Plan panel, so a
|
|
plugin needing rich text (e.g. a notes scratchpad) ships no tiptap of its own.
|
|
|
|
## State machine
|
|
|
|
```
|
|
registered -> active -> disabled -> uninstalled
|
|
registered|active|disabled --failure--> error
|
|
error --successful Enable/health recovery--> active
|
|
error --Disable--> disabled
|
|
```
|
|
|
|
| State | Meaning |
|
|
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `registered` | Package extracted and record written; go-plugin spawn/handshake pending or in flight |
|
|
| `active` | Handshake succeeded and health (`Ping`) passes; events delivered, webhooks proxied |
|
|
| `error` | 3 consecutive `Ping` failures (30s interval, injectable), or the subprocess crashed and restart attempts (backoff, max 5) are exhausted. Events buffered (ring buffer, 100 events, 5-minute TTL). Webhooks return 503 |
|
|
| `disabled` | Operator explicitly disabled. Subprocess stopped. No events, no webhooks. State and config preserved |
|
|
| `uninstalled` | Subprocess stopped, package/record/state deleted (no grace period in v1) |
|
|
|
|
Health monitoring: kandev's go-plugin client calls `Ping()` on the plugin every 30
|
|
seconds (injectable). 3 consecutive failures -> `error` + inbox item + restart attempt
|
|
with backoff. A subprocess crash (unexpected process exit) triggers an immediate
|
|
restart with backoff (max 5 attempts, then `error`). Next successful handshake/`Ping`
|
|
-> `active`, queued events delivered in order, and the persisted failure diagnostic
|
|
is cleared. An operator can manually enable a plugin in `error` to retry its spawn
|
|
and handshake; boot does not automatically retry a persisted `error` state. A
|
|
manual Enable racing the final restart-exhaustion callback must complete without
|
|
deadlock: the manager never waits for a stopping process while holding its
|
|
process registry lock, so the final callback and replacement start can both
|
|
complete.
|
|
|
|
## Permissions
|
|
|
|
- Plugins are global to the kandev instance, installed by the operator. There is no per-user plugin access in v1.
|
|
- Capability-based access control: undeclared capabilities on a Host RPC return gRPC
|
|
status `PermissionDenied` with message `capability '<name>' not declared`.
|
|
- Each plugin's Host service instance is bound to its own plugin ID at spawn time —
|
|
there is no plugin-supplied ID to check, so capability checks evaluate directly
|
|
against that plugin's installed manifest on every RPC.
|
|
|
|
## Security
|
|
|
|
- **Auth is the spawn relationship.** Kandev spawns the plugin subprocess itself, so
|
|
there is no separate credential to issue, store, or leak: the go-plugin handshake
|
|
(magic cookie) plus AutoMTLS (mutual TLS negotiated per-launch, transparent to
|
|
plugin authors) authenticate the channel. There is no `api_key`, no
|
|
`webhook_secret`, and no HMAC signing anywhere in the contract.
|
|
- **Package integrity.** `checksums.txt` is verified for every file at install time.
|
|
An optional ed25519 signature (`checksums.txt.sig`) is verified when present;
|
|
unsigned packages install with a surfaced warning rather than being blocked (signing
|
|
is not required in v1 — see "Out of scope").
|
|
- **Capability-based access control** evaluated per Host RPC via a server interceptor.
|
|
- **Network**: the plugin subprocess talks to kandev over a unix domain socket
|
|
(macOS/Linux) or loopback TCP with AutoMTLS (Windows) — never a routable network
|
|
address. There is no remote/operator-hosted plugin tier in v1; every plugin backend
|
|
is a binary kandev spawns and supervises on the same host. See "Out of scope".
|
|
- **`host.storage`'s user-state routes are the first plugin HTTP surface reachable
|
|
with the caller's own browser session** (every other plugin HTTP route is either
|
|
operator-only management or a self-authenticating external webhook). It is
|
|
guarded the same way any other authenticated API route is — `httpmw`'s allowlist
|
|
explicitly does not cover it, so an unauthenticated request is rejected before the
|
|
handler runs — plus a capability gate (`user_state`), scope/key validation, a body
|
|
cap, and per-user row isolation. The stored value is opaque to the host: it is
|
|
never interpreted, never included in the `plugin.user-state.updated` WS payload,
|
|
and never delivered to the plugin's gRPC backend (a browser-only surface).
|
|
|
|
## Failure modes
|
|
|
|
- **3 consecutive `Ping` failures (90s), or crash with restart attempts exhausted
|
|
(max 5, backoff)**: status -> `error`. Events buffered (100, 5min TTL). Webhooks
|
|
return 503. Inbox item created and the bounded failure diagnostic is persisted
|
|
with its timestamp.
|
|
- **Failed spawn/handshake, missing install path, or failed restart**: status ->
|
|
`error` with the bounded diagnostic persisted; the Plugins UI exposes **Enable**
|
|
as the manual retry action.
|
|
- **Diagnostic safety**: persisted failure text is normalized, credential/path
|
|
redacted, and bounded before it is returned by plugin list/detail APIs. Plugin
|
|
stdout is not a durable diagnostic channel.
|
|
- **Buffer overflows (>100 events or >5min)**: oldest events dropped. Kandev emits
|
|
at most one overflow warning per plugin per minute, aggregating the number of
|
|
dropped events since the previous warning.
|
|
- **Plugin returns a gRPC error (or times out) on `DeliverEvent`**: retry up to 3 times
|
|
with exponential backoff (5s, 15s, 45s). After exhaustion, event is logged as failed
|
|
and dropped.
|
|
- **External webhook hits a disabled/error plugin**: kandev returns 503.
|
|
- **Undeclared capability access attempt on a Host RPC**: gRPC `PermissionDenied` with
|
|
a message naming the missing capability.
|
|
- **Undeclared, unauthorized, oversized, or timed-out browser action**: Kandev rejects
|
|
it before/at bounded plugin dispatch; it never falls back to public webhook routing.
|
|
- **Disabled/unloaded repository or review provider**: registrations and in-flight work
|
|
are revoked; provider credential leases are invalidated immediately, and host
|
|
selectors/panels remove stale plugin items safely, including after same-ID reload.
|
|
- **Reference source denied during message submission**: Kandev rejects the selected
|
|
reference rather than queuing stale or cross-workspace metadata.
|
|
- **Checksum mismatch or unresolvable host-platform executable at install time**:
|
|
install is rejected before any code runs.
|
|
- **Frontend bundle import or initialization remains in flight**: open and saved
|
|
plugin-panel identities are preserved regardless of duration. A failed or timed-out
|
|
initialization leaves the panel recoverable for a later successful load; it is not
|
|
interpreted as disable or uninstall.
|
|
- **Per-user state purge fails during uninstall**: uninstall fails closed before the
|
|
package or plugin record is removed. The stopped-but-installed plugin remains
|
|
retryable and a successful retry purges every user's rows before removal completes.
|
|
|
|
## Persistence guarantees
|
|
|
|
- Plugin installation records (`id`, `version`, `install_path`, capabilities, status,
|
|
`signed`, `last_error`, `last_error_at`) persist to disk as
|
|
`~/.kandev/plugins/<id>.yml` and survive backend restarts.
|
|
- Extracted plugin packages persist at `~/.kandev/plugins/<id>/<version>/` until
|
|
uninstall.
|
|
- Plugin state in SQLite survives restarts.
|
|
- `plugin_user_state` survives restarts and ordinary disable/enable cycles, but no row
|
|
survives a successful uninstall. If its purge fails, uninstall does not report
|
|
success and leaves the plugin installed so the cleanup can be retried.
|
|
- Event delivery buffer is in-memory; events in the buffer do not survive a backend
|
|
restart.
|
|
- There are no plugin credentials to persist or lose — auth is re-derived from the
|
|
spawn relationship on every process launch.
|