15 KiB
status, system, requirements, created, owners
| status | system | requirements | created | owners | ||
|---|---|---|---|---|---|---|
| draft | plugins |
|
2026-04-26 |
|
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
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:
- Core bloat. Every new integration increases the surface area of core. Adding more at similar scale makes the codebase unmaintainable.
- 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.
- 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
PermissionDeniedstatus. - 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_urlregistration 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-sdkpackage is the frontend author contract. Official plugins use its typedhost.contextreads 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 nativeTaskCreateDialog, 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):
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.78.0" # optional
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 # workspace | task | repository
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
and
2026-07-31-plugin-repository-provider-extensions.
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.
Install pipeline
There is no manifest-paste registration step. A plugin is installed from a URL or an uploaded tarball:
- An administrator calls
POST /api/plugins/installwith JSON{"url": "..."}(kandev downloads the tarball) or a multipartpackagefield (direct upload). Auth-disabled single-user instances retain their synthetic administrator. - Kandev verifies
checksums.txtcovers every other file in the tarball and every hash matches (integrity gate, always enforced). - If
checksums.txt.sigis 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"). - Kandev parses and validates
manifest.yamlbefore any code runs: schema,idpattern, capability vocabulary, and thatruntime.executablescontains an entry for the host's OS/arch. - Kandev extracts the package to
~/.kandev/plugins/<id>/<version>/and records the installation (id,version,install_path, capabilities, status). - Kandev spawns the platform-matched binary via
hashicorp/go-plugin, completes the handshake (§2 of GRPC-CONTRACT.md) — statusregisteredwhile this is pending. - Handshake succeeds → status
active. Handshake/spawn failure → statuserror(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)
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)
);