Files
kandev/docs/specs/plugins/system-design/plugins-01.md
T
2026-08-26 11:26:09 +00:00

298 lines
15 KiB
Markdown

---
status: draft
system: plugins
requirements:
- REQ-PLUGINS-PLUGINS-001
created: 2026-04-26
owners:
- cfl
---
# Plugin System System Design Part 1
## 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
The plugin system is a **platform-level capability of kandev**, not tied to any single
feature area. It lets third parties (and kandev itself) extend the product — backend
behavior and native frontend UI — through a stable contract, without forking or
modifying core.
The gRPC/go-plugin transport, package format, and RPC surface described below are
frozen in `docs/plans/plugins/GRPC-CONTRACT.md`; that file is the authoritative
wire-level reference. The native frontend contract is frozen in
`docs/plans/plugins/PLUGIN-API.md`. This spec describes the resulting product
behavior.
## Why
Kandev keeps growing external integrations and surface-specific behavior directly in
the core codebase: source-control sync, issue-tracker browsing, notification providers,
and planned channel types (Slack, Discord, Telegram, email). Each one adds
platform-specific logic — API clients, webhook handlers, payload formatting, OAuth
flows, secret management, and bespoke UI — to the Go backend and the SPA. This creates
three problems:
1. **Core bloat.** Every new integration increases the surface area of core. Adding more
at similar scale makes the codebase unmaintainable.
2. **Release coupling.** Fixing a bug in one integration requires a full kandev release,
and users who don't use it still receive the code. Integration authors cannot ship
independently of the core release cycle.
3. **Extensibility ceiling.** Users and third parties cannot add their own integrations
or UI without forking kandev.
A plugin system decouples extensions from core. Plugin **backends** are Go binaries
that kandev spawns and supervises as subprocesses, communicating over a strict typed
gRPC protocol; plugins may additionally ship a **native frontend bundle** that kandev
loads into the SPA. Both extend kandev through well-defined, capability-gated
surfaces. The core stays small; the ecosystem grows independently.
## What
- Plugin **backends** are **Go binaries** distributed inside a release tarball
(per-platform executables) that kandev **spawns and supervises as subprocesses**
via `hashicorp/go-plugin`, speaking a strict typed **gRPC protocol**
(`kandev.plugin.v1`) over a unix domain socket (macOS/Linux) or loopback TCP with
AutoMTLS (Windows). No in-process backend loading, no separately-managed operator
process, no HTTP transport for the backend contract.
- A plugin MAY additionally ship a **native frontend bundle** (`ui.bundle`) that kandev
loads into the SPA to register native routes/nav/components (see "Frontend plugin
runtime"). This is the one in-process surface; the backend stays out-of-process
(but is now kandev-managed, not operator-managed).
- A plugin manifest declares identity, runtime executables (per OS/arch), capabilities,
declared webhooks and authenticated actions, repository-provider and reference-source
ownership, config schema, and optional UI bundle.
- Plugins SHALL receive events, expose proxied external webhook
endpoints, and read/write a plugin-scoped KV state — all over gRPC.
- Plugins are distributed as a signed-or-unsigned release **tarball** and installed
either by **URL** (kandev downloads it) or by **manual upload** (multipart file).
There is no manifest-paste registration step.
- The Settings > Plugins install dialog SHALL show the primary Install action as
busy while an install is in flight, including an animated loading indicator and
an installing label, while keeping the action disabled until the pipeline settles.
- Capability-based access control: a plugin can only call Host RPCs it declared in its
manifest; undeclared capabilities are rejected with a gRPC `PermissionDenied` status.
- **Kandev owns the plugin process lifecycle**: it extracts the package, spawns the
binary, performs the go-plugin handshake, health-checks it (`Ping`), and restarts it
on crash or health-check failure. Operators no longer run or manage plugin processes
themselves. The remote/self-hosted tier (`base_url` registration of an
operator-run process kandev never spawns) is removed; see "Out of scope".
- Plugin repository providers, task actions, and review panels register through
revocable generic frontend contracts. A provider plugin can participate in native
task creation, Link menus, desktop/mobile review surfaces, and composer `#` search
without a host-specific provider branch.
- The independently consumable, runtime-free `@kandev/plugin-sdk` package is the
frontend author contract. Official plugins use its typed `host.context` reads and
named UI/provider contracts instead of importing or copying private Kandev store,
React, Zustand, or `@/` application types.
- First-party-parity code-host dashboards use host-owned provider-neutral list,
toolbar, scope, task-menu, and linked-task primitives exposed through `host.ui`.
Plugins provide normalized data and callbacks; task presets open the native
`TaskCreateDialog`, while review remains owned by the task review-provider surface.
- Native UI plugins register bounded, plugin-owned translation catalogs and consume them through a
host-scoped imperative/reactive localization API. Locale changes follow Kandev without bundling a
second i18n runtime, and disable/reload/uninstall removes only that plugin's resources.
## Data model
### Plugin package format
A plugin ships as a release tarball, `<id>-<version>.tar.gz`:
```
manifest.yaml # authoritative; read BEFORE any code runs
server/plugin-<goos>-<goarch>[.exe] # any subset of platforms; host key required at install
ui/bundle.js # optional (frontend half)
ui/*.css / assets/icon.svg # optional
checksums.txt # "sha256 path" for every other file
checksums.txt.sig # OPTIONAL ed25519 signature (unsigned → warn, not blocked)
```
`manifest.yaml` declares identity, capabilities, webhooks, config schema, and
an optional UI bundle, plus a `runtime` block naming the per-platform executables
(replaces the old `base_url`/`endpoints` block, which is removed entirely):
```yaml
id: "kandev-plugin-slack" # Unique, pattern: ^[a-z0-9][a-z0-9._-]*$
api_version: 1
version: "1.0.0"
display_name: "Slack Notifications"
description: "Post to Slack on task events, relay messages to agents"
author: "kandev"
categories: ["connector"] # connector | automation | tools | analytics
runtime:
type: binary
executables:
linux-amd64: server/plugin-linux-amd64
darwin-arm64: server/plugin-darwin-arm64
# ... any subset; kandev requires the running host's platform key at install time
min_kandev_version: "0.91.1" # required for admin actions
capabilities:
events: ["task.created", "task.state_changed", "agent.completed"]
api_read: ["tasks", "sessions"] # Host data API reads (see below); live now
api_write: ["tasks", "messages"] # Host data API writes (CreateTask/UpdateTask/SendMessage); live now
state: true
secrets: true
auth: true # establish a login session for an
# external (OIDC/SAML) identity — see ADR 0050
user_state: true # authenticated, per-user host.storage — see
# "Frontend plugin runtime" and ADR
# 2026-08-01-per-user-plugin-storage
actions:
- key: "connection.save"
scope: workspace
access: admin # defaults to authenticated
max_body_bytes: 65536
repository_providers: ["example-repository-provider"]
reference_sources:
- source: "example_change_requests"
provider: "example-repository-provider"
kind: "change_request"
display_name: "Example change requests"
kind_label: "Change request"
order: 100
webhooks:
- key: "slack-events"
description: "Slack Events API webhook"
method: "POST"
access: public
config_schema:
type: object
properties:
bot_token_secret: { type: string, description: "Secret reference for Slack bot token" }
default_channel: { type: string, description: "Default channel for notifications" }
notify_on_task_created: { type: boolean, default: true }
required: ["bot_token_secret", "default_channel"]
ui: # Native frontend plugin (see "Frontend plugin runtime")
bundle: "/ui/bundle.js" # root-relative ES module path
styles: ["/ui/plugin.css"] # optional root-relative stylesheets
# Runtime fields managed by kandev (not authored):
status: "active"
version: "1.0.0"
install_path: "~/.kandev/plugins/kandev-plugin-slack/1.0.0"
signed: false
installed_at: "2026-04-26T10:00:00Z"
restart_count: 0
last_error: null
last_error_at: null
```
`last_error` and `last_error_at` are host-managed runtime diagnostics. When a
spawn, handshake, health check, restart, or install-path check moves a plugin to
`error`, kandev stores a single-line diagnostic (bounded to 2048 bytes) and the
UTC timestamp of the failure. Before persistence, kandev redacts credential-like
values (including PATs, bearer tokens, and labeled passwords, tokens, secrets,
or API keys) and replaces the host home path with `~`; arbitrary plugin stdout
and untrusted subprocess details are never persisted verbatim. The diagnostic
is bounded after redaction. A successful handshake and health check clears both
fields. Existing records without these fields load as null.
`capabilities.api_read` / `capabilities.api_write` gate the **Host data API** Host
RPCs (both reads and writes live now) — the vocabulary is a list of
resource names: `tasks`, `sessions`, `messages`, `workspaces`, `workflows`,
`agent_profiles`, `executor_profiles`, `repositories` for `api_read`, plus `tasks` (CreateTask/
UpdateTask) and `messages` (SendMessage) for `api_write`. See "Host data API".
**Declaring data access.** Listing a resource under `api_read` grants the
corresponding Host data reads for that resource only — e.g. `api_read:
["sessions"]` unlocks `Host.Sessions().List(...)` and
`Host.Sessions().CodeStats(...)` (backed by `ListSessions` /
`ListSessionCodeStats`) but not `Host.Tasks()`. A resource left off the list
still resolves to a reader/accessor (no nil pointer), but every method on it
returns gRPC `PermissionDenied` with message `capability 'api_read:<resource>'
not declared`. Writes work the same way under `api_write` (message
`capability 'api_write:<resource>' not declared`), and reads and writes gate
independently on the same resource — a plugin may declare `api_read:tasks`
without `api_write:tasks`, or vice versa (see "Host data API writes").
`actions` declare the only browser-invocable plugin operations. Each key is unique per
plugin, has one resource scope (`workspace`, `task`, or `repository`), and supplies a
bounded request-body maximum. `repository_providers` declares stable IDs the plugin may
register in the frontend repository-provider registry; one enabled plugin owns one
provider ID. A provider that supports the native task branch picker declares the
workspace-scoped `repositories.branches` action. Kandev invokes it server-to-server with
the persisted repository identity and accepts only a bounded normalized branch list;
browser input cannot select the provider or repository for that invocation.
`reference_sources` declares plugin-owned composer sources. Source names
are unique, a descriptor cannot claim another provider, and all entries are removed when
the plugin disables. See ADRs
[2026-07-31-authenticated-plugin-actions](../../../decisions/2026-07-31-authenticated-plugin-actions.md)
and
[2026-07-31-plugin-repository-provider-extensions](../../../decisions/2026-07-31-plugin-repository-provider-extensions.md).
**External login (`auth`).** An `auth`-capable plugin can log a visitor in
against an external IdP (OIDC/SAML): its webhook (the callback / SAML ACS)
validates the token, then asserts the identity to kandev via the reserved
`X-Kandev-Auth-Login` response header (`{provider, subject, email,
display_name}`). The host maps it to a user (link-by-email or just-in-time
member provisioning), mints the session, and sets the session cookie (name
derived from the request host) itself — the plugin never receives the raw
token. Requires auth enabled; new
users are members, and the host never creates an admin nor auto-links to an
existing admin account. The plugin **must** only assert IdP-verified emails (an
unverified email claim is an account-takeover vector the host cannot detect).
This is the highest-privilege capability; grant it only to trusted plugins.
See [ADR 0050](../../../decisions/0050-plugin-external-auth-capability.md).
### Install pipeline
There is no manifest-paste registration step. A plugin is installed from a URL or an
uploaded tarball:
1. An administrator calls `POST /api/plugins/install` with JSON `{"url": "..."}`
(kandev downloads the tarball) or a multipart `package` field (direct upload).
Auth-disabled single-user instances retain their synthetic administrator.
2. Kandev verifies `checksums.txt` covers every other file in the tarball and every
hash matches (integrity gate, always enforced).
3. If `checksums.txt.sig` is present, kandev verifies the ed25519 signature; if
absent, install proceeds with a surfaced "unsigned plugin" warning (signing is not
required in v1 — see "Out of scope").
4. Kandev parses and validates `manifest.yaml` **before any code runs**: schema, `id`
pattern, capability vocabulary, and that `runtime.executables` contains an entry
for the host's OS/arch.
5. Kandev extracts the package to `~/.kandev/plugins/<id>/<version>/` and records the
installation (`id`, `version`, `install_path`, capabilities, status).
6. Kandev spawns the platform-matched binary via `hashicorp/go-plugin`, completes the
handshake (§2 of GRPC-CONTRACT.md) — status `registered` while this is pending.
7. Handshake succeeds → status `active`. Handshake/spawn failure → status `error`
(restart retried with backoff; see "State machine"). The failure diagnostic is
persisted on the record so the operator can see why activation failed and
retry with **Enable**.
Uninstall stops the subprocess and removes the record, all installed versions, and
plugin state (no 24-hour grace period in v1). `POST /api/plugins/register` is removed;
there is no operator-supplied manifest, no generated credentials, and no cleartext
secret returned at install time.
### `plugin_state` (SQLite)
```sql
CREATE TABLE plugin_state (
id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'instance', -- instance | workspace | task | agent
scope_id TEXT, -- NULL for instance scope
state_key TEXT NOT NULL,
value_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (plugin_id, scope, scope_id, state_key)
);
```