Files
kandev/apps/backend/pkg/pluginsdk/serve.go
T
João Salavisa e145d48f22 feat: add plugin system with grpc runtime and native ui plugins (#1742)
* 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>
2026-07-17 12:00:26 +01:00

278 lines
9.6 KiB
Go

// serve.go wires plugin authors (via Serve) and kandev's runtime manager
// (via the exported GRPCPlugin type) onto hashicorp/go-plugin, per §2/§3 of
// docs/plans/plugins/GRPC-CONTRACT.md.
//
// # Host injection
//
// The frozen Plugin service (§3) has no RPC for negotiating a broker ID, so
// the two sides can't use go-plugin's usual "allocate an ID and pass it in
// the request" bidirectional pattern for the Host service. Instead they
// agree on a constant, hostBrokerID:
//
// - kandev (host side, GRPCPlugin.GRPCClient — called once when kandev
// dispenses the plugin) starts serving its own Host implementation on
// hostBrokerID via broker.AcceptAndServe.
// - The plugin subprocess (GRPCPlugin.GRPCServer — called once at
// subprocess startup, before the process has even accepted a
// connection from kandev) cannot dial hostBrokerID synchronously: the
// broker's control stream to kandev doesn't exist yet, so a blocking
// Dial here would deadlock startup. It instead spawns a background
// goroutine that retries broker.Dial(hostBrokerID) with backoff until
// it succeeds or HostDialTimeout elapses, then injects the resulting
// Host into Impl via the optional HostSetter interface —
// UnimplementedPlugin implements HostSetter, so embedding it is enough
// to opt in. A Plugin that doesn't implement HostSetter simply never
// receives a Host (fine for plugins that don't need one).
//
// GRPCPlugin is exported specifically so kandev's runtime manager can reuse
// it on the host side with plugin.NewClient — see the GRPCPlugin doc
// comment for the exact wiring.
package pluginsdk
import (
"context"
"fmt"
"time"
hcplugin "github.com/hashicorp/go-plugin"
pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1"
"google.golang.org/grpc"
)
// Handshake is the go-plugin handshake shared by kandev and every plugin
// backend (§2 of docs/plans/plugins/GRPC-CONTRACT.md). Both sides must use
// this exact value — Serve uses it automatically; kandev's runtime manager
// must set it on plugin.ClientConfig.HandshakeConfig.
var Handshake = hcplugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "KANDEV_PLUGIN",
MagicCookieValue: "kandev-plugin-v1",
}
// PluginMapKey is the go-plugin plugin-map key both Serve (plugin side) and
// kandev's runtime manager (host side, plugin.ClientConfig.Plugins) use.
const PluginMapKey = "plugin"
// hostBrokerID is the well-known go-plugin broker stream ID used for the
// Host service. See the "Host injection" section in the file header.
const hostBrokerID = 1
// defaultHostDialTimeout bounds how long the plugin subprocess retries
// dialing hostBrokerID before giving up and leaving Host unset.
const defaultHostDialTimeout = 30 * time.Second
// GRPCPlugin adapts a Plugin (subprocess side) and/or a Host (kandev side)
// to hashicorp/go-plugin's plugin.GRPCPlugin interface. It is the shared
// type both sides use:
//
// - Plugin authors never construct it directly; Serve does so internally.
//
// - kandev's runtime manager constructs it directly:
//
// client := plugin.NewClient(&plugin.ClientConfig{
// HandshakeConfig: pluginsdk.Handshake,
// Plugins: map[string]plugin.Plugin{pluginsdk.PluginMapKey: &pluginsdk.GRPCPlugin{Host: myHostImpl}},
// AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
// AutoMTLS: true,
// Cmd: exec.Command(pluginBinaryPath),
// })
// rpcClient, _ := client.Client()
// raw, _ := rpcClient.Dispense(pluginsdk.PluginMapKey)
// remote := raw.(*pluginsdk.RemotePlugin)
//
// See the file header for how Host injection works across the two sides.
type GRPCPlugin struct {
hcplugin.NetRPCUnsupportedPlugin
// Impl is the plugin author's implementation. Set by Serve on the
// plugin subprocess side.
Impl Plugin
// Host is kandev's Go-native Host implementation. Set by kandev's
// runtime manager on the host side.
Host Host
// HostDialTimeout overrides defaultHostDialTimeout. Zero means use the
// default; mainly useful in tests.
HostDialTimeout time.Duration
}
var _ hcplugin.GRPCPlugin = (*GRPCPlugin)(nil)
// GRPCServer is called once inside the plugin subprocess (by plugin.Serve,
// via Serve) to register the Plugin service and — if Impl implements
// HostSetter — kick off the background Host dial described in the file
// header.
func (p *GRPCPlugin) GRPCServer(broker *hcplugin.GRPCBroker, s *grpc.Server) error {
pluginv1.RegisterPluginServer(s, &grpcPluginServer{impl: p.Impl})
setter, ok := p.Impl.(HostSetter)
if !ok {
return nil
}
timeout := p.HostDialTimeout
if timeout <= 0 {
timeout = defaultHostDialTimeout
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
conn, err := dialBrokerWithRetry(ctx, broker, hostBrokerID)
if err != nil {
return
}
setter.SetHost(newHostClient(conn))
}()
return nil
}
// GRPCClient is called once inside kandev (by plugin.NewClient's Dispense)
// to build the RemotePlugin used to call the plugin, and — if Host is set —
// to start serving it on hostBrokerID so the plugin subprocess can call
// back. See the file header.
func (p *GRPCPlugin) GRPCClient(_ context.Context, broker *hcplugin.GRPCBroker, conn *grpc.ClientConn) (interface{}, error) {
if p.Host != nil {
go broker.AcceptAndServe(hostBrokerID, func(opts []grpc.ServerOption) *grpc.Server {
s := grpc.NewServer(opts...)
registerHostServer(s, p.Host)
return s
})
}
return &RemotePlugin{client: pluginv1.NewPluginClient(conn)}, nil
}
// dialBrokerWithRetry retries broker.Dial(id) with backoff until it
// succeeds or ctx is done. A single Dial attempt can legitimately fail
// while the peer hasn't called AcceptAndServe(id, ...) yet (see the file
// header); retrying absorbs that startup race.
func dialBrokerWithRetry(ctx context.Context, broker *hcplugin.GRPCBroker, id uint32) (*grpc.ClientConn, error) {
backoff := 100 * time.Millisecond
const maxBackoff = 2 * time.Second
for {
conn, err := broker.Dial(id)
if err == nil {
return conn, nil
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("pluginsdk: dial broker id %d: %w", id, ctx.Err())
case <-time.After(backoff):
}
if backoff < maxBackoff {
backoff *= 2
}
}
}
// RemotePlugin is the Go-native client kandev's runtime manager uses to
// call a running plugin, returned by Dispense(pluginsdk.PluginMapKey).
type RemotePlugin struct {
client pluginv1.PluginClient
}
// DeliverEvent calls the plugin's DeliverEvent RPC. Timeout/retry policy
// (§5: 10s timeout, 3 retries) is the caller's responsibility — this method
// is a thin, context-respecting proxy.
func (r *RemotePlugin) DeliverEvent(ctx context.Context, e *Event) error {
proto, err := e.toProto()
if err != nil {
return err
}
_, err = r.client.DeliverEvent(ctx, proto)
return err
}
// InvokeTool calls the plugin's InvokeTool RPC.
func (r *RemotePlugin) InvokeTool(ctx context.Context, req *ToolRequest) (*ToolResponse, error) {
proto, err := req.toProto()
if err != nil {
return nil, err
}
resp, err := r.client.InvokeTool(ctx, proto)
if err != nil {
return nil, err
}
return toolResponseFromProto(resp)
}
// HandleWebhook calls the plugin's HandleWebhook RPC.
func (r *RemotePlugin) HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error) {
resp, err := r.client.HandleWebhook(ctx, req.toProto())
if err != nil {
return nil, err
}
return webhookResponseFromProto(resp), nil
}
// grpcPluginServer adapts the author-facing Plugin interface to the
// generated pluginv1.PluginServer interface. Registered inside the plugin
// subprocess by GRPCPlugin.GRPCServer.
type grpcPluginServer struct {
pluginv1.UnimplementedPluginServer
impl Plugin
}
func (s *grpcPluginServer) DeliverEvent(ctx context.Context, req *pluginv1.Event) (*pluginv1.EventAck, error) {
e, err := eventFromProto(req)
if err != nil {
return nil, err
}
if err := s.impl.OnEvent(ctx, e); err != nil {
return nil, err
}
return &pluginv1.EventAck{}, nil
}
func (s *grpcPluginServer) InvokeTool(ctx context.Context, req *pluginv1.ToolRequest) (*pluginv1.ToolResponse, error) {
toolReq, err := toolRequestFromProto(req)
if err != nil {
return nil, err
}
resp, err := s.impl.InvokeTool(ctx, toolReq)
if err != nil {
return nil, err
}
return resp.toProto()
}
func (s *grpcPluginServer) HandleWebhook(ctx context.Context, req *pluginv1.WebhookRequest) (*pluginv1.WebhookResponse, error) {
resp, err := s.impl.HandleWebhook(ctx, webhookRequestFromProto(req))
if err != nil {
return nil, err
}
return resp.toProto(), nil
}
var _ pluginv1.PluginServer = (*grpcPluginServer)(nil)
// Option configures Serve.
type Option func(*serveConfig)
type serveConfig struct {
hostDialTimeout time.Duration
}
// WithHostDialTimeout overrides how long Serve retries dialing kandev's
// Host broker connection before giving up (default 30s). Mainly useful in
// tests that want a tighter bound than production.
func WithHostDialTimeout(d time.Duration) Option {
return func(c *serveConfig) { c.hostDialTimeout = d }
}
// Serve wires p up as a kandev plugin backend and blocks until the process
// is terminated by kandev. It owns all go-plugin/grpc plumbing: the
// handshake (Handshake), the plugin map (PluginMapKey), and the Host broker
// dial + injection described in the file header.
func Serve(p Plugin, opts ...Option) {
cfg := &serveConfig{hostDialTimeout: defaultHostDialTimeout}
for _, opt := range opts {
opt(cfg)
}
gp := &GRPCPlugin{Impl: p, HostDialTimeout: cfg.hostDialTimeout}
hcplugin.Serve(&hcplugin.ServeConfig{
HandshakeConfig: Handshake,
Plugins: map[string]hcplugin.Plugin{PluginMapKey: gp},
GRPCServer: hcplugin.DefaultGRPCServer,
})
}