Files
kandev/apps/backend/internal/plugins/runtime/manager_test.go
T
João Salavisa fae2303db3 feat(plugins): per-plugin settings pages driven by manifest config_schema (#1761)
* feat(plugins): per-plugin settings pages driven by manifest config_schema

Settings > Plugins rows now open /settings/plugins/:id — a detail page
showing the manifest plus a settings form generated from the plugin
author's config_schema. Secret fields (secret: true / format: password)
are masked on the operator API and kept on masked re-submits; a running
plugin restarts on save and reads its config (cleartext secrets included)
through the new Host GetConfig gRPC RPC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): write plugin config file owner-only (0600)

Operator config can now carry cleartext secret values (config_schema
fields marked secret), so <id>.config.yml must not be world-readable —
match the 0600 mode the record file already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugins): vault-backed plugin secrets — Get/Set/DeleteSecret RPCs and encrypted config secrets

Plugins get vault-backed secret primitives on the Host API
(GetSecret/SetSecret/DeleteSecret, capability-gated, namespaced to
plugin:<id>㊙️<key> so a plugin can only touch its own entries;
purged on uninstall). Secret config_schema fields now live in kandev's
AES-256-GCM vault too — <id>.config.yml persists only a vault reference,
and the Host GetConfig RPC resolves it back to cleartext for the plugin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(plugins): serve PR screenshots from the media/pr-1761-screenshots ref instead of the repo

The PR body now points at raw URLs pinned to an unmerged media ref, so
the binaries never land in main (PRs are squash-merged; only the branch
tip tree merges).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address review feedback on settings validation and save flow

- integer form fields reject non-integral input instead of parseInt-truncating
- enum selections keep their schema-declared types (numeric enums round-trip)
- backend enum validation normalizes int-vs-float64 before comparison
- maskSecrets masks any non-zero secret value regardless of runtime type
- save flow splits PATCH from refetch: a refetch failure no longer reports a
  successful save as an error, and typed secrets are masked on screen
- optional enum fields gain a 'Not set' option so they can be cleared
- resolveConfigSecrets copies before mutating; restart-failure SetStatus
  errors are logged; SDK test fake returns a non-nil empty config map
- add missing tests: usePluginConfigForm hook, getPluginConfig client,
  integer/enum regression cases

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): harden secret config invariants from review

- reject non-string values on declared secret fields at validation time,
  closing the path where a numeric/boolean secret bypassed the vault and
  persisted cleartext in the config file
- delete removed secret fields' vault entries only AFTER the config commit
  succeeds, so a failed SetConfig never leaves the still-current config
  referencing a deleted vault entry
- uninstall fails closed: the plugin's vault namespace is purged before any
  destructive step, and a cleanup failure aborts the uninstall (retryable)
  instead of letting a reinstall inherit stale secrets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): failure-atomic config secrets, quiesced uninstall purge, ctx propagation

- UpdateConfig is now failure-atomic for secret overwrites: storeConfigSecrets
  snapshots each vault entry before writing and returns a rollback that
  restores prior values (or deletes newly-created ones). A vault.Set failure
  mid-loop or a failed SetConfig commit rolls back, so a request reported as
  failed never changes the value a still-current config ref resolves to.
- Uninstall stops the plugin process BEFORE purging its vault namespace, so
  the plugin can no longer race the cleanup with a SetSecret between ListIDs
  and the deletes; the purge stays fail-closed and retryable.
- Thread the request context through Uninstall -> deletePluginSecrets instead
  of context.Background(), per the pass-context-through-chains convention.
- Consolidate the 'secret not found:' prefix check into isSecretNotFound
  (host.go + service rollback share one definition).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): detach rollback context, fail closed without a vault, reconcile uninstall status

- vault rollback closures now run on context.WithoutCancel(ctx): a request
  cancelled mid-save (browser closed) can no longer abort the rollback and
  leave the vault holding new values while the unchanged config still points
  at them (Greptile P1).
- storeConfigSecrets fails closed (errSecretVaultRequired) when a plugin
  declares secret config fields but no vault is wired, instead of silently
  persisting the secret in cleartext.
- vaultRestoreFunc distinguishes not-found from a genuine Reveal error: a
  backend read failure aborts the update before writing rather than risk a
  rollback that deletes a real secret; rollback now returns errors, joined
  into UpdateConfig's failure so an inconsistent vault is surfaced, not just
  logged.
- Uninstall reconciles lifecycle status when a secret-purge failure aborts
  after the process was stopped: a previously-running plugin transitions to
  error and notifies observers instead of being reported active with no
  process running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): detach removed-secret cleanup ctx and clear stale save dirty flag

- cleanupRemovedConfigSecrets now runs on context.WithoutCancel(ctx), matching
  the rollback path, so a client disconnect right after the config commit can
  no longer cancel the delete and orphan now-unreferenced vault entries.
- tests: assert GetConfig succeeds before treating an empty map as proof that
  nothing persisted (a swallowed read error would be a false pass).
- web: on a post-save refetch failure, rebase initialValues onto the masked
  snapshot so the saved form no longer reads as dirty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): fail tests on discarded GetConfig errors, drop setState-in-updater

- config_test: capture and fatal on GetConfig errors at the four remaining
  positive-assertion sites (a nil from a failed read would panic on the next
  map access instead of giving a clear failure), matching the earlier fix.
- web hook: compute the masked post-save snapshot from the current values and
  apply it to setValues + setInitialValues directly, instead of calling
  setInitialValues inside a setValues functional updater (a React anti-pattern
  that runs twice under StrictMode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): skip validation of a secret field's own vault ref

- validateConfigSchema takes the plugin id and skips a secret field whose
  value is that field's config vault ref (an internal storage marker,
  already validated when first set). Without this, a field declaring both
  secret: true and enum: [...] 400s on every save that keeps the mask,
  because the enum check saw the vault ref instead of a user value. Freshly
  entered secret values are still validated; a foreign plugin's ref is not
  skipped. Covered by unit + service-level regression tests.
- surface mid-loop vault rollback failures in storeConfigSecrets via a
  warning instead of discarding them (double-fault observability).
- web: integer inputs get step=1 so browsers flag non-integral input in
  place (serializeConfigValues remains the backstop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(plugins): split config secret tests into config_secrets_test.go

config_test.go had grown past the 800-line backend file-length limit
(revive), failing CI lint. Move the vault-backed config and plugin-scoped
secret tests (plus their fake-vault/store helpers) into a sibling file;
no test logic changes.

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 22:13:58 +01:00

461 lines
15 KiB
Go

package runtime
import (
"context"
"errors"
"os"
"path/filepath"
goruntime "runtime"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/store"
"github.com/kandev/kandev/pkg/pluginsdk"
)
// fakeHost is a minimal in-memory pluginsdk.Host for end-to-end tests: it
// only implements SetState (with a channel so tests can synchronize on
// delivery without a sleep), and errors on everything else. It embeds
// UnimplementedHostData to satisfy the Host data API (ADR 0043)
// sub-accessors without wiring them; see docs/plans/plugins/host-data-api/
// task-04-host-data-impl.md for the real implementation.
type fakeHost struct {
pluginsdk.UnimplementedHostData
mu sync.Mutex
states map[string]map[string]any
setCh chan struct{}
}
func newFakeHost() *fakeHost {
return &fakeHost{states: map[string]map[string]any{}, setCh: make(chan struct{}, 16)}
}
func (h *fakeHost) GetState(context.Context, string, string, string) (map[string]any, bool, error) {
return nil, false, nil
}
func (h *fakeHost) SetState(_ context.Context, scope, scopeID, key string, value map[string]any) error {
h.mu.Lock()
h.states[scope+"|"+scopeID+"|"+key] = value
h.mu.Unlock()
select {
case h.setCh <- struct{}{}:
default:
}
return nil
}
func (h *fakeHost) DeleteState(context.Context, string, string, string) error { return nil }
func (h *fakeHost) ListState(context.Context, string, string) ([]pluginsdk.StateEntry, error) {
return nil, nil
}
func (h *fakeHost) GetConfig(context.Context) (map[string]any, error) { return nil, nil }
func (h *fakeHost) GetSecret(context.Context, string) (string, bool, error) { return "", false, nil }
func (h *fakeHost) SetSecret(context.Context, string, string) error { return nil }
func (h *fakeHost) DeleteSecret(context.Context, string) error { return nil }
func (h *fakeHost) RevealSecret(context.Context, string) (string, error) { return "", nil }
func (h *fakeHost) EmitEvent(context.Context, string, map[string]any) error { return nil }
func (h *fakeHost) get(scope, scopeID, key string) (map[string]any, bool) {
h.mu.Lock()
defer h.mu.Unlock()
v, ok := h.states[scope+"|"+scopeID+"|"+key]
return v, ok
}
// buildFixtureRecord copies the real fixture plugin binary into a fresh
// InstallPath and returns a *store.Record ready for Manager.Start,
// skipping the test if -short suppressed the fixture build.
func buildFixtureRecord(t *testing.T, id string) *store.Record {
t.Helper()
if fixtureBinPath == "" {
t.Skip("fixture plugin binary not built (test run with -short)")
}
installPath := t.TempDir()
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
relExec := filepath.Join("server", "plugin-"+platformKey)
destExec := filepath.Join(installPath, relExec)
if err := os.MkdirAll(filepath.Dir(destExec), 0o755); err != nil {
t.Fatalf("mkdir server dir: %v", err)
}
copyFile(t, fixtureBinPath, destExec, 0o755)
return &store.Record{
Manifest: manifest.Manifest{
ID: id,
APIVersion: 1,
Version: "1.0.0",
Runtime: manifest.Runtime{
Type: "binary",
Executables: map[string]string{platformKey: relExec},
},
},
Status: store.StatusRegistered,
InstallPath: installPath,
}
}
func copyFile(t *testing.T, src, dst string, mode os.FileMode) {
t.Helper()
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read %s: %v", src, err)
}
if err := os.WriteFile(dst, data, mode); err != nil {
t.Fatalf("write %s: %v", dst, err)
}
}
func TestManager_StartDeliverEventInvokeToolStop(t *testing.T) {
rec := buildFixtureRecord(t, "kandev-fixture-plugin")
pluginsDir := t.TempDir()
host := newFakeHost()
m := NewManager(pluginsDir, nil, testLogger(t))
t.Cleanup(m.StopAll)
ctx := context.Background()
if err := m.Start(ctx, rec, func(string) pluginsdk.Host { return host }); err != nil {
t.Fatalf("Start() unexpected error: %v", err)
}
if !m.Running(rec.ID) {
t.Fatal("Running() = false right after a successful Start()")
}
remote, ok := m.Get(rec.ID)
if !ok {
t.Fatal("Get() ok = false right after a successful Start()")
}
t.Run("InvokeTool echoes input over the real subprocess", func(t *testing.T) {
resp, err := remote.InvokeTool(ctx, &pluginsdk.ToolRequest{ToolName: "echo", Input: map[string]any{"x": float64(1)}})
if err != nil {
t.Fatalf("InvokeTool() unexpected error: %v", err)
}
if resp.Output["x"] != float64(1) {
t.Fatalf("InvokeTool().Output = %v, want echoed input", resp.Output)
}
})
t.Run("DeliverEvent reaches the plugin, which calls back into Host.SetState", func(t *testing.T) {
err := remote.DeliverEvent(ctx, &pluginsdk.Event{EventID: "e1", EventType: "task.created"})
if err != nil {
t.Fatalf("DeliverEvent() unexpected error: %v", err)
}
select {
case <-host.setCh:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the plugin's Host.SetState callback")
}
value, found := host.get("instance", "", "last_event")
if !found {
t.Fatal("fakeHost never recorded the delivered event")
}
if value["event_type"] != "task.created" {
t.Fatalf("recorded event_type = %v, want %q", value["event_type"], "task.created")
}
})
t.Run("Ping succeeds against the live subprocess", func(t *testing.T) {
if err := m.Ping(rec.ID); err != nil {
t.Fatalf("Ping() unexpected error: %v", err)
}
})
m.Stop(rec.ID)
if m.Running(rec.ID) {
t.Fatal("Running() = true after Stop()")
}
}
func TestManager_CrashTriggersAutomaticRestart(t *testing.T) {
rec := buildFixtureRecord(t, "kandev-fixture-plugin-crash")
pluginsDir := t.TempDir()
host := newFakeHost()
var mu sync.Mutex
var transitions []bool
onStatusChange := func(_ string, healthy bool) {
mu.Lock()
transitions = append(transitions, healthy)
mu.Unlock()
}
m := NewManager(pluginsDir, onStatusChange, testLogger(t),
WithPingInterval(20*time.Millisecond),
WithMaxConsecutiveFailures(1),
WithRestartBackoff([]time.Duration{10 * time.Millisecond}),
WithMaxRestartAttempts(3),
)
t.Cleanup(m.StopAll)
ctx := context.Background()
if err := m.Start(ctx, rec, func(string) pluginsdk.Host { return host }); err != nil {
t.Fatalf("Start() unexpected error: %v", err)
}
remote, ok := m.Get(rec.ID)
if !ok {
t.Fatal("Get() ok = false right after Start()")
}
// Invoking the "crash" tool exits the subprocess immediately; the RPC
// call itself is expected to error (connection dropped mid-call) or
// hang up cleanly - either way we only assert on the recovery below.
_, _ = remote.InvokeTool(ctx, &pluginsdk.ToolRequest{ToolName: "crash"})
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return len(transitions) >= 2 && !transitions[0] && transitions[1]
}, 10*time.Second, 20*time.Millisecond,
"onStatusChange should observe degraded (false) then recovered (true) after the crash")
}
// TestManager_ConcurrentStartOnlySpawnsOnce pins the fix for the TOCTOU
// double-spawn race: two Start calls for the same plugin id racing the
// "already running" check must not both reach spawnFn. The first goroutine
// is deliberately paused *inside* its (slow) spawn — the exact window the
// old code released the manager lock across — before the second goroutine's
// startProcess call is allowed to race in.
func TestManager_ConcurrentStartOnlySpawnsOnce(t *testing.T) {
m := NewManager(t.TempDir(), nil, testLogger(t))
t.Cleanup(m.StopAll)
var spawnCalls int32
firstEntered := make(chan struct{})
release := make(chan struct{})
var closeOnce sync.Once
spawnFn := func() (spawnedProcess, error) {
if atomic.AddInt32(&spawnCalls, 1) == 1 {
closeOnce.Do(func() { close(firstEntered) })
<-release // hold the first spawn open so the second Start can race in
}
return &fakeSpawnedProcess{}, nil
}
var wg sync.WaitGroup
errs := make([]error, 2)
wg.Add(1)
go func() {
defer wg.Done()
errs[0] = m.startProcess("race-plugin", spawnFn)
}()
<-firstEntered // the first Start is now mid-spawn, before it has registered anything
errs[1] = m.startProcess("race-plugin", spawnFn)
close(release)
wg.Wait()
if got := atomic.LoadInt32(&spawnCalls); got != 1 {
t.Fatalf("spawnFn called %d times for a concurrent Start of the same id, want exactly 1 (no double-spawn)", got)
}
if errs[0] != nil {
t.Fatalf("first startProcess() unexpected error: %v", errs[0])
}
if errs[1] == nil {
t.Fatal("second concurrent startProcess() for the same id succeeded, want an error (already starting)")
}
if !m.Running("race-plugin") {
t.Fatal("Running() = false after the winning Start() completed")
}
}
// TestManager_StopDuringStartAbortsAndLeavesNoOrphanProcess pins the fix for
// the Stop-during-Start orphan race: Stop previously only consulted
// m.processes, so a Disable/Uninstall racing an in-flight Start was a
// silent no-op — the spawn then completed and inserted a live supervised
// process for a plugin whose record/files may already be gone by the time
// the spawn finished. claimStart's "starting" reservation now doubles as
// the hook Stop uses to request an abort: startProcess checks it right
// after the (slow) spawn succeeds and, if set, kills the freshly spawned
// process and never inserts it into m.processes.
func TestManager_StopDuringStartAbortsAndLeavesNoOrphanProcess(t *testing.T) {
m := NewManager(t.TempDir(), nil, testLogger(t))
t.Cleanup(m.StopAll)
entered := make(chan struct{})
release := make(chan struct{})
var closeOnce sync.Once
spawned := &fakeSpawnedProcess{}
spawnFn := func() (spawnedProcess, error) {
closeOnce.Do(func() { close(entered) })
<-release // hold the spawn open until the test has called Stop
return spawned, nil
}
errCh := make(chan error, 1)
go func() {
errCh <- m.startProcess("racing-plugin", spawnFn)
}()
<-entered // the spawn is in flight, before startProcess has registered anything
m.Stop("racing-plugin") // races the in-flight Start: must not be a no-op
close(release) // let the spawn finish
err := <-errCh
if err == nil {
t.Fatal("startProcess() expected an error when Stop() raced an in-flight Start, got nil")
}
if m.Running("racing-plugin") {
t.Fatal("Running() = true after Stop() raced an in-flight Start; an orphan process was left registered")
}
if !spawned.isKilled() {
t.Fatal("the spawned process was never killed after Stop() raced an in-flight Start (orphan subprocess)")
}
}
// TestManager_StopAllDuringStartAbortsAndLeavesNoOrphanProcess is the same
// race as TestManager_StopDuringStartAbortsAndLeavesNoOrphanProcess, but
// through StopAll (graceful shutdown), which previously only iterated
// m.processes too.
func TestManager_StopAllDuringStartAbortsAndLeavesNoOrphanProcess(t *testing.T) {
m := NewManager(t.TempDir(), nil, testLogger(t))
t.Cleanup(m.StopAll)
entered := make(chan struct{})
release := make(chan struct{})
var closeOnce sync.Once
spawned := &fakeSpawnedProcess{}
spawnFn := func() (spawnedProcess, error) {
closeOnce.Do(func() { close(entered) })
<-release
return spawned, nil
}
errCh := make(chan error, 1)
go func() {
errCh <- m.startProcess("racing-plugin-2", spawnFn)
}()
<-entered
m.StopAll()
close(release)
err := <-errCh
if err == nil {
t.Fatal("startProcess() expected an error when StopAll() raced an in-flight Start, got nil")
}
if m.Running("racing-plugin-2") {
t.Fatal("Running() = true after StopAll() raced an in-flight Start; an orphan process was left registered")
}
if !spawned.isKilled() {
t.Fatal("the spawned process was never killed after StopAll() raced an in-flight Start (orphan subprocess)")
}
}
// TestManager_StartAfterExhaustionRespawns pins the fix that lets a fresh
// Start cleanly replace an entry whose restart attempts were exhausted
// (gaveUp==true, current==nil): before the fix, Start saw the stale map
// entry and always errored "already running" instead of respawning.
func TestManager_StartAfterExhaustionRespawns(t *testing.T) {
m := NewManager(t.TempDir(), nil, testLogger(t),
WithPingInterval(5*time.Millisecond),
WithMaxConsecutiveFailures(1),
WithRestartBackoff([]time.Duration{0}),
WithMaxRestartAttempts(1),
)
t.Cleanup(m.StopAll)
var mu sync.Mutex
firstSpawnDone := false
spawnFn := func() (spawnedProcess, error) {
mu.Lock()
defer mu.Unlock()
if !firstSpawnDone {
firstSpawnDone = true
return &fakeSpawnedProcess{exited: true}, nil // dies immediately, forcing an exhausting restart loop
}
return nil, errors.New("respawn always fails") // every restart attempt fails too
}
if err := m.startProcess("exhaust-plugin", spawnFn); err != nil {
t.Fatalf("startProcess() unexpected error: %v", err)
}
// Wait for the supervision loop to actually finish giving up (gaveUp),
// not just for current to go nil — current is cleared at the start of
// handleFailureAndRestart, before the (failing) respawn attempt(s) run,
// so polling only Running()==false races the loop's own completion.
require.Eventually(t, func() bool {
m.mu.Lock()
p, ok := m.processes["exhaust-plugin"]
m.mu.Unlock()
return ok && p.isGaveUp()
}, 2*time.Second, 5*time.Millisecond,
"plugin's process should have given up once restart attempts are exhausted")
healthy := &fakeSpawnedProcess{}
respawnCalls := 0
freshSpawnFn := func() (spawnedProcess, error) {
respawnCalls++
return healthy, nil
}
if err := m.startProcess("exhaust-plugin", freshSpawnFn); err != nil {
t.Fatalf("startProcess() after exhaustion unexpected error: %v", err)
}
if respawnCalls != 1 {
t.Fatalf("respawn spawnFn called %d times, want 1", respawnCalls)
}
if !m.Running("exhaust-plugin") {
t.Fatal("Running() = false after a fresh Start() following exhaustion")
}
}
// TestManager_DefaultStartTimeoutIs30s pins the fix for bounded startup:
// Manager.Start previously spawned via a hcplugin.Client with no configured
// StartTimeout, defaulting to go-plugin's own 1-minute handshake timeout, so
// a hung plugin binary could block Enable/Install for up to a minute. A
// fresh Manager must default to a much shorter, explicit bound.
func TestManager_DefaultStartTimeoutIs30s(t *testing.T) {
m := NewManager(t.TempDir(), nil, testLogger(t))
if m.startTimeout != 30*time.Second {
t.Fatalf("default startTimeout = %v, want 30s", m.startTimeout)
}
}
// TestManager_WithStartTimeoutOverridesDefault pins the escape hatch for
// callers (and this test file) that need a different bound.
func TestManager_WithStartTimeoutOverridesDefault(t *testing.T) {
m := NewManager(t.TempDir(), nil, testLogger(t), WithStartTimeout(5*time.Second))
if m.startTimeout != 5*time.Second {
t.Fatalf("startTimeout = %v, want 5s (WithStartTimeout override)", m.startTimeout)
}
}
// TestManager_StartFailsFastOnHandshakeTimeout proves the configured
// startTimeout actually reaches the underlying hcplugin.Client: an
// unreasonably short timeout must make Start() return an error quickly
// instead of ever completing the handshake, even against a real, working
// fixture binary.
func TestManager_StartFailsFastOnHandshakeTimeout(t *testing.T) {
rec := buildFixtureRecord(t, "kandev-fixture-plugin-timeout")
m := NewManager(t.TempDir(), nil, testLogger(t), WithStartTimeout(1*time.Nanosecond))
t.Cleanup(m.StopAll)
started := time.Now()
err := m.Start(context.Background(), rec, func(string) pluginsdk.Host { return newFakeHost() })
elapsed := time.Since(started)
if err == nil {
t.Fatal("Start() with a 1ns startTimeout unexpectedly succeeded")
}
if elapsed > 10*time.Second {
t.Fatalf("Start() took %v to fail, want well under go-plugin's 1-minute default handshake timeout", elapsed)
}
}