Files
kandev/apps/backend/internal/plugins/service_install_test.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

322 lines
11 KiB
Go

package plugins
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
goruntime "runtime"
"sync"
"testing"
"github.com/kandev/kandev/internal/plugins/pkgtar"
"github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest"
"github.com/kandev/kandev/internal/plugins/store"
)
func TestServiceInstallActivatesOnSuccessfulSpawn(t *testing.T) {
svc, _, rt := newTestService(t)
rec := installTestPlugin(t, svc, "kandev-plugin-slack")
if rec.Status != StatusActive {
t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive)
}
if !rt.Running("kandev-plugin-slack") {
t.Fatal("Install() did not spawn the plugin via the runtime manager")
}
got, err := svc.Get("kandev-plugin-slack")
if err != nil {
t.Fatalf("Get() unexpected error: %v", err)
}
if got.InstallPath == "" {
t.Fatal("Get().InstallPath is empty after Install()")
}
}
func TestServiceInstallDuplicateVersionReturnsErrVersionExists(t *testing.T) {
svc, _, _ := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
_, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-slack", "1.0.0", false))
if !errors.Is(err, pkgtar.ErrVersionExists) {
t.Fatalf("Install() duplicate error = %v, want pkgtar.ErrVersionExists", err)
}
}
func TestServiceInstallSpawnFailureLeavesRecordInErrorStatus(t *testing.T) {
svc, fsStore, rt := newTestService(t)
rt.setStartErr("kandev-plugin-slack", errors.New("spawn failed"))
rec, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-slack", "1.0.0", false))
if err == nil {
t.Fatal("Install() expected an error when the spawn fails")
}
if rec == nil {
t.Fatal("Install() expected a non-nil record even when the spawn fails (package was extracted)")
}
if rec.Status != StatusError {
t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusError)
}
onDisk, getErr := fsStore.Get("kandev-plugin-slack")
if getErr != nil {
t.Fatalf("store.Get(): %v", getErr)
}
if onDisk.Status != StatusError {
t.Fatalf("store.Get().Status = %q, want %q", onDisk.Status, StatusError)
}
}
// TestServiceInstallOverActivePluginRestartsWithNewVersion pins the fix for
// installing a new version over an already-active plugin: activate's
// "already running" short-circuit previously skipped spawning entirely, so
// the live subprocess kept running the OLD version's binary even though the
// record/install_path now pointed at the new one. Install must stop the old
// process and start the new install_path so the running process matches the
// installed version.
func TestServiceInstallOverActivePluginRestartsWithNewVersion(t *testing.T) {
svc, _, rt := newTestService(t)
rec1 := installTestPlugin(t, svc, "kandev-plugin-slack") // v1.0.0, active + running
if !rt.Running("kandev-plugin-slack") {
t.Fatal("sanity check: plugin not running after first install")
}
rec2, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-slack", "1.1.0", false))
if err != nil {
t.Fatalf("second Install() unexpected error: %v", err)
}
if rec2.Version != "1.1.0" {
t.Fatalf("rec2.Version = %q, want %q", rec2.Version, "1.1.0")
}
if rec2.InstallPath == rec1.InstallPath {
t.Fatal("rec2.InstallPath == rec1.InstallPath, want the new version's own install dir")
}
if !rt.stopped("kandev-plugin-slack") {
t.Fatal("Install() over an active plugin did not stop the old runtime process")
}
if got := rt.startCallCount("kandev-plugin-slack"); got != 2 {
t.Fatalf("runtime Start called %d times, want 2 (initial install + restart on reinstall)", got)
}
if !rt.Running("kandev-plugin-slack") {
t.Fatal("plugin not running after reinstalling over an already-active version")
}
if rec2.Status != StatusActive {
t.Fatalf("rec2.Status = %q, want %q", rec2.Status, StatusActive)
}
}
// failingSaveStore wraps a real store.Store, letting a test arm exactly one
// Save call to fail with a simulated error — used to exercise Install's
// cleanup-on-persist-failure path without a real disk-full/permission
// simulation.
type failingSaveStore struct {
store.Store
mu sync.Mutex
failNext bool
}
func (s *failingSaveStore) Save(rec *store.Record) error {
s.mu.Lock()
fail := s.failNext
s.failNext = false
s.mu.Unlock()
if fail {
return errors.New("simulated save failure")
}
return s.Store.Save(rec)
}
func (s *failingSaveStore) armFailNextSave() {
s.mu.Lock()
s.failNext = true
s.mu.Unlock()
}
// TestServiceInstallUpgradeSaveFailurePreservesOldVersionAndData pins the
// fix for a failed upgrade destroying every installed version plus the
// plugin's writable data directory: cleanup on a store.Save failure must
// remove only the freshly extracted version directory, and must restart the
// previous version's process (already stopped to make way for the new
// spawn) rather than leaving the plugin down.
func TestServiceInstallUpgradeSaveFailurePreservesOldVersionAndData(t *testing.T) {
dir := t.TempDir()
fsStore := store.NewFSStore(dir)
failing := &failingSaveStore{Store: fsStore}
reg := NewRegistry()
svc := NewService(failing, reg, nil, testLogger(t))
svc.SetPluginsDir(dir)
rt := newFakeRuntime()
svc.SetRuntime(rt)
rec1 := installTestPlugin(t, svc, "kandev-plugin-slack") // v1.0.0, active + running
if !rt.Running("kandev-plugin-slack") {
t.Fatal("sanity check: plugin not running after first install")
}
// Simulate the old version's writable data directory holding user data.
dataDir := filepath.Join(dir, "kandev-plugin-slack", "data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
t.Fatalf("MkdirAll(dataDir): %v", err)
}
marker := filepath.Join(dataDir, "marker.txt")
if err := os.WriteFile(marker, []byte("keep me"), 0o644); err != nil {
t.Fatalf("WriteFile(marker): %v", err)
}
failing.armFailNextSave()
_, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-slack", "1.1.0", false))
if err == nil {
t.Fatal("Install() expected an error from the simulated Save failure")
}
if _, statErr := os.Stat(rec1.InstallPath); statErr != nil {
t.Fatalf("old version dir %q was removed by cleanup: %v", rec1.InstallPath, statErr)
}
if _, statErr := os.Stat(marker); statErr != nil {
t.Fatalf("plugin data dir marker was removed by cleanup: %v", statErr)
}
newVersionDir := filepath.Join(dir, "kandev-plugin-slack", "1.1.0")
if _, statErr := os.Stat(newVersionDir); !os.IsNotExist(statErr) {
t.Fatalf("new version dir %q still exists after the Save failure: stat err = %v", newVersionDir, statErr)
}
if !rt.Running("kandev-plugin-slack") {
t.Fatal("the previous version's process was not restarted after the failed upgrade")
}
onDisk, getErr := fsStore.Get("kandev-plugin-slack")
if getErr != nil {
t.Fatalf("store.Get(): %v", getErr)
}
if onDisk.Version != "1.0.0" {
t.Fatalf("store record Version = %q, want %q (the old version's record must survive unchanged)", onDisk.Version, "1.0.0")
}
}
// minKandevVersionPackage builds a valid, runtime-managed plugin tar.gz
// declaring min_kandev_version, for TestServiceInstall_MinKandevVersion*.
func minKandevVersionPackage(t *testing.T, id, version, minKandevVersion string) *bytes.Buffer {
t.Helper()
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
manifestYAML := fmt.Sprintf(`
id: %s
api_version: 1
version: %s
display_name: Test Plugin
min_kandev_version: %q
runtime:
type: binary
executables:
%s: server/plugin
`, id, version, minKandevVersion, platformKey)
var buf bytes.Buffer
files := map[string][]byte{
"manifest.yaml": []byte(manifestYAML),
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
}
if err := pkgtartest.WritePackage(&buf, files); err != nil {
t.Fatalf("WritePackage: %v", err)
}
return &buf
}
// TestServiceInstall_RejectsPackageNewerThanRunningKandevVersion pins
// enforcement of manifest.min_kandev_version: once a running kandev version
// is wired via SetKandevVersion, a package that requires a newer kandev
// must be rejected (and its extracted package directory cleaned up) rather
// than installed and left to fail confusingly at spawn time.
func TestServiceInstall_RejectsPackageNewerThanRunningKandevVersion(t *testing.T) {
svc, dir, _, _ := newTestServiceWithDir(t)
svc.SetKandevVersion("1.0.0")
_, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", "2.0.0"))
if err == nil {
t.Fatal("Install() expected an error for a package requiring a newer kandev version")
}
if _, statErr := os.Stat(filepath.Join(dir, "kandev-plugin-slack", "1.0.0")); !os.IsNotExist(statErr) {
t.Fatalf("Install() left the extracted package on disk after rejecting it: stat err = %v", statErr)
}
if _, getErr := svc.Get("kandev-plugin-slack"); getErr == nil {
t.Fatal("Install() registered a record for a package it rejected on min_kandev_version")
}
}
// TestServiceInstall_AllowsPackageAtOrBelowRunningKandevVersion pins the
// non-blocking side of the same check.
func TestServiceInstall_AllowsPackageAtOrBelowRunningKandevVersion(t *testing.T) {
svc, _, _ := newTestService(t)
svc.SetKandevVersion("2.0.0")
rec, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", "1.5.0"))
if err != nil {
t.Fatalf("Install() unexpected error: %v", err)
}
if rec.Status != StatusActive {
t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive)
}
}
// TestServiceInstall_NoEnforcementWithoutKandevVersionWired pins the
// no-op default: a Service that never called SetKandevVersion (e.g. every
// other test in this file, and any caller that hasn't wired it yet) must
// keep installing packages that declare min_kandev_version exactly as
// before.
func TestServiceInstall_NoEnforcementWithoutKandevVersionWired(t *testing.T) {
svc, _, _ := newTestService(t)
rec, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", "999.0.0"))
if err != nil {
t.Fatalf("Install() unexpected error: %v", err)
}
if rec.Status != StatusActive {
t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive)
}
}
func TestValidateInstallURL_AcceptsHTTPAndHTTPS(t *testing.T) {
for _, raw := range []string{
"https://example.com/plugin.tar.gz",
"http://example.com/plugin.tar.gz",
} {
if err := validateInstallURL(raw); err != nil {
t.Fatalf("validateInstallURL(%q) unexpected error: %v", raw, err)
}
}
}
func TestValidateInstallURL_RejectsNonHTTPScheme(t *testing.T) {
for _, raw := range []string{
"file:///etc/passwd",
"gopher://example.com/plugin",
"ftp://example.com/plugin.tar.gz",
} {
if err := validateInstallURL(raw); err == nil {
t.Fatalf("validateInstallURL(%q) expected error, got nil", raw)
}
}
}
func TestValidateInstallURL_RejectsEmptyHost(t *testing.T) {
if err := validateInstallURL("https:///plugin.tar.gz"); err == nil {
t.Fatal("validateInstallURL() expected error for empty host, got nil")
}
}
func TestValidateInstallURL_RejectsMalformedURL(t *testing.T) {
if err := validateInstallURL("://not-a-url"); err == nil {
t.Fatal("validateInstallURL() expected error for malformed URL, got nil")
}
}
func TestServiceInstallFromURL_RejectsNonHTTPSchemeBeforeAnyRequest(t *testing.T) {
svc, _, _ := newTestService(t)
_, err := svc.InstallFromURL(context.Background(), "file:///etc/passwd")
if err == nil {
t.Fatal("InstallFromURL() expected error for file:// scheme, got nil")
}
}