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

15 KiB

status, system, requirements, created, owners
status system requirements created owners
draft plugins
REQ-PLUGINS-PLUGINS-001
2026-04-26
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

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):

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 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:

  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)

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)
);