e145d48f22
* feat: add plugin system with grpc runtime and native ui plugins - go plugin backends ship as release tarballs (per-platform binaries, checksums) that kandev installs from url/upload or filesystem sideload+sync, then spawns and supervises via hashicorp/go-plugin (unix socket / loopback tcp+automtls) - strict typed grpc contract (kandev.plugin.v1): DeliverEvent/InvokeTool/HandleWebhook plus a capability-gated host service (state, secrets, emit); author sdk in pkg/pluginsdk; packaging cli in cmd/plugin-pack - native frontend bundles: window.registerKandevPlugin + registry/host loader with dynamic routes, nav items, slots and ws handlers; plugins settings page with install dialog, sync button and enable/disable/uninstall - gated behind the plugins feature flag (prod off, dev/e2e on); covered by unit and integration tests plus playwright e2e against a real packaged fixture plugin Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): hydrate base app context on plugin routes Plugin-owned SPA routes classify as RouteUnknown, so the boot payload shipped only feature flags — no workspaces, workflows, or kanban context. Native plugin UI (e.g. host.ui.TaskCreateDialog) then had an empty store. Hydrate the same base context as the Home route for unknown SPA routes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plugins): expose TaskCreateDialog on host.ui Lets a native plugin open kandev's real create-task modal (prefilled via initialValues) instead of POSTing directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): harden filesystem/URL sinks against CodeQL findings Add sink-level guards on the plugin trust boundary: securejoin containment on archive extraction (zip-slip), plugin-id validation before every fs_store path, http/https scheme validation on install-from-URL, and non-interpolated ws-handler error logging. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): address review findings (races, resource caps, lifecycle) - serialize per-plugin startup to close a TOCTOU double-spawn race, and let a plugin restart after exhausting attempts - bound plugin startup with an explicit 30s timeout - reject backslash paths and cap decompressed bytes by actual size in pkgtar; only mark a package Signed when a verifier actually ran and passed - delete plugin_state on uninstall; validate webhook keys against the manifest and cap webhook body size; restart an active plugin when a new version installs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): sanitize plugin-id path sinks for CodeQL Pin the plugin id to a single path segment with filepath.Base at recordPath/ configPath and pkgtar.Remove — a barrier the taint analysis recognizes. Behaviour-identical for valid ids (already validated by safePluginID); clears the go/path-injection alerts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): inline plugin-id validation at path sinks for CodeQL filepath.Base does not strip '..', so it is not a path-injection barrier. Call the recognized safePluginID / id-pattern guard inline in Delete/GetConfig/ SetConfig and pkgtar.Remove — the same barrier that already cleared the Get sink. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): address code-review findings (backend) - add capability-gating tests for the Host RPC service (was untested) - close a stop-during-start orphan race so Disable/Uninstall can't leave a supervised subprocess behind - on failed upgrade, remove only the new version dir (not all versions + data); restart the prior version - validate manifest version and reject ids ending in .config (record-file alias) - make Deliverer.Refresh non-blocking and cancel in-flight delivery on worker stop - clamp out-of-range webhook status, skip-and-warn corrupt records in List, write records atomically, semver-aware sideload version pick Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): address code-review findings (frontend) - wrap plugin routes + settings routes in a shared PluginErrorBoundary so a throwing plugin page shows a fallback instead of white-screening the SPA - fix disable/re-enable in one session: keep the cached registration and re-run initialize instead of relying on a cached ES-module re-import - prefix root-relative bundle/style URLs with apiBaseUrl (split-origin/Tauri) - surface partial-install warnings via toast.warning instead of a bare success - dedupe/remove injected style links on unload; gate PluginNavItems on the flag Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): serialize per-plugin lifecycle + guard config writes - add a per-plugin keyed lock in Service so concurrent Enable/Disable/Install/ Uninstall/UpdateConfig for one id are serialized (no status-machine race) - take s.mu in SetConfig/GetConfig and write config atomically - bound each plugin initialize() with a timeout so one hung bundle can't stall the rest of the boot load Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(plugins): split service_test.go to satisfy file-length lint Move the install/URL test group into service_install_test.go; no logic change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: build and ship the e2e fixture plugin package to E2E shards The plugin E2E specs' global-setup requires apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz. Build it in the E2E build job, upload it as an artifact, and download it in both the regular and container shard jobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
595 lines
31 KiB
Markdown
595 lines
31 KiB
Markdown
---
|
|
status: draft
|
|
created: 2026-04-26
|
|
owner: cfl
|
|
---
|
|
|
|
# Plugin System
|
|
|
|
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 tools, declared webhooks, config schema, and optional UI bundle.
|
|
- Plugins SHALL receive events, register agent tools, 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.
|
|
- 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".
|
|
|
|
## 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, tools, 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.78.0" # optional
|
|
|
|
capabilities:
|
|
events: ["task.created", "task.state_changed", "agent.completed"]
|
|
api_read: ["tasks", "agents", "projects"] # reserved for future Host RPCs (see below)
|
|
api_write: ["tasks", "comments"] # reserved for future Host RPCs (see below)
|
|
state: true
|
|
secrets: true
|
|
|
|
tools:
|
|
- name: "slack_send_message"
|
|
display_name: "Send Slack Message"
|
|
description: "Posts a message to a Slack channel"
|
|
input_schema:
|
|
type: object
|
|
properties:
|
|
channel: { type: string }
|
|
text: { type: string }
|
|
required: ["channel", "text"]
|
|
|
|
webhooks:
|
|
- key: "slack-events"
|
|
description: "Slack Events API webhook"
|
|
method: "POST"
|
|
|
|
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" # ES module extracted alongside the package
|
|
styles: ["ui/plugin.css"] # optional stylesheets
|
|
|
|
# Runtime fields managed by kandev (not authored):
|
|
status: "active"
|
|
version: "1.0.0"
|
|
install_path: "~/.kandev/plugins/kandev-plugin-slack/1.0.0"
|
|
installed_at: "2026-04-26T10:00:00Z"
|
|
restart_count: 0
|
|
```
|
|
|
|
`capabilities.api_read` / `capabilities.api_write` are reserved for **future** Host
|
|
RPCs (e.g. a `CreateTask` RPC) — they are unrelated to office and are not implemented
|
|
by any Host RPC today; see "Read/write kandev data".
|
|
|
|
### Install pipeline
|
|
|
|
There is no manifest-paste registration step. A plugin is installed from a URL or an
|
|
uploaded tarball:
|
|
|
|
1. Operator calls `POST /api/plugins/install` with JSON `{"url": "..."}` (kandev
|
|
downloads the tarball) or a multipart `package` field (direct upload).
|
|
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").
|
|
|
|
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)
|
|
);
|
|
```
|
|
|
|
## API surface
|
|
|
|
### Plugin management API (operator -> kandev, HTTP)
|
|
|
|
```
|
|
POST /api/plugins/install # Install a plugin: JSON {"url": "..."} or multipart `package`
|
|
POST /api/plugins/sync # Reconcile the registry with the plugins directory on disk
|
|
GET /api/plugins # List installed plugins
|
|
GET /api/plugins/{id} # Get plugin detail
|
|
PATCH /api/plugins/{id} # Update plugin config
|
|
DELETE /api/plugins/{id} # Uninstall plugin (stops subprocess, removes package + state)
|
|
POST /api/plugins/{id}/enable
|
|
POST /api/plugins/{id}/disable
|
|
GET /api/plugins/tools # List all declared tools across active plugins
|
|
GET /api/plugins/{id}/bundle # Frontend bundle, served from the extracted package dir
|
|
GET /api/plugins/{id}/ui/* # Frontend bundle assets, served from the extracted package dir
|
|
```
|
|
|
|
`POST /api/plugins/sync` is documented in full under "Filesystem sideloading & sync"
|
|
below.
|
|
|
|
`GET /api/plugins/{id}/bundle` and `/ui/*` are served directly by kandev **from the
|
|
extracted package directory** (`~/.kandev/plugins/<id>/<version>/ui/...`) — there is
|
|
no reverse proxy and no upstream plugin process involved in serving frontend assets,
|
|
since the files are already on local disk after install.
|
|
|
|
Enable/disable/uninstall act on the supervised subprocess: disable stops it (state and
|
|
config preserved); enable respawns it; uninstall stops it and deletes its package,
|
|
record, and state.
|
|
|
|
### Event delivery (kandev -> plugin, gRPC `DeliverEvent`)
|
|
|
|
Kandev calls the plugin's `Plugin.DeliverEvent` RPC (unary) with an `Event` message:
|
|
|
|
```proto
|
|
message Event {
|
|
string event_id = 1; // fresh uuid per delivery
|
|
string event_type = 2; // bus subject, e.g. "task.created"
|
|
string occurred_at = 3; // RFC3339 UTC
|
|
string workspace_id = 4; // empty if not derivable
|
|
google.protobuf.Struct payload = 5; // marshaled bus event.Data
|
|
}
|
|
```
|
|
|
|
Expected response: `EventAck{}`. A non-nil gRPC error or a timeout counts as failure.
|
|
|
|
Delivery semantics (unchanged from earlier design, now carried over gRPC):
|
|
- **At-least-once.** Plugins must be idempotent (dedup by `event_id`).
|
|
- **Timeout:** 10 seconds. Up to 3 retries with exponential backoff (5s, 15s, 45s).
|
|
- **Sequential per plugin** — no concurrent delivery to the same plugin. Plugins
|
|
needing parallel processing queue internally.
|
|
- **Buffering while unhealthy:** events are held in a ring buffer (100 events, 5-minute
|
|
TTL) and flushed in order once the plugin recovers (health/Ping succeeds again).
|
|
|
|
Event types: any subject kandev publishes on its internal event bus
|
|
(`internal/events/types.go`). Plugins subscribe to whatever they need; the catalog
|
|
below is a non-exhaustive sample across feature areas — the plugin system is not tied to
|
|
any one of them.
|
|
|
|
| Category | Events |
|
|
|----------|--------|
|
|
| Tasks | `task.created`, `task.updated`, `task.state_changed`, `task.deleted`, `task.moved` |
|
|
| Sessions | `task_session.state_changed`, `turn.started`, `turn.completed` |
|
|
| Agents | `agent.started`, `agent.completed`, `agent.failed`, `agent.stopped` |
|
|
| Other feature areas (examples) | Any additional subjects emitted by feature areas such as office/agents (e.g. `office.comment.created`, `office.approval.created`) — plugins may subscribe to these, but the plugin system does not depend on them |
|
|
| GitHub | `github.pr_state_changed`, `github.pr_feedback`, `github.new_issue` |
|
|
| Plugin | `plugin.<plugin_id>.<name>` (cross-plugin events) |
|
|
|
|
Wildcard subscriptions: `task.*`, `agent.*`, `<feature>.*` (any subject prefix).
|
|
|
|
### Agent tool invocation (kandev -> plugin, gRPC `InvokeTool`)
|
|
|
|
When an agent calls a plugin tool, kandev calls the plugin's `Plugin.InvokeTool` RPC:
|
|
|
|
```proto
|
|
message ToolRequest {
|
|
string tool_call_id = 1;
|
|
string tool_name = 2;
|
|
google.protobuf.Struct input = 3;
|
|
ToolContext context = 4;
|
|
}
|
|
message ToolContext { string task_id = 1; string agent_instance_id = 2; string session_id = 3; }
|
|
message ToolResponse { google.protobuf.Struct output = 1; string error = 2; }
|
|
```
|
|
|
|
Timeout: 30 seconds. Tools available to all agents by default (`tool_scope` for
|
|
role/instance restrictions is future work).
|
|
|
|
### External webhook proxy (external -> kandev -> plugin)
|
|
|
|
```
|
|
POST /api/plugins/{plugin_id}/webhooks/{webhook_key}
|
|
```
|
|
|
|
This remains kandev's one plugin-facing **HTTP** endpoint (external systems like Slack
|
|
or Jira cannot speak gRPC). Kandev validates the plugin is active and the webhook key
|
|
is declared, converts the HTTP request into a `WebhookRequest` gRPC message, and calls
|
|
the plugin's `Plugin.HandleWebhook` RPC:
|
|
|
|
```proto
|
|
message WebhookRequest {
|
|
string webhook_key = 1;
|
|
string method = 2;
|
|
string path = 3; // remainder after the key
|
|
string query = 4;
|
|
map<string, string> headers = 5; // single-valued; multi joined by ", "
|
|
bytes body = 6;
|
|
}
|
|
message WebhookResponse { int32 status = 1; map<string, string> headers = 2; bytes body = 3; }
|
|
```
|
|
|
|
The `WebhookResponse` is relayed back as the HTTP response. The plugin verifies the
|
|
external system's signature (Slack signing secret, GitHub webhook secret, etc.) itself.
|
|
|
|
### Host gRPC service (plugin -> kandev)
|
|
|
|
There is no plugin-facing HTTP API for state, secrets, or cross-plugin events. Instead,
|
|
kandev implements a `Host` gRPC service and serves it back to the plugin over the
|
|
go-plugin broker (the plugin is the gRPC client for this service):
|
|
|
|
```proto
|
|
service Host {
|
|
rpc GetState(GetStateRequest) returns (GetStateResponse);
|
|
rpc SetState(SetStateRequest) returns (SetStateResponse);
|
|
rpc DeleteState(DeleteStateRequest) returns (DeleteStateResponse);
|
|
rpc ListState(ListStateRequest) returns (ListStateResponse);
|
|
rpc RevealSecret(RevealSecretRequest) returns (RevealSecretResponse);
|
|
rpc EmitEvent(EmitEventRequest) returns (EmitEventResponse);
|
|
}
|
|
```
|
|
|
|
- `GetState`/`SetState`/`DeleteState`/`ListState` operate on `plugin_state`, scoped by
|
|
`scope` (`instance`, `workspace`, `task`, `agent`) and `scope_id`. Plugins cannot
|
|
read other plugins' state — the Host service instance handed to a plugin's
|
|
subprocess is bound to that plugin's own ID at spawn time, so there is no
|
|
plugin-supplied ID to spoof.
|
|
- `RevealSecret(ref)` resolves a secret reference through kandev's `internal/secrets/`
|
|
package. Requires `capabilities.secrets: true`.
|
|
- `EmitEvent(event_name, payload)` publishes `plugin.<plugin_id>.<event_name>` on the
|
|
internal event bus for delivery to subscribers (replaces the old
|
|
`POST /api/plugins/{plugin_id}/events/emit` HTTP endpoint).
|
|
|
|
Every Host RPC is capability-gated by a **unary server interceptor** that checks the
|
|
plugin's manifest capabilities (`state`, `secrets`; `api_read`/`api_write` reserved)
|
|
before the handler runs, and returns gRPC status `PermissionDenied` with message
|
|
`capability '<name>' not declared` on a miss.
|
|
|
|
### Read/write kandev data (future)
|
|
|
|
`capabilities.api_read` and `capabilities.api_write` are reserved in the manifest
|
|
schema for **future** Host RPCs (e.g. `CreateTask`, `ListTasks`, `AddComment`) that
|
|
would let a plugin read/write kandev's own data (tasks, agents, comments) the same way
|
|
it reads/writes its own KV state. These RPCs do not exist yet — declaring the
|
|
capability today has no effect. This is unrelated to office; it is a general
|
|
extension point for any future kandev resource. See "Out of scope".
|
|
|
|
## Filesystem sideloading & sync
|
|
|
|
Besides the URL/upload install pipeline, an operator with shell access to the host
|
|
can place plugin content directly under the plugins directory
|
|
(`~/.kandev/plugins/`) without going through `POST /api/plugins/install`. `POST
|
|
/api/plugins/sync` (and the Settings > Plugins **Sync** button, which calls it and
|
|
refreshes the list) reconciles the registry with whatever is actually on disk:
|
|
|
|
1. **Directory sideloads.** For every `<pluginsDir>/<id>/<version>/manifest.yaml`
|
|
found with no existing `{id}.yml` record, kandev parses and validates the
|
|
manifest, requires it to be runtime-managed (`runtime.type: binary`) and its `id`
|
|
field to match the `<id>` directory name, and registers it — always with status
|
|
**`disabled`**, never `active`. Sideloads are unverified (no checksum, no
|
|
integrity gate the URL/upload pipeline runs) and are never auto-spawned; an
|
|
operator must explicitly enable one after inspecting it. If more than one version
|
|
directory exists for the same unregistered id, the lexically greatest version is
|
|
registered and the others are reported as skipped, not registered.
|
|
2. **Dropped tarballs.** Every `*.tar.gz` file sitting directly in the plugins
|
|
directory is run through the same verified install pipeline `POST
|
|
/api/plugins/install` uses (checksum verification, manifest validation, platform
|
|
executable check, extraction, spawn, activate). On success the tarball file is
|
|
deleted. On a validation failure the file is left in place (not retried
|
|
automatically) and the failure is reported.
|
|
3. **Missing installs.** Every registered record whose `install_path` no longer
|
|
exists on disk (deleted out from under kandev) is stopped, if its process is
|
|
running, and transitioned to status `error`.
|
|
|
|
`POST /api/plugins/sync` returns a `SyncResult`: which plugin ids were newly
|
|
sideloaded (`added`), which were installed from a dropped tarball (`installed`),
|
|
which were marked missing (`missing`), and a list of per-item `errors` (path +
|
|
reason) for anything rejected or skipped along the way. A single item's failure
|
|
never aborts the rest of the scan. Concurrent sync calls are serialized so an
|
|
operator double-clicking Sync (or a sync racing the boot scan) cannot double-install
|
|
the same dropped tarball.
|
|
|
|
At **boot**, kandev runs only the directory-sideload and missing-install steps
|
|
(never the tarball-install step) as part of resuming previously-active plugins —
|
|
conservative by design: starting up should never itself spawn a binary an operator
|
|
has not explicitly approved via install or Sync. What the boot scan found is logged;
|
|
an operator triggers the full sync (including tarball installs) explicitly via the
|
|
Sync button or the API.
|
|
|
|
## 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 path inside the extracted package,
|
|
e.g. `ui/bundle.js`) and optional `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 }]` for every **active** plugin that
|
|
declares a bundle (gated on the `plugins` feature flag).
|
|
- **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).
|
|
- **Registry surface:** `registerRoute(path, C)`, `registerNavItem(item)`,
|
|
`registerSettingsRoute(path, C)`, `registerComponent(slot, C)` (slots:
|
|
`task-sidebar`, `settings-nav`, ...), `registerWsHandler(action, fn)`.
|
|
Registrations are namespaced per plugin and bulk-revoked on disable/uninstall.
|
|
- **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).
|
|
|
|
## State machine
|
|
|
|
```
|
|
registered -> active -> disabled -> uninstalled
|
|
| |
|
|
+-> error -+
|
|
```
|
|
|
|
| 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, tools available, 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). Tools removed from agent tool set. Webhooks return 503 |
|
|
| `disabled` | Operator explicitly disabled. Subprocess stopped. No events, no tools, 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.
|
|
|
|
## 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".
|
|
|
|
## Failure modes
|
|
|
|
- **3 consecutive `Ping` failures (90s), or crash with restart attempts exhausted
|
|
(max 5, backoff)**: status -> `error`. Events buffered (100, 5min TTL). Tools hidden
|
|
from agent tool set. Webhooks return 503. Inbox item created.
|
|
- **Buffer overflows (>100 events or >5min)**: oldest events dropped and logged.
|
|
- **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.
|
|
- **Tool call timeout (>30s)**: agent receives a timeout error.
|
|
- **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.
|
|
- **Checksum mismatch or unresolvable host-platform executable at install time**:
|
|
install is rejected before any code runs.
|
|
|
|
## Persistence guarantees
|
|
|
|
- Plugin installation records (`id`, `version`, `install_path`, capabilities, status)
|
|
persist to disk under `~/.kandev/plugins/<id>/` and survive backend restarts.
|
|
- Extracted plugin packages persist at `~/.kandev/plugins/<id>/<version>/` until
|
|
uninstall.
|
|
- Plugin state in SQLite survives restarts.
|
|
- 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.
|
|
|
|
## Scenarios
|
|
|
|
- **GIVEN** an operator with a release tarball URL for a Slack notification plugin,
|
|
**WHEN** the operator calls `POST /api/plugins/install` with `{"url": "..."}`,
|
|
**THEN** kandev downloads the tarball, verifies `checksums.txt`, validates the
|
|
manifest, extracts it to `~/.kandev/plugins/kandev-plugin-slack/1.0.0/`, spawns the
|
|
platform-matched binary, completes the go-plugin handshake, and the plugin appears
|
|
in `GET /api/plugins` with status `active`.
|
|
|
|
- **GIVEN** an operator with a plugin tarball on their local machine, **WHEN** the
|
|
operator uploads it via `POST /api/plugins/install` (multipart `package`), **THEN**
|
|
kandev runs the same verify → validate → extract → spawn pipeline and the plugin
|
|
reaches `active` without any URL ever being contacted.
|
|
|
|
- **GIVEN** an operator who extracted a plugin package directly into
|
|
`~/.kandev/plugins/<id>/<version>/` on the host filesystem (no install call), **WHEN**
|
|
the operator clicks **Sync** in Settings > Plugins (`POST /api/plugins/sync`),
|
|
**THEN** kandev finds the unrecorded `manifest.yaml`, validates it, and registers the
|
|
plugin with status **`disabled`** — never spawning it automatically. The plugin then
|
|
appears in the list, and the operator can enable it explicitly like any other plugin.
|
|
|
|
- **GIVEN** an active Slack plugin subscribed to `task.state_changed`, **WHEN** a task
|
|
moves to `done`, **THEN** kandev calls the plugin's `DeliverEvent` gRPC method with
|
|
the event over the go-plugin unix socket. The plugin formats a Slack message and
|
|
calls the Slack API, then returns `EventAck{}`.
|
|
|
|
- **GIVEN** a Jira sync plugin that registered the `jira_search` agent tool, **WHEN**
|
|
an agent calls `jira_search` with query "auth migration", **THEN** kandev calls the
|
|
plugin's `InvokeTool` gRPC method. The plugin queries Jira, returns results as the
|
|
`ToolResponse`. The agent receives the search results as the tool output.
|
|
|
|
- **GIVEN** a Jira sync plugin with a registered `jira-webhooks` webhook, **WHEN**
|
|
Jira POSTs a webhook to
|
|
`https://kandev.example.com/api/plugins/kandev-plugin-jira/webhooks/jira-webhooks`,
|
|
**THEN** kandev converts the HTTP request into a `WebhookRequest` and calls the
|
|
plugin's `HandleWebhook` gRPC method. The plugin parses the Jira event, calls
|
|
`Host.SetState` to record the linked task, and returns a `WebhookResponse` that
|
|
kandev relays back as the HTTP response.
|
|
|
|
- **GIVEN** an active plugin subprocess that crashes, **WHEN** kandev detects the
|
|
process exit, **THEN** kandev immediately attempts a restart with backoff, marks the
|
|
plugin `error` while buffering events (up to 100 or 5 minutes), and creates an inbox
|
|
item "Plugin kandev-plugin-slack is unreachable". **WHEN** a subsequent restart
|
|
attempt succeeds and the handshake completes, **THEN** status returns to `active`
|
|
and buffered events are delivered in order.
|
|
|
|
- **GIVEN** a plugin whose manifest declares `secrets: false`, **WHEN** the plugin
|
|
calls `Host.RevealSecret`, **THEN** kandev's server interceptor returns gRPC status
|
|
`PermissionDenied` with message `capability 'secrets' not declared`, before the
|
|
handler runs.
|
|
|
|
- **GIVEN** two plugins (Slack and Jira), **WHEN** the Jira plugin calls
|
|
`Host.EmitEvent` with `event_name: "sync-completed"`, **THEN** it is published as
|
|
`plugin.kandev-plugin-jira.sync-completed` and the Slack plugin (subscribed to
|
|
`plugin.kandev-plugin-jira.*`) receives it via `DeliverEvent` and posts a sync
|
|
summary to Slack.
|
|
|
|
- **GIVEN** a plugin with state, **WHEN** the plugin calls `Host.SetState` with
|
|
`scope: "task", scope_id: "task_xyz", key: "jira_issue_id", value: "PROJ-123"`,
|
|
**THEN** the state is persisted in SQLite. A subsequent `Host.GetState` call with the
|
|
same scope/key returns `"PROJ-123"`.
|
|
|
|
## Out of scope
|
|
|
|
- **Remote / operator-hosted plugin tier.** The earlier `base_url` registration model,
|
|
where an operator ran and managed a plugin process themselves and kandev only knew
|
|
its address, is removed. Every plugin backend in v1 is a binary kandev spawns and
|
|
supervises locally. A remote tier (kandev talking gRPC to a plugin process it does
|
|
not spawn) may return as future work if a real need emerges.
|
|
- **Plugin JS sandboxing.** Native UI plugins (see "Frontend plugin runtime") run
|
|
in the kandev origin with full app-store access. Isolating plugin JS in a worker,
|
|
realm, or comparable sandbox is future work; v1 relies on only loading active,
|
|
operator-installed plugins served same-origin.
|
|
- **In-process backend plugins.** Plugin *backends* remain out-of-process — no Go
|
|
plugin loading via `plugin.Open`, no WASM, no shared-memory communication. (This is
|
|
distinct from the frontend bundle, which does load into the SPA.)
|
|
- **Plugin marketplace or registry.** No central catalog, no one-click install, no
|
|
automatic discovery. Install-by-URL/upload is a manual, single-plugin action; a
|
|
searchable catalog is a later phase.
|
|
- **Mandatory package signing.** `checksums.txt.sig` verification is supported when
|
|
present, but signing is optional in v1 — an unsigned package installs with a warning
|
|
rather than being rejected. Requiring signatures is future work.
|
|
- **Agent-session tool wiring.** Declared plugin tools are listed
|
|
(`GET /api/plugins/tools`) and invocable via the tool client, but are not yet
|
|
injected into live agent tool sets.
|
|
- **Hot reload.** Upgrading a plugin requires a new install (new version directory);
|
|
there is no in-place manifest or binary swap on a running process.
|
|
- **Multi-instance plugins.** Each plugin ID maps to exactly one supervised subprocess.
|
|
- **Rate limiting.** No per-plugin rate limits in v1. Misbehaving plugins can be disabled manually.
|
|
- **Plugin database namespaces.** Plugins do not get their own SQLite schemas. KV state is sufficient for v1.
|
|
- **`api_read`/`api_write` Host RPCs.** The capability vocabulary is reserved in the
|
|
manifest schema, but no Host RPC reads or writes kandev's own data (tasks, agents,
|
|
comments) yet — see "Read/write kandev data".
|