diff --git a/apps/web/e2e/tests/plugins/plugins-docs-screenshots.spec.ts b/apps/web/e2e/tests/plugins/plugins-docs-screenshots.spec.ts new file mode 100644 index 0000000000..cc3a3711cb --- /dev/null +++ b/apps/web/e2e/tests/plugins/plugins-docs-screenshots.spec.ts @@ -0,0 +1,154 @@ +/** + * Docs media generator — NOT a CI assertion. Skipped unless + * CAPTURE_DOCS_MEDIA=1 is set, so it never runs in the normal e2e shards. + * + * When enabled, it installs a real gRPC plugin into the worker's isolated + * backend (same harness as tests/plugins/plugins.spec.ts) and screenshots + * the operator-facing surfaces straight into docs/screenshots/plugin-*.png, + * which the public plugin docs embed as ../screenshots/plugin-*.png (the + * landing publisher only rewrites/copies images under screenshots/; media/ + * is reserved for assets). + * + * Regenerate (from apps/web), pointing at the polished kandev-plugin-hello + * example so the captures match the docs prose (display name "Hello Plugin", + * "Hello World" nav item, /hello-world route): + * + * CAPTURE_DOCS_MEDIA=1 \ + * DOCS_PLUGIN_PACKAGE=/abs/path/to/kandev-plugin-hello-1.5.0.tar.gz \ + * DOCS_PLUGIN_ID=kandev-plugin-hello \ + * DOCS_PLUGIN_NAV_ID=hello-world \ + * DOCS_PLUGIN_ROUTE=/hello-world \ + * pnpm e2e --project=chromium --workers=1 tests/plugins/plugins-docs-screenshots.spec.ts + * + * With no DOCS_PLUGIN_* overrides it falls back to the repo's own e2e fixture + * package (apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz), so the spec is + * always self-runnable even without the external example checked out. + */ +import fs from "node:fs"; +import path from "node:path"; +import type { Page } from "@playwright/test"; +import { expect, test } from "../../fixtures/test-base"; + +const CAPTURE = process.env.CAPTURE_DOCS_MEDIA === "1"; + +const PACKAGE_PATH = + process.env.DOCS_PLUGIN_PACKAGE ?? + path.resolve(__dirname, "../../../../../apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz"); +const PLUGIN_ID = process.env.DOCS_PLUGIN_ID ?? "kandev-plugin-e2e"; +const NAV_ITEM_ID = process.env.DOCS_PLUGIN_NAV_ID ?? "e2e-hello"; +const PLUGIN_ROUTE = process.env.DOCS_PLUGIN_ROUTE ?? "/plugins/e2e-hello"; + +const SCREENSHOTS_DIR = path.resolve(__dirname, "../../../../../docs/screenshots"); +const VIEWPORT = { width: 1280, height: 860 }; + +// Hand-authored "How it works" flowchart (SVG), rendered to +// docs/screenshots/plugin-architecture.png (plugins.md embeds it). A clean +// dark top-down flowchart (mermaid's shape, drawn properly) that matches the +// docs page; the render uses omitBackground so the rounded card is transparent. +const ARCH_HTML = ` + + + +InstallURL · upload · filesystem sync + +Verifychecksums.txt · validate manifest.yaml + +Extract~/.kandev/plugins/<id>/<version>/ + +Spawn subprocessgo-plugin · gRPC over unix socket · AutoMTLS + +DeliverEventHandleWebhook +kandev delivers bus eventsat-least-once · buffered while unhealthy +kandev relays a webhookPOST/GET /api/plugins/{id}/webhooks/{key} + +Plugin calls back — Host APIstate · config · secrets · EmitEvent · read-only data +optional +SPA loads the native UI bundleroutes · nav · slots · WS handlers +`; + +/** Let transient success toasts auto-dismiss so they don't sit in captures. */ +async function waitForToastsGone(page: Page): Promise { + await page + .locator("[data-sonner-toast]") + .first() + .waitFor({ state: "detached", timeout: 8_000 }) + .catch(() => undefined); +} + +async function shot(page: Page, name: string): Promise { + fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + await waitForToastsGone(page); + await page.screenshot({ path: path.join(SCREENSHOTS_DIR, `plugin-${name}.png`) }); +} + +test.describe("Plugin docs screenshots", () => { + test.skip(!CAPTURE, "docs media generator — set CAPTURE_DOCS_MEDIA=1 to run"); + + test("captures the operator-facing plugin surfaces", async ({ testPage, apiClient }) => { + test.setTimeout(120_000); + await testPage.setViewportSize(VIEWPORT); + + // --- Install dialog (upload tab), captured before we actually upload --- + await testPage.goto("/settings/plugins"); + await testPage.getByTestId("install-plugin-trigger").click(); + await expect(testPage.getByTestId("install-plugin-dialog")).toBeVisible(); + await testPage.getByTestId("install-plugin-tab-upload").click(); + await shot(testPage, "install-dialog"); + + // --- Install the package, land on the list with an Active row --- + await testPage.getByTestId("install-plugin-file-input").setInputFiles(PACKAGE_PATH); + await testPage.getByTestId("install-plugin-upload-submit").click(); + const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`); + await expect(pluginRow).toBeVisible({ timeout: 15_000 }); + await expect(pluginRow.getByText("Active", { exact: true })).toBeVisible(); + await expect(testPage.getByTestId("install-plugin-dialog")).toBeHidden(); + await shot(testPage, "settings-list"); + + // --- Per-plugin settings page: config_schema form + manifest card --- + await testPage.getByTestId(`plugin-row-link-${PLUGIN_ID}`).click(); + await expect(testPage).toHaveURL(new RegExp(`/settings/plugins/${PLUGIN_ID}$`)); + await expect(testPage.getByTestId(`plugin-detail-${PLUGIN_ID}`)).toBeVisible(); + await expect(testPage.getByTestId("plugin-manifest-card")).toBeVisible(); + + // Capture the pristine first-open form (schema-driven fields at their + // defaults + the manifest card) — no edits, so no unsaved-changes banner. + await shot(testPage, "settings-page"); + + // --- The plugin's own native route + its sidebar nav item --- + await testPage.goto("/"); + await testPage.reload(); + const navItem = testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`); + await expect(navItem).toBeVisible({ timeout: 15_000 }); + await navItem.click(); + await expect(testPage).toHaveURL(new RegExp(`${PLUGIN_ROUTE}$`)); + await shot(testPage, "native-page"); + + // Clean up so a repeat run reinstalls from a clean slate. + await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); + }); + + test("renders the how-it-works architecture diagram", async ({ browser }) => { + const ctx = await browser.newContext({ deviceScaleFactor: 2 }); + const page = await ctx.newPage(); + await page.setContent(ARCH_HTML, { waitUntil: "networkidle" }); + fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + await page.locator("#diagram").screenshot({ + path: path.join(SCREENSHOTS_DIR, "plugin-architecture.png"), + omitBackground: true, + }); + await ctx.close(); + }); +}); diff --git a/docs/public/configuration.md b/docs/public/configuration.md index 30a6521978..fec18bf504 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -107,7 +107,7 @@ The Docker socket is effectively root-equivalent on many hosts. Do not publish i The launcher starts `agentctl`, performs a one-time nonce handshake, and supplies the resulting per-launch token internally. Do not persist or proxy its bootstrap/auth state. Agent command, model, environment, permission, and MCP configuration belongs in agent profiles rather than this section. -### Authentication, Office, voice, and feature flags +### Authentication, Office, Plugins, voice, and feature flags | YAML key | Environment variable | Default | Current behavior | |---|---|---|---| @@ -116,6 +116,7 @@ The launcher starts `agentctl`, performs a one-time nonce handshake, and supplie | `office.jwtSigningKey` | `KANDEV_OFFICE_JWTSIGNINGKEY` | random per start | HMAC key for Office agent-runtime JWTs. Set a stable secret when Office tasks must survive restarts. | | `voice.openAIApiKey` | `KANDEV_VOICE_OPENAI_API_KEY` | empty | Server-side transcription fallback when browser speech recognition is unavailable. Empty disables the fallback and its endpoint returns unavailable. | | `features.office` | `KANDEV_FEATURES_OFFICE` | `false` in production | Experimental Office UI, routes, services, and automation. | +| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | `false` in production | Extensible plugin system: install/manage plugins, spawn plugin backends, and load native UI bundles. Loaded plugin code runs with backend privileges. | Do not infer security from `auth.jwtSecret`: setting it currently does not turn the local server into an authenticated public service. Office's JWT key has a narrower, active purpose. Store both active secrets and third-party API keys in your deployment secret manager; never commit them in `config.yaml`. @@ -273,17 +274,19 @@ voice: features: office: false + plugins: false ``` Copying this entire file is unnecessary and can freeze old defaults in a deployment. Keep only deliberate overrides. On Windows, do not copy the Unix Docker host/path literals from this example. ## Runtime feature toggles -**Settings → System → Feature Toggles** manages two startup-time flags: +**Settings → System → Feature Toggles** manages three startup-time flags: | Key | Environment lock | Production default | Effect | |---|---|---|---| | `features.office` | `KANDEV_FEATURES_OFFICE` | off | Experimental autonomous-agent Office surfaces and automation. | +| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | off | Extensible plugin system: install/manage plugins and load their backends and native UI bundles. | | `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` (also locked by explicit legacy/debug-message vars) | off | High-risk diagnostic endpoints and ACP frame logging. | UI changes are persisted in the database and require a restart. An explicitly set environment value wins and locks the UI control. Otherwise a database override wins over the embedded profile/default. Resetting a toggle removes its database override. diff --git a/docs/public/coverage.json b/docs/public/coverage.json index 00e6eb6183..44761fafaa 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -265,6 +265,14 @@ "sources": ["apps/backend/internal/office/routes.go", "apps/web/src/office-routes.tsx"], "tests": ["apps/backend/internal/office/onboarding/service_test.go", "apps/web/src/office-routes.test.ts"] }, + { + "id": "plugin-system", + "audiences": ["operator", "contributor"], + "stability": "experimental", + "docs": ["plugins", "plugins-authoring", "plugins-manifest"], + "sources": ["apps/backend/internal/plugins/handlers.go", "apps/backend/pkg/pluginsdk/host.go", "apps/web/lib/plugins/host-api.ts"], + "tests": ["apps/backend/internal/plugins/service_test.go", "apps/backend/internal/plugins/manifest/manifest_test.go", "apps/web/lib/plugins/host-api.test.ts"] + }, { "id": "websocket-protocol", "audiences": ["contributor", "integrator"], diff --git a/docs/public/extending-kandev.md b/docs/public/extending-kandev.md index 477148e27f..b3f3a16605 100644 --- a/docs/public/extending-kandev.md +++ b/docs/public/extending-kandev.md @@ -1,6 +1,6 @@ --- title: "Extending Kandev" -description: "Add agents, executors, integrations, workflow behavior, MCP tools, settings, or workbench surfaces at their real ownership boundaries." +description: "Add agents, executors, integrations, workflow behavior, MCP tools, plugins, settings, or workbench surfaces at their real ownership boundaries." --- # Extending Kandev @@ -65,6 +65,28 @@ A new relayed tool normally requires: Inject task/session identity from server context instead of trusting arguments. Enforce task/workspace reachability, confirmation for destructive actions, pagination, concurrency behavior, and least-privilege credentials. The backend's external MCP routes currently have no Kandev user-auth middleware; deployment network controls are part of the security boundary. +## Build a plugin + +Plugins are a peer extension mechanism to the seams above, aimed at +extensions that should ship and version independently of a kandev release. +A plugin backend is a Go binary that kandev spawns and supervises as a +subprocess, communicating over a strict typed gRPC protocol +(`internal/plugins/`, `pkg/pluginsdk`) — it receives bus events and relays +external webhooks, calling back into kandev through a capability-gated Host +RPC service (state, secrets, read-only data, cross-plugin events). A plugin may additionally ship an optional **native +frontend bundle** that the SPA loads at boot to register real routes, nav +items, slot components, and WebSocket handlers, sharing kandev's own React +instance and app store. + +Plugins are distributed as a signed-or-unsigned tarball and installed by URL, +manual upload, or filesystem sideload/sync — there is no manifest-paste +registration step and no credentials to issue. The whole system sits behind +the `plugins` feature flag (Settings > System > Feature Toggles), off by +default in production. See [Plugins](plugins.md) for the operator-facing +install/operate flow, [Authoring a plugin](plugins-authoring.md) for the +build tutorial and SDK reference, and the [Plugin manifest +reference](plugins-manifest.md) for the complete `manifest.yaml` schema. + ## Add settings, flags, or workbench UI Create backend ownership and validation before a web form. Web settings require the appropriate domain client/hook, `src/settings-routes.tsx`, page/components, and sidebar or general navigation. Add a global state slice only when multiple surfaces or WebSocket hydration require it. diff --git a/docs/public/feature-status.md b/docs/public/feature-status.md index 2862748760..fff205a1a2 100644 --- a/docs/public/feature-status.md +++ b/docs/public/feature-status.md @@ -98,8 +98,9 @@ Released builds can lag this page. Check **Settings > System > About** or `kande | Statistics and host resource metrics | Supported | Workspace stats and on-demand CPU/memory/disk display are shipped. Execution-environment metrics depend on executor/runtime support. | | SQLite backups, database maintenance, status, and logs | Supported | Backups use SQLite `VACUUM INTO` snapshots under the data root. Retention, free space, file permissions, and off-host copies remain operator responsibilities. | | Update checking and applying updates | Dependency-bound | Checking is shipped. Package-manager installs update through their package manager; backend self-update is service-install-specific; desktop updater support varies by artifact/platform. | -| Feature Toggle settings | Supported | The page exposes Office and Debug overrides, environment locks, and restart-required state. Debug is high risk because logs/endpoints can expose prompt, file, and tool data. A toggle being present does not promote its target feature to supported. | +| Feature Toggle settings | Supported | The page exposes Office, Plugins, and Debug overrides, environment locks, and restart-required state. Debug is high risk because logs/endpoints can expose prompt, file, and tool data. A toggle being present does not promote its target feature to supported. | | Office mode | In progress | Production defaults `KANDEV_FEATURES_OFFICE=false`; development and E2E enable it for implementation/testing. Its routes, agents, labels, documents, dependencies, routines, skills, routing, costs, and approvals may change between releases. | +| Plugin system | In progress | Production defaults `KANDEV_FEATURES_PLUGINS=false`; development and E2E enable it for implementation/testing. Loaded plugin code runs with backend privileges; manifest schema, capabilities, and host API may change between releases. | | Mock providers and E2E-only routes | Internal | They are selected only by development/test runtime profiles and must not be exposed as product integrations. | ## Publication boundary diff --git a/docs/public/meta.json b/docs/public/meta.json index 03116c9244..85d8e34c85 100644 --- a/docs/public/meta.json +++ b/docs/public/meta.json @@ -41,6 +41,10 @@ "extending-kandev", "add-agent-cli", "release-process", + "---Plugins---", + "plugins", + "plugins-authoring", + "plugins-manifest", "---Office---", "office-provider-routing", "---Protocol Reference---", diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md new file mode 100644 index 0000000000..4f4d33fd2e --- /dev/null +++ b/docs/public/plugins-authoring.md @@ -0,0 +1,438 @@ +--- +title: "Authoring a Plugin" +description: "Build a kandev plugin backend and optional native UI, package it, and iterate against a local kandev instance." +status: experimental +--- + +# Authoring a Plugin + +This is a build tutorial for a kandev plugin: a Go backend spawned by kandev +over gRPC, with an optional native frontend bundle. See [Plugins](plugins.md) +for the operator-facing install/enable/disable flow, and the [Plugin manifest +reference](plugins-manifest.md) for the full `manifest.yaml` schema. + +Two working example plugins accompany this guide: + +- `kandev-plugin-hello` — a nav item, native route, sidebar slot component, a + WS-driven counter, a Host-state-backed event handler, and a webhook. +- `kandev-plugin-github` — a connector PoC that shells out to the `gh` CLI + from a webhook handler used as a request relay, reads live kanban data from + the host store, and opens kandev's real create-task dialog + (`host.ui.TaskCreateDialog`) prefilled from a pull request. + +## Prerequisites + +- Go (matching the version in `apps/backend/go.mod`). +- The kandev plugin SDK, `github.com/kandev/kandev/pkg/pluginsdk`. It is + **not yet published as a standalone Go module** — it lives inside the + kandev monorepo (`apps/backend/pkg/pluginsdk`), so a plugin repo develops + against a local checkout via a `replace` directive: + + ```go + // go.mod + module my-plugin + + require github.com/kandev/kandev v0.0.0-00010101000000-000000000000 + + replace github.com/kandev/kandev => ../kandev/apps/backend + ``` + + Adjust the relative path to wherever you've checked out the kandev repo. + This is what both example plugins do; see their `go.mod` for the exact + pinned dependency versions. + +## Backend: minimal plugin + +A plugin backend implements `pluginsdk.Plugin` and calls `pluginsdk.Serve`, +which owns the entire go-plugin/gRPC transport — handshake, plugin map, and +Host injection. There is no HTTP server, no listen address, and no +credentials to configure. + +> **Language.** The backend is any native executable that speaks the +> hashicorp/go-plugin gRPC protocol (magic-cookie handshake, AutoMTLS, the +> `kandev.plugin.v1` services) — kandev just spawns the platform binary and +> talks to it over that wire. Go is the only SDK kandev ships today, so this +> guide is Go; another language means implementing the handshake, mTLS, and +> proto yourself. + +```go +// main.go +package main + +import "github.com/kandev/kandev/pkg/pluginsdk" + +func main() { + pluginsdk.Serve(&myPlugin{}) +} +``` + +```go +// plugin.go +package main + +import ( + "context" + + "github.com/kandev/kandev/pkg/pluginsdk" +) + +type myPlugin struct { + pluginsdk.UnimplementedPlugin // embed to get HostSetter + no-op defaults +} + +var _ pluginsdk.Plugin = (*myPlugin)(nil) +``` + +`pluginsdk.Plugin` is the interface kandev calls into: + +```go +type Plugin interface { + // OnEvent handles a single bus event delivery. A non-nil error causes + // kandev to retry (3 retries, 5s/15s/45s backoff). + OnEvent(ctx context.Context, e *Event) error + + // HandleWebhook handles an inbound request relayed from + // POST /api/plugins/{id}/webhooks/{key}. + HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error) +} +``` + +Embed `pluginsdk.UnimplementedPlugin` and override only the methods you need +— it's a no-op base that also implements `HostSetter`, so `Serve` injects a +live `Host` into your plugin once the broker connection back to kandev is +established (retrieve it later via `p.Host()`). + +## The Host API + +A plugin calls back into kandev through the injected `Host`: + +```go +type Host interface { + GetState(ctx context.Context, scope, scopeID, key string) (value map[string]any, found bool, err error) + SetState(ctx context.Context, scope, scopeID, key string, value map[string]any) error + DeleteState(ctx context.Context, scope, scopeID, key string) error + ListState(ctx context.Context, scope, scopeID string) ([]StateEntry, error) + + // GetConfig returns the plugin's own operator-editable config (set via + // Settings > Plugins > , generated from manifest config_schema). + // Ungated — always readable, secret values included. Kandev restarts a + // running plugin when its config changes, so re-read at startup. + GetConfig(ctx context.Context) (map[string]any, error) + + // GetSecret/SetSecret/DeleteSecret manage a plugin-owned secret in + // kandev's encrypted vault, namespaced to this plugin. Require the + // `secrets` capability. + GetSecret(ctx context.Context, key string) (value string, found bool, err error) + SetSecret(ctx context.Context, key, value string) error + DeleteSecret(ctx context.Context, key string) error + + // RevealSecret resolves an operator-provided secret reference (e.g. a + // config value pointing at a shared kandev secret) to its cleartext + // value. For secrets the plugin itself owns, use GetSecret/SetSecret. + RevealSecret(ctx context.Context, ref string) (string, error) + + EmitEvent(ctx context.Context, name string, payload map[string]any) error + + // Tasks/Sessions/Workspaces/Workflows/AgentProfiles/Repositories return + // read-only accessors for the Host data API (ADR 0043), each gated on + // its own capabilities.api_read entry (e.g. "tasks", "sessions"). + // Write RPCs (capabilities.api_write) are reserved and not yet + // implemented. + Tasks() TaskReader + Sessions() SessionReader + Workspaces() WorkspaceReader + Workflows() WorkflowReader + AgentProfiles() AgentProfileReader + Repositories() RepositoryReader +} +``` + +**Host state** is a small key/value store kandev keeps for your plugin in +its own database. Each entry is addressed by a `(scope, scopeID, key)` +triple and holds a JSON object (`map[string]any`): `SetState` upserts one, +`GetState` reads it back (`found` is `false` when the key was never set), +`DeleteState` removes it, and `ListState` returns every entry under a +`(scope, scopeID)`. Values are JSON objects, not bare scalars — wrap a +number as `map[string]any{"n": 3}`. State is namespaced per plugin (kandev +injects your plugin id server-side, so no plugin can read or write +another's), survives restarts, is captured by kandev's database backups, and +is deleted when the plugin is uninstalled. Reach for it when you want kandev +to durably remember something small and structured for you; use the writable +data directory below for arbitrary files you'd rather manage yourself. + +`scope` partitions that store by what a value belongs to: `instance` (the +whole kandev instance; `scopeID` empty), or `workspace` / `task` / `agent` +with `scopeID` set to that entity's id. A per-task counter, for example, is +`SetState(ctx, "task", taskID, "count", map[string]any{"n": 3})` — kept +separate from every other task's. + +`EmitEvent` publishes `plugin..` on kandev's internal +event bus for delivery to any subscriber (including other plugins). + +The data-reader accessors return typed, paginated readers — e.g. +`host.Tasks().List(ctx, TaskFilter{...}, Page{Limit: 50})` returns +`([]Task, *PageInfo, error)` with an opaque `PageInfo.NextCursor` for the +next page. See `pkg/pluginsdk/data_types.go` for the full `Task`, +`Workspace`, `Workflow`, `WorkflowStep`, `AgentProfile`, `Repository`, +`Session`, and filter/page types. + +**Capability gating.** Every Host RPC except `GetConfig` and `EmitEvent` is +checked against your manifest's `capabilities` before the handler runs: +`GetState`/`SetState`/`DeleteState`/`ListState` require +`capabilities.state: true`; `GetSecret`/`SetSecret`/`DeleteSecret`/ +`RevealSecret` require `capabilities.secrets: true`; each data-reader +accessor requires its resource in `capabilities.api_read` (e.g. `tasks`, +`sessions`, `workspaces`, `workflows`, `agent_profiles`, `repositories`). +Calling one without the declared capability returns gRPC `PermissionDenied` +with a message naming the missing capability — declare what you use. + +**Writable data directory.** Kandev injects `KANDEV_PLUGIN_DATA_DIR` into +every spawned plugin subprocess — a per-plugin writable directory +(`~/.kandev/plugins//data`) for anything you'd rather keep on disk than +in `Host` state. + +```go +func (p *myPlugin) OnEvent(ctx context.Context, e *pluginsdk.Event) error { + host := p.Host() + if host == nil { + return nil // broker dial still in progress + } + // Read the current count (found == false the first time), then write it back + 1. + value, _, err := host.GetState(ctx, "instance", "", "count") + if err != nil { + return err + } + n, _ := value["n"].(float64) // JSON numbers decode as float64 + return host.SetState(ctx, "instance", "", "count", map[string]any{"n": n + 1}) +} +``` + +## Optional: native UI + +A plugin may ship `ui.bundle` in its manifest: a **hand-written, no-build** +plain-JS ES module. There is no bundler step — kandev serves the file +verbatim from the extracted package directory, so you edit the bundle and +repackage. + +![A native plugin route rendered inside kandev: a sidebar nav item the plugin registered, the host's page title bar, and page content reading live task data from the shared app store.](../screenshots/plugin-native-page.png) + +The single entry point: the bundle, once evaluated, calls +`window.registerKandevPlugin(id, { initialize(registry, host), destroy?() })`. +Kandev's frontend host imports the bundle on boot (or on runtime enable), +then calls `initialize(registry, host)`. On disable/uninstall it calls +`destroy?.()` and bulk-revokes every registration the plugin made. + +**Registry surface** (`registry: PluginRegistry`, passed to `initialize`): + +```ts +interface PluginRegistry { + // Top-level SPA route, exact-match against window.location path. The host + // wraps the page in kandev's title bar by default; configure or opt out + // via options.topbar. + registerRoute(path: string, Component: React.ComponentType, options?: PluginRouteOptions): void; + // Sidebar/main nav entry, rendered by . + registerNavItem(item: NavItem): void; + // Route under /settings/plugins/{id}/..., rendered inside the settings shell. + registerSettingsRoute(path: string, Component: React.ComponentType): void; + // Named slot injection. Initial slots: "task-sidebar", "settings-nav", "main-nav-footer". + registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void; + // WS action handler, bridged into the existing lib/ws dispatch. + registerWsHandler(action: string, handler: (payload: unknown) => void): void; +} + +interface NavItem { + id: string; + label: string; + path: string; + // Curated icon name (see lib/plugins/icons.ts); unknown names render the puzzle glyph. + icon?: string; + // "main" (default): top-level sidebar entry. "integrations": renders inside + // the sidebar's Integrations section alongside first-party integration links. + // "settings" is accepted by the type but not rendered by any sidebar section + // today — for a settings page use registerSettingsRoute instead. + section?: "main" | "settings" | "integrations"; +} + +interface PluginRouteOptions { + // Kandev-style title bar above the page. Default: enabled with a derived + // title (from the matching nav item, else the plugin's display name). + // Pass a PluginPageChrome to configure it, or `false` for a full-bleed + // page that owns its own chrome (e.g. with host.ui.PageTopbar). + topbar?: boolean | PluginPageChrome; +} + +interface PluginPageChrome { + title?: string; + subtitle?: string; + icon?: string; // same curated set as NavItem.icon + backHref?: string; // default "/" + backLabel?: string; // default "Kandev" + actions?: React.ComponentType; // rendered on the right side of the topbar +} +``` + +**Host API** (`host: PluginHostApi`, passed to `initialize`): + +```ts +interface PluginHostApi { + pluginId: string; + React: typeof import("react"); // shared host React instance — MUST use this, never bundle your own React + jsx: typeof React.createElement; // convenience alias + store: { // kandev's live app store — read access is the common + getState(): AppState; // case; setState is exposed but writes affect the whole SPA + setState(partial): void; + subscribe(listener): () => void; + }; + api: { + // fetch scoped to /api/plugins/{id}/...; relayed to your webhook handler. + fetch(path: string, init?: RequestInit): Promise; + // Backend API origin ("" when the SPA and API share an origin) — lets a + // plugin reach first-party kandev REST endpoints directly. + baseUrl: string; + }; + ui: Record; // curated @kandev/ui subset — see below + theme: "light" | "dark"; + // Soft SPA navigation (history push/replace), same as the app's own router. + navigate(href: string, options?: { replace?: boolean }): void; +} +``` + +A plugin bundle must render with `host.React` / `host.jsx` — bundling your +own React copy breaks hook identity against the host tree. + +`host.ui` is a curated `@kandev/ui` subset — Alert, Badge, Button, Card, +Checkbox, Dialog, DropdownMenu, Input, Label, Pagination, ScrollArea, Select, +Separator, Sheet, Skeleton, Spinner, Switch, Table, Tabs, Textarea, Tooltip +(each with their compound sub-parts, e.g. `DialogContent`, `TableRow`) — plus +first-party app UI: `Combobox` (the app's picker), `PageTopbar` (the title +bar a route gets by default via `registerRoute`'s `options.topbar`, exposed +here for routes that opt out and render their own chrome), and +`TaskCreateDialog`, so a plugin can hand off task creation to kandev's real +create-task flow (repo/branch/agent pickers, validation) instead of POSTing +directly. See `apps/web/lib/plugins/host-api.ts` for the exact current list. + +## Three integration patterns + +Both example repos are full, working plugins — read them rather than +copy-pasting fragments: + +1. **Event handling with Host state** (`kandev-plugin-hello/server/plugin.go`). + `OnEvent` receives `task.created` deliveries (declared in + `capabilities.events`) and increments a persistent counter via + `Host.GetState`/`SetState`, scoped `instance`. Demonstrates the full + Host state round trip and idempotent-by-design event handling. + +2. **Live host-store reads from native UI** + (`kandev-plugin-hello/ui/bundle.js`, `useKanbanTasks`). A plugin page + subscribes directly to `host.store` and re-renders on every store change + — reading the exact same live state the first-party kanban UI renders + from, no polling and no extra API calls. + +3. **Webhook-as-relay + `TaskCreateDialog`** + (`kandev-plugin-github/server/plugin.go` and `ui/bundle.js`). The backend + declares a `prs` webhook and, on `HandleWebhook`, shells out to the `gh` + CLI (inheriting the plugin subprocess's own environment for GitHub auth) + and returns JSON. The UI page calls `host.api.fetch("webhooks/prs")` — + which kandev relays over gRPC `HandleWebhook` to the same handler — to use + an external CLI as a same-origin data source without a webhook secret. + Each PR row opens `host.ui.TaskCreateDialog` prefilled from the PR, + reading `workspaceId`/`workflowId`/`steps` from the live host store. + +## Packaging + +A plugin ships as `-.tar.gz`: + +``` +manifest.yaml # authoritative; read BEFORE any code runs +server/plugin--[.exe] # any subset of platforms declared in runtime.executables +ui/bundle.js # optional +ui/*.css / assets/icon.svg # optional +checksums.txt # generated — do not author this file +checksums.txt.sig # optional ed25519 signature +``` + +Cross-compile one executable per platform you declare in +`runtime.executables`, then pack the staged directory with +`github.com/kandev/kandev/cmd/plugin-pack`: + +``` +plugin-pack -dir -out -.tar.gz [-platform-only] +``` + +`plugin-pack` walks the directory, requires `manifest.yaml` to be present, +and **generates `checksums.txt`** covering every other file — pre-supplying +either checksum file yourself is a packaging error. `-platform-only` +restricts the package to the current host's platform, for faster local +iteration than a full multi-platform build. + +A sample `Makefile` (trimmed from `kandev-plugin-hello/Makefile`): + +```makefile +STAGE := .build/stage +VERSION := 1.0.0 +PKG_OUT := my-plugin-$(VERSION).tar.gz + +package: + rm -rf $(STAGE) + mkdir -p $(STAGE)/server + cp manifest.yaml $(STAGE)/manifest.yaml + cp -r ui $(STAGE)/ui + GOOS=linux GOARCH=amd64 go build -o $(STAGE)/server/plugin-linux-amd64 ./server + GOOS=linux GOARCH=arm64 go build -o $(STAGE)/server/plugin-linux-arm64 ./server + GOOS=darwin GOARCH=amd64 go build -o $(STAGE)/server/plugin-darwin-amd64 ./server + GOOS=darwin GOARCH=arm64 go build -o $(STAGE)/server/plugin-darwin-arm64 ./server + GOOS=windows GOARCH=amd64 go build -o $(STAGE)/server/plugin-windows-amd64.exe ./server + go run github.com/kandev/kandev/cmd/plugin-pack -dir $(STAGE) -out $(PKG_OUT) + rm -rf $(STAGE) + +# Faster local loop: host platform only. +package-host: + rm -rf $(STAGE) + mkdir -p $(STAGE)/server + cp manifest.yaml $(STAGE)/manifest.yaml + cp -r ui $(STAGE)/ui + go build -o $(STAGE)/server/plugin-$$(go env GOOS)-$$(go env GOARCH)$$(go env GOEXE) ./server + go run github.com/kandev/kandev/cmd/plugin-pack -dir $(STAGE) -out $(PKG_OUT) -platform-only + rm -rf $(STAGE) +``` + +Install the resulting tarball via **Settings > Plugins** (upload) or: + +```bash +curl -F "package=@my-plugin-1.0.0.tar.gz" http://localhost:38429/api/plugins/install +``` + +## Iterate loop + +- **UI-only or backend-only change:** edit the source, repackage + (`make package-host` for a fast host-only build), then reinstall. There is + no kandev rebuild needed for plugin-only changes — the extracted package + and running subprocess are entirely separate from the kandev binary. +- **`registry`/`host.ui`/host-core changes:** these live inside kandev + itself (`apps/web/lib/plugins/`), not the plugin package — changing them + requires a kandev build (`make build-web` / `make build-backend`), not a + plugin repackage. +- **Reinstalling the same version is rejected.** `pkgtar.Install` fails with + `ErrVersionExists` (HTTP 409) if `~/.kandev/plugins///` + already exists. To pick up a rebuilt package during iteration, either bump + `version` in `manifest.yaml` or uninstall the plugin first. + +## Gotchas + +- **`ui.bundle` and `ui.styles` are root-relative paths**, e.g. + `/ui/bundle.js` — not `ui/bundle.js`. Kandev serves them from + `GET /api/plugins/{id}/bundle` and `GET /api/plugins/{id}/ui/*`, resolved + against the extracted package directory. +- **Plugin pages are "unknown" SPA routes, but still get the full app + shell.** `registerRoute` paths are resolved after every static/nested + first-class route and before the kanban catch-all — a plugin can't shadow + a built-in route, but its page still renders inside the normal boot-payload-hydrated + app shell (workspaces, settings, theme), not a bare unstyled page. +- **Version bump or uninstall required to reinstall** — see "Iterate loop" + above. +- **Windows executables need the `.exe` suffix** in both the built file name + and the `runtime.executables` manifest value (e.g. + `windows-amd64: server/plugin-windows-amd64.exe`) — kandev does not append + it for you. + +Related: [Plugins](plugins.md), [Plugin manifest reference](plugins-manifest.md). diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md new file mode 100644 index 0000000000..f8204f38a6 --- /dev/null +++ b/docs/public/plugins-manifest.md @@ -0,0 +1,224 @@ +--- +title: "Plugin Manifest Reference" +description: "Complete field-by-field reference for a kandev plugin's manifest.yaml, including the full event-subscription vocabulary." +status: experimental +--- + +# Plugin Manifest Reference + +`manifest.yaml` is the authoritative description of a plugin — identity, +runtime executables, capabilities, webhooks, config schema, and +optional UI bundle. Kandev parses and validates it **before any plugin code +runs**. See [Authoring a plugin](plugins-authoring.md) for the build +workflow and [Plugins](plugins.md) for install/operate. + +## Annotated example + +```yaml +id: "kandev-plugin-slack" # ^[a-z0-9][a-z0-9._-]*$ +api_version: 1 # must be 1 (the only supported value) +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 # only supported value today + executables: + linux-amd64: server/plugin-linux-amd64 + linux-arm64: server/plugin-linux-arm64 + darwin-amd64: server/plugin-darwin-amd64 + darwin-arm64: server/plugin-darwin-arm64 + windows-amd64: server/plugin-windows-amd64.exe + # any subset; the host's - key + # is required at install time +min_kandev_version: "0.78.0" # optional + +capabilities: + events: ["task.created", "task.state_changed", "agent.completed"] + api_read: ["tasks", "agent_profiles"] # gates the Host data-reader RPCs + api_write: ["tasks"] # reserved, no Host RPC enforces this yet + state: true + secrets: true + +webhooks: + - key: "slack-events" + description: "Slack Events API webhook" + method: "POST" # informational only, not enforced + +config_schema: + type: object + properties: + bot_token: { type: string, secret: true, title: "Bot Token", description: "Slack bot OAuth token" } + default_channel: { type: string, description: "Default channel for notifications" } + notify_on_task_created: { type: boolean, default: true } + required: ["bot_token", "default_channel"] + +ui: # optional native frontend plugin + bundle: "/ui/bundle.js" # root-relative + styles: ["/ui/plugin.css"] # optional, root-relative +``` + +## Field reference + +| Field | Required | Type | Notes | +|---|---|---|---| +| `id` | yes | string | Must match `^[a-z0-9][a-z0-9._-]*$` (lowercase alphanumeric, dots, underscores, hyphens; must start with a lowercase alphanumeric). Directory name under `~/.kandev/plugins/`. | +| `api_version` | yes | int | Must be exactly `1`. Any other value is rejected. | +| `version` | yes | string | Free-form; used as the version directory name (`~/.kandev/plugins///`). | +| `display_name` | no | string | Shown in Settings > Plugins. | +| `description` | no | string | Shown in Settings > Plugins. | +| `author` | no | string | Free-form. | +| `categories` | no | string[] | Each entry must be one of `connector`, `automation`, `tools`, `analytics`. Unknown values are rejected. | +| `runtime.type` | conditionally | string | `"binary"` is the only supported value. Setting it (vs. leaving it empty) makes the manifest **runtime-managed** — see "Managed vs. legacy" below. | +| `runtime.executables` | required when `runtime.type: binary` | map\ | Key is `-` (e.g. `linux-amd64`, `darwin-arm64`, `windows-amd64`); value is a clean, package-relative path under `server/` (no leading `/`, no `..` segments). At least one entry required; the running host's key must be present at install time. Windows values end in `.exe`. | +| `min_kandev_version` | no | string | Optional advisory; not currently enforced by the installer. | +| `capabilities.events` | no | string[] | Bus subjects (or wildcard patterns) this plugin subscribes to. See "Event subscription vocabulary" below. | +| `capabilities.api_read` | no | string[] | Gates the Host data API's read-only accessors. Each entry is a resource name: `tasks`, `sessions`, `workspaces`, `workflows`, `agent_profiles`, `repositories`. Calling the matching `Host` accessor (e.g. `Tasks()`) without its resource declared returns gRPC `PermissionDenied`. See "Host data API resource vocabulary" below. | +| `capabilities.api_write` | no | string[] | **Reserved for future Host RPCs.** Declared but not enforced by anything today — no Host RPC currently writes kandev's own data. | +| `capabilities.state` | no | bool | Gates `Host.GetState`/`SetState`/`DeleteState`/`ListState`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. | +| `capabilities.secrets` | no | bool | Gates `Host.RevealSecret`/`GetSecret`/`SetSecret`/`DeleteSecret`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. | +| `webhooks[].key` | yes | string | Must be unique within the manifest. Used in the relay path `POST /api/plugins/{id}/webhooks/{key}`. | +| `webhooks[].description` | no | string | Free-form. | +| `webhooks[].method` | no | string | **Informational only** — kandev does not validate or enforce the inbound HTTP method against this value. | +| `config_schema` | no | object | JSON-Schema-like object driving the settings form at **Settings > Plugins > ``** (`GET /api/plugins/{id}/config` and `PATCH /api/plugins/{id}`). See "Config schema validation and secret fields" below. | +| `ui.bundle` | no | string | Root-relative path (must start with `/`, e.g. `/ui/bundle.js`) to the plugin's native UI ES module, served at `GET /api/plugins/{id}/bundle`. | +| `ui.styles` | no | string[] | Root-relative CSS paths (each must start with `/`), served at `GET /api/plugins/{id}/ui/*` and injected as `` tags on load. | +| `ui.pages` | no | object[] | Optional declarative page metadata. Secondary to `ui.bundle` — a native bundle registers its own routes/nav at runtime, so most plugins omit `ui.pages`. | +| `ui.pages[].key` | yes* | string | Stable identifier for the page (*required when a page entry is present). | +| `ui.pages[].title` | yes* | string | Display title. | +| `ui.pages[].path` | yes* | string | Route path for the page. | +| `ui.pages[].surface` | yes* | string | Where the page mounts. Enum, one of: `settings` · `task-panel` · `main-nav`. Any other value is a validation error. | + +`ui.pages` is declarative manifest metadata only. A native bundle's runtime +nav items, icons, and per-route title-bar chrome (`registerNavItem`, +`registerRoute`'s `options.topbar`) are a separate JS SDK surface with no +`manifest.yaml` field — see [Authoring a plugin](plugins-authoring.md). + +## Managed vs. legacy manifests + +Setting `runtime.type: binary` makes a manifest **runtime-managed**: kandev +spawns and supervises the declared executable itself. A managed manifest +must **not** set `base_url` or an `endpoints` block (`health`/`events`/ +`webhooks` paths on a remote service) — those describe the old +remote/operator-hosted tier, and validation rejects a managed manifest that +sets them. + +A manifest with an empty `runtime.type` still *parses* as a legacy remote +manifest (with `base_url`/`endpoints` instead) and passes +`manifest.Validate()` on its own — but the **installer** rejects it: `pkgtar +.Install` requires `manifest.IsManaged()` to be true (`runtime.type: +binary`), so a legacy manifest can never actually be installed via `POST +/api/plugins/install` or a filesystem sideload. The remote tier is +effectively removed in practice, even though the manifest schema still +recognizes its shape. + +## Host data API resource vocabulary + +`capabilities.api_read` gates the read-only Host data accessors (ADR 0043): +each entry must be one of `tasks`, `sessions`, `workspaces`, `workflows`, +`agent_profiles`, `repositories`. Declaring a resource grants the matching +`Host` accessor (`Tasks()`, `Sessions()`, `Workspaces()`, `Workflows()`, +`AgentProfiles()`, `Repositories()` — see [Authoring a +plugin](plugins-authoring.md)); calling an accessor for an undeclared +resource returns gRPC `PermissionDenied`. `capabilities.api_write` reserves +the same resource names for a future write path — no write RPC exists yet, +so declaring it currently has no effect. + +## Config schema validation and secret fields + +`config_schema` is not an arbitrary, purely descriptive JSON Schema — kandev +validates submitted config against a specific subset of it before +persisting: + +- `required` (an array of property names) is enforced — a `PATCH` missing a + required property is rejected. +- `type` (`string`, `boolean`, `number`, or `integer`) is checked against + the submitted value. +- `enum` membership is checked when present. +- A property with `secret: true`, or `format: "password"`, is treated as a + **secret field** and must be `type: string` (or untyped) — a non-string + secret is rejected. Secret values are moved into kandev's encrypted vault; + `GET /api/plugins/{id}/config` returns the literal mask `"********"` in + their place, and resubmitting that mask unchanged is treated as "keep the + stored value" rather than overwriting it with the literal string. +- `title` is read by the settings-page renderer as a display label + override for the property (falling back to the property name); it has no + backend validation effect. + +The plugin process itself always sees real, unmasked values (secrets +included) via the `GetConfig` Host RPC — masking only applies to the +operator-facing API/UI. + +## Event subscription vocabulary + +`capabilities.events` entries are bus subjects (e.g. `task.created`) or +wildcard patterns using `*` as a **single dot-segment** wildcard (e.g. +`task.*`, `agent.*`, `github.*`). A pattern segment of `*` matches exactly +one subject segment; every other segment must match literally; and the +**pattern and subject must have the same number of dot-separated segments** +to match at all. `task.*` matches `task.created` (2 segments each) but does +**not** match a three-segment subject such as `shell.output.` +(`shell.*` would not match it either — 2 vs. 3 segments). + +Any subject kandev publishes on its internal event bus is a valid +subscription target — this is not a closed list scoped to any one feature +area. The table below groups every subject defined in +`internal/events/types.go` by domain (some are further suffixed per-session, +e.g. `shell.output.`, `git.event.` — subscribe to the +literal wildcard segment count that matches, e.g. `shell.output.*`). + +| Domain | Events | +|---|---| +| Tasks | `task.created`, `task.updated`, `task.state_changed`, `task.deleted`, `task.moved`, `task.tree_hold_created`, `task.tree_hold_released` | +| Workspaces | `workspace.created`, `workspace.updated`, `workspace.deleted` | +| Workflows | `workflow.created`, `workflow.updated`, `workflow.deleted` | +| Workflow steps | `workflow_step.created`, `workflow_step.updated`, `workflow_step.deleted`, `workflow.step_completion_signaled` | +| Comments / messages | `message.added`, `message.updated`, `message.deleted`, `message.queue.status_changed` | +| Task sessions | `task_session.state_changed` | +| Task plans | `task_plan.created`, `task_plan.updated`, `task_plan.deleted`, `task_plan.revision.created`, `task_plan.reverted` | +| Task walkthroughs | `task_walkthrough.created`, `task_walkthrough.updated`, `task_walkthrough.deleted` | +| Session turns | `turn.started`, `turn.completed` | +| Repositories | `repository.created`, `repository.updated`, `repository.deleted`, `repository.script.created`, `repository.script.updated`, `repository.script.deleted` | +| Executors | `executor.created`, `executor.updated`, `executor.deleted`, `executor.profile.created`, `executor.profile.updated`, `executor.profile.deleted`, `executor.prepare.progress`, `executor.prepare.completed` | +| Users | `user.settings.updated` | +| System | `system.job.update` | +| Environments | `environment.created`, `environment.updated`, `environment.deleted` | +| Agent profiles | `agent_profile.created`, `agent_profile.updated`, `agent_profile.deleted` | +| Agents | `agent.started`, `agent.running`, `agent.boot_ready`, `agent.ready`, `agent.completed`, `agent.failed`, `agent.stopped`, `agent.context_reset`, `agent.acp_session_created`, `agentctl.starting`, `agentctl.ready`, `agentctl.error` | +| Agent stream | `agent.stream` (per-session: `agent.stream.`), `agent.turn.message_saved` | +| Agent prompts | `permission_request.received` (per-session: `permission_request.received.`) | +| Clarification | `clarification.answered`, `clarification.primary_answered`, `clarification.cancelled`, `clarification.stale_dismissed` | +| Git / workspace status | `git.event` (per-session), `git.ws` (per-session), `file.change.notified` (per-session) | +| Shell I/O | `shell.output` (per-session), `shell.exit` (per-session) | +| Dev server I/O | `process.output` (per-session), `process.status` (per-session) | +| Session context | `context_window.updated` (per-session), `available_commands.updated` (per-session), `session_mode.changed` (per-session), `agent_capabilities.updated` (per-session), `session_models.updated` (per-session), `session_info.updated` (per-session), `session_todos.updated` (per-session), `session_prompt_usage.updated` (per-session) | +| Automations | `automation.triggered`, `automation.run.created` | +| GitHub | `github.pr_feedback`, `github.pr_state_changed`, `github.new_pr_to_review`, `github.new_issue`, `github.task_pr.updated`, `github.task_ci_options.updated`, `github.watch.event`, `github.rate_limit.updated` | +| GitLab | `gitlab.mr_feedback`, `gitlab.mr_state_changed`, `gitlab.new_mr_to_review`, `gitlab.new_issue`, `gitlab.task_mr.updated`, `gitlab.watch.event` | +| Jira | `jira.new_issue` | +| Linear | `linear.new_issue` | +| Sentry | `sentry.new_issue` | +| Office (autonomous agents) | `office.agent.created`, `office.agent.updated`, `office.agent.status_changed`, `office.skill.created`, `office.skill.updated`, `office.project.created`, `office.project.updated`, `office.approval.created`, `office.approval.resolved`, `office.comment.created`, `office.cost.recorded`, `office.run.queued`, `office.run.processed`, `office.run.event_appended` (per-run), `office.routine.triggered`, `office.inbox.item`, `office.task.status_changed`, `office.task.updated`, `office.task.decision_recorded`, `office.task.review_requested`, `office.provider.health_changed`, `office.route_attempt.appended`, `office.routing.settings_updated` | +| Cross-plugin | `plugin..` — published by `Host.EmitEvent`; subscribe with `plugin..*` to react to another plugin's events. | + +> Event list current as of 2026-07-16; regenerate from +> `apps/backend/internal/events/types.go` if this page drifts from the code. + +## Runtime-managed fields (do not author these) + +Once installed, kandev writes several fields onto the stored record +alongside the parsed manifest. These are **kandev-owned runtime state, not +author-supplied manifest fields** — do not include them in `manifest.yaml`; +they have no effect there and are overwritten on install: + +| Field | Meaning | +|---|---| +| `status` | Current lifecycle state: `registered`, `active`, `error`, `disabled`, or `uninstalled`. | +| `install_path` | Absolute path the package was extracted to (`~/.kandev/plugins///`). | +| `signed` | Whether the package's `checksums.txt.sig` was cryptographically verified. Signature verification is not currently wired, so this is always false today (every package is reported unsigned). | +| `installed_at` | Install timestamp. | +| `restart_count` | Best-effort restart bookkeeping used by the supervision loop. | + +Related: [Plugins](plugins.md), [Authoring a plugin](plugins-authoring.md). diff --git a/docs/public/plugins.md b/docs/public/plugins.md new file mode 100644 index 0000000000..fb7d10a7aa --- /dev/null +++ b/docs/public/plugins.md @@ -0,0 +1,193 @@ +--- +title: "Plugins" +description: "Install and manage kandev plugins: Go backends kandev spawns and supervises, with an optional native frontend bundle." +status: experimental +--- + +# Plugins + +Plugins extend kandev without forking core: a plugin ships a **Go backend** +that kandev spawns and supervises as a subprocess over a strict typed gRPC +protocol, and can optionally ship a **native frontend bundle** that kandev +loads into the SPA. This page covers what plugins are, how to install and +manage them, and the current security posture. For building a plugin, see +[Authoring a plugin](plugins-authoring.md). For the manifest schema, see +[Plugin manifest reference](plugins-manifest.md). + +Plugins are an operator-level, instance-wide capability — there is no +per-user plugin access. They are gated behind the `plugins` feature flag +(**Settings > System > Feature Toggles**), off by default in production +builds and on by default in dev/e2e builds. Enabling the flag requires a +backend restart and surfaces **Settings > Plugins** in the sidebar. + +## How it works + +![Plugin lifecycle: install, verify, extract, and spawn a go-plugin gRPC subprocess; then, over one supervised gRPC connection, kandev delivers bus events and relays external webhooks to the plugin, the plugin calls back into the Host API, and the SPA optionally loads the native UI bundle.](../screenshots/plugin-architecture.png) + +Kandev owns the whole process lifecycle: it extracts the package, spawns the +binary, completes the go-plugin handshake, health-checks it (`Ping` every +30s), and restarts it on crash or repeated health-check failure (backoff, +max 5 attempts). There is no separate operator-managed plugin process to run +or babysit — install a package and kandev does the rest. + +A native UI bundle can register a nav item that renders as a top-level +sidebar entry or, when it declares itself part of the Integrations section, +alongside kandev's first-party integration links in the main sidebar's +**Integrations** section — expect new entries to appear there once such a +plugin is installed and active. + +## Installing a plugin + +Open **Settings > Plugins** and click **Install plugin**. You can install +from a URL (kandev downloads the tarball) or by uploading a `.tar.gz` file +directly. No credentials are ever shown or copied — installing a plugin has +nothing to reveal, unlike a webhook-secret/API-key registration flow. + +![The Install plugin dialog with From URL and Upload file tabs and a drag-and-drop area for a .tar.gz package.](../screenshots/plugin-install-dialog.png) + +The same operations are available over HTTP: + +```bash +# Install from a URL +curl -X POST http://localhost:38429/api/plugins/install \ + -H 'Content-Type: application/json' \ + -d '{"url": "https://example.com/acme-tools-1.0.0.tar.gz"}' + +# Install by uploading a local tarball +curl -X POST http://localhost:38429/api/plugins/install \ + -F "package=@acme-tools-1.0.0.tar.gz" +``` + +Either path runs the same pipeline: + +1. Verify `checksums.txt` covers every other file in the tarball and every + hash matches (always enforced). +2. Check for `checksums.txt.sig`. Signature verification is not currently + wired up, so every package — signed or not — installs and is reported + as unsigned today (see "Signed vs. unsigned packages" below). +3. Parse and validate `manifest.yaml` **before any code runs**: schema, `id` + pattern, the `categories` and UI-surface enums, and that + `runtime.executables` contains an entry for the host's OS/arch. +4. Extract to `~/.kandev/plugins///` and record the + installation. +5. Spawn the platform-matched binary and complete the go-plugin handshake. + Status is `registered` while this is pending, `active` once the + handshake succeeds, or `error` if spawn/handshake fails (the operator can + retry via **Enable**). + +A successful install that failed to spawn returns HTTP 201 with a +`warning` field rather than failing outright — the package is installed, +just not yet running. + +Once installed, the plugin appears in the list with its category, a status +badge (`active`), a signing badge (`unsigned` today), and **Disable** and +**Uninstall** actions: + +![The Settings > Plugins page listing an installed, active plugin with its category, an unsigned badge, and Disable/Uninstall actions.](../screenshots/plugin-settings-list.png) + +## Filesystem sideload and Sync + +Besides install-by-URL/upload, an operator with shell access to the host can +place plugin content directly under `~/.kandev/plugins/` without going +through the install endpoint. The **Sync** button in Settings > Plugins (and +`POST /api/plugins/sync`) reconciles kandev's registry with what is actually +on disk: + +- **A dropped directory** — `~/.kandev/plugins///manifest.yaml` + placed manually with no existing record — is validated and registered + with status **`disabled`**, never auto-enabled. Directory sideloads skip + the checksum/integrity gate the URL/upload pipeline runs, so an operator + must explicitly inspect and enable one. 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. +- **A dropped tarball** — any `*.tar.gz` sitting directly in + `~/.kandev/plugins/` — is run through the same verified install pipeline + `POST /api/plugins/install` uses. On success the tarball file is deleted; + on failure it is left in place and the failure is reported. +- **A missing install** — a registered record whose `install_path` no + longer exists on disk — is stopped (if running) and marked `error`. + +At boot, kandev runs only the directory-sideload and missing-install steps +(never the tarball-install step), as part of resuming plugins that were +already active. This is conservative by design: starting up never spawns a +binary an operator hasn't explicitly approved via install or Sync. + +## Enable, disable, uninstall + +- **Disable** stops the subprocess. Config and state are preserved; no + events or webhooks are delivered while disabled. +- **Enable** respawns the subprocess and re-completes the handshake. +- **Uninstall** stops the subprocess and deletes the plugin's package, + registration record, and all persisted state — there is no grace period. + +## Per-plugin settings + +A plugin can declare `config_schema` in its manifest, generating a settings +form at **Settings > Plugins > ``** (also `GET /api/plugins/{id}/config` +and `PATCH /api/plugins/{id}`). Fields marked `secret: true` or +`format: "password"` (for example a GitHub PAT) are never returned in +cleartext to the operator UI — reads show a masked placeholder, and +resubmitting the form unchanged leaves the stored secret alone. The plugin +process itself receives the real values via the `GetConfig` Host RPC. +Saving config **restarts the running plugin** so it re-reads its config. +`.config.yml` on disk is written with mode `0600` and may hold vault +references rather than cleartext for secret fields. + +![A plugin's settings page: a schema-driven form with a masked API-token field, a toggle, and a text field, above a Manifest card showing the plugin's id, version, signing status, and capabilities.](../screenshots/plugin-settings-page.png) + +## Signed vs. unsigned packages + +Every package's `checksums.txt` is verified at install time — this integrity +gate is always enforced. Signing (`checksums.txt.sig`, an ed25519 signature +over `checksums.txt`) is a separate, optional layer, and its verification +hook is not currently wired up in the shipped product: no signature is +cryptographically checked today, so every install — signed tarball or not — +is currently treated and reported as unsigned. A signed package installs +identically to an unsigned one; signing is not required in v1. + +## On-disk layout + +``` +~/.kandev/plugins/ +├── .yml # registration record (status, install_path, signed, ...) +├── .config.yml # operator-editable config (PATCH /api/plugins/{id}) +└── / + ├── / # extracted package (InstallPath) + │ ├── manifest.yaml + │ ├── server/plugin--[.exe] + │ └── ui/bundle.js # optional + └── data/ # KANDEV_PLUGIN_DATA_DIR — shared across versions +``` + +## Security posture + +- **Auth is the spawn relationship.** Kandev spawns the plugin subprocess + itself over a unix domain socket (macOS/Linux) or loopback TCP (Windows) — + never a routable network address — and secures it in both cases with the + go-plugin handshake plus AutoMTLS. There is no `api_key`, `webhook_secret`, + or HMAC signing anywhere in the contract. +- **Capability-based access control.** A plugin can only call the Host RPCs + it declared in its manifest: `state` gates the state RPCs, `secrets` gates + the plugin-owned secret RPCs, and each read-only data accessor (tasks, + sessions, workspaces, workflows, agent profiles, repositories) is gated + individually via `api_read:` (write access, `api_write:`, + is reserved but not yet implemented). An undeclared capability returns gRPC + `PermissionDenied` with a message naming the missing capability, checked by + a server interceptor before the handler runs. `GetConfig` and `EmitEvent` + are the only ungated RPCs — a plugin can always read its own config + (secrets included) and emit events. +- **Native UI plugins run in-origin with full app-store access.** This is an + accepted tradeoff, not an oversight: a plugin bundle shares the kandev + React instance and Zustand store so it can build UI indistinguishable from + first-party pages. In v1 only **active, operator-installed** plugins load; + a failing bundle or `initialize` is caught and never breaks boot; slot + components render behind error boundaries. Hard sandboxing (a worker or + realm boundary) is explicit future work — see below. +- **Package integrity is always checked; signing is optional.** See + "Signed vs. unsigned packages" above. +- **No plugin marketplace.** Install-by-URL/upload is a manual, single-plugin + action; there is no central catalog, one-click install, or automatic + discovery. + +Related: [Authoring a plugin](plugins-authoring.md), [Plugin manifest +reference](plugins-manifest.md), and [Extending Kandev](extending-kandev.md). diff --git a/docs/screenshots/plugin-architecture.png b/docs/screenshots/plugin-architecture.png new file mode 100644 index 0000000000..8223378629 Binary files /dev/null and b/docs/screenshots/plugin-architecture.png differ diff --git a/docs/screenshots/plugin-install-dialog.png b/docs/screenshots/plugin-install-dialog.png new file mode 100644 index 0000000000..3ca4dd816a Binary files /dev/null and b/docs/screenshots/plugin-install-dialog.png differ diff --git a/docs/screenshots/plugin-native-page.png b/docs/screenshots/plugin-native-page.png new file mode 100644 index 0000000000..989d806394 Binary files /dev/null and b/docs/screenshots/plugin-native-page.png differ diff --git a/docs/screenshots/plugin-settings-list.png b/docs/screenshots/plugin-settings-list.png new file mode 100644 index 0000000000..ca1f41b0fb Binary files /dev/null and b/docs/screenshots/plugin-settings-list.png differ diff --git a/docs/screenshots/plugin-settings-page.png b/docs/screenshots/plugin-settings-page.png new file mode 100644 index 0000000000..3a289c47fc Binary files /dev/null and b/docs/screenshots/plugin-settings-page.png differ