Files
kandev/apps/backend/internal/plugins/service.go
T
João Salavisa 279e15b318 feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/SendMessage) (#1970)
* feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/CreateComment)

The api_write manifest capability was a stored no-op. Wire the deferred
phase-2 write RPCs (ADR 0043) so closed-loop plugins (Sentry->task,
Slack slash-command task creation, refactor fan-out) can create and
update tasks and post comments.

- SDK: Host.Tasks() gains Create/Update; new Host.Comments() writer;
  CreateTaskInput/UpdateTaskInput/Comment DTOs (pkg/pluginsdk).
- Gating: api_write:tasks gates Create/UpdateTask, api_write:comments
  gates CreateComment; undeclared -> gRPC PermissionDenied, per-method
  and independent of the read caps on the same resource.
- Writes route through the first-party service layer (task service for
  tasks, office service for comments) via backendapp adapters, never a
  repository -- so task.*/comment-created events fire and WS updates.
  Server stamps source="plugin:<id>"; CreateTask resolves workspace/
  workflow defaults and best-effort honors start_agent; UpdateTask uses
  a conservative title/description/state/workflow_step_id mask.
- Fixture plugins exercise a CreateTask+CreateComment round-trip;
  in-process, real-broker wire, and subprocess tests cover the round-trip
  and per-capability denial.
- Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments moved
  writes deferred -> implemented; ADR 0043 amended with the concrete
  service-layer routing decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugins): send a message to a task session via Host SendMessage (replaces CreateComment)

The office TaskComment write was the wrong primitive — it's an office/
dashboard construct, not a way to message the agent working a task, and
it only did anything under office reactivity. Replace the CreateComment
write RPC with SendMessage, which delivers a prompt to a task session
through the orchestrator's real delivery path (the same one message_task
uses).

- Proto: swap CreateComment/Comment for SendMessage(SendMessageRequest)
  returns SendMessageResponse{session_id, status}; regenerated stubs.
- SDK: Host.Comments() removed; Host.Messages().Send(taskID, sessionID,
  text) added (pairs with Messages().List), returning MessageDispatch.
- Gating: api_write:comments dropped; SendMessage gated api_write:messages
  (independent of api_read:messages on the same accessor, like Tasks()).
- Delivery: a new pluginsTaskMessengerAdapter (backendapp) resolves the
  target session (explicit id verified against the task, or the primary
  session), records a user message stamped source="plugin:<id>", then
  dispatches by state — running → queue; idle/completed → prompt (resume
  if the agent process is gone); never-started → launch; failed dispatch
  deletes the recorded message. Wired via SetWriteDeps (live-read).
- Tests: adapter state-machine unit tests (queue/start/prompt/resume/
  delete-on-failure/belongs-to-task/no-primary/terminal); SDK, in-process
  gating, wire, and subprocess round-trips updated to SendMessage.
- Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments, and the
  ADR 0043 amendment updated from comment-create to message-send.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): address PR review — nil guards, detached contexts, state validation

Follow-up fixes from the #1970 review (Claude + CodeRabbit bots):

- grpcHostServer.CreateTask/UpdateTask/SendMessage: nil-guard the returned
  value before toProto() so a custom/test Host returning (nil, nil) yields a
  gRPC Internal error, not a server panic (matches GetTask's pattern).
- host.go: fix stale server doc comment (Comments()/CreateComment →
  Messages()/SendMessage).
- startTaskBestEffort and the post-resume wait+retry now run on
  context.WithoutCancel(ctx): the task/resume is already committed, so a
  plugin's incoming deadline must not silently abort the launch/resume.
- pluginsTaskWriterAdapter.UpdateTask validates the state string against the
  known task-state enum (excluding orchestrator-owned SCHEDULING) and returns
  InvalidArgument, so a plugin typo can't forward a bogus state to the service.
- Tests: add the resume-wait-timeout path (message deleted so a retry can't
  duplicate), and a dedicated pluginsTaskWriterAdapter test covering
  source→metadata mapping and state validation (both adapters now depend on
  narrow interfaces for testability).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): launch start_agent asynchronously so CreateTask doesn't block

Review nit follow-up: startTaskBestEffort called StartTask synchronously, so
with the detached context the plugin's CreateTask RPC blocked on the
orchestrator's full inline launch path (office lookup, state update, workflow
step move, task fetch) with no bound — and the "asynchronous" comment was wrong.

Move the fire-and-forget into pluginsTaskStarterAdapter (backendapp, no goleak),
mirroring MCP's launchAutoStartTask: the launch runs in a goroutine on a
context.WithTimeout(context.WithoutCancel(ctx), AgentLaunchTimeout), and
StartTask returns immediately. The plugin host stays a simple synchronous call
(its taskStarter contract now documents "returns promptly, launches async").

Adds adapters_plugin_starter_test.go: launch invoked, returns before the launch
completes, detaches from a cancelled caller context, and swallows launch errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(plugins): document write validation + SendMessage rollback; harden starter test cleanup

Addresses the final PR #1970 review nits:
- adapters_plugin_starter_test.go: register the block-channel release via
  t.Cleanup (sync.Once-guarded) before the goroutine can block, per AGENTS.md.
- GRPC-CONTRACT.md: document CreateTask/UpdateTask validation + error rules
  (title required, id required, NotFound on missing task, invalid-state reject).
- spec.md: document that a failed SendMessage dispatch deletes the recorded
  message, plus the required-field/NotFound/terminal-session rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 10:54:12 +01:00

1144 lines
44 KiB
Go

package plugins
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sync"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/marketplace"
"github.com/kandev/kandev/internal/plugins/pkgtar"
"github.com/kandev/kandev/internal/plugins/state"
"github.com/kandev/kandev/internal/plugins/store"
"github.com/kandev/kandev/pkg/pluginsdk"
)
// maxDownloadSize caps the response body InstallFromURL will read, per the
// task's build instructions (100MB cap).
const maxDownloadSize = 100 << 20
// downloadTimeout bounds how long InstallFromURL waits for the whole
// download.
const downloadTimeout = 60 * time.Second
// Service is the core plugin service: install/uninstall, the in-memory
// Registry, the lifecycle state machine, and the runtime.Manager wiring
// that spawns/supervises each plugin's subprocess.
//
// # Extension points
//
// Event delivery (internal/plugins/delivery) is wired in by backendapp
// after Provide, following the same post-construction "SetX" pattern
// internal/jira/service.go uses for SetTaskDeleter / SetRepositoryLookup
// (avoids an import cycle between this package and its siblings):
//
// - SetDeliverer(d Deliverer) attaches the event-delivery subsystem.
// Install, Uninstall, Enable, Disable, and any successful SetStatus
// call notify the attached Deliverer via Refresh() so it can
// re-subscribe to the event bus based on current registry state.
// - StateStore() exposes the already-constructed *state.Store so the
// HTTP layer doesn't need a second NewStore(pool) call.
// - Registry() and EventBus() are exposed for any other read-only wiring
// (e.g. proxies checking a plugin's manifest/capabilities without
// going through Service's error-wrapping Get).
type Service struct {
mu sync.Mutex
// syncMu serializes Sync/bootScan calls (service_sync.go) so concurrent
// operator clicks — or a boot scan racing an operator-triggered sync —
// cannot double-install the same dropped tarball or dir sideload.
syncMu sync.Mutex
// lifecycleLocks serializes Enable/Disable/Install/InstallFromURL/
// Uninstall/UpdateConfig per plugin id, so two near-simultaneous
// lifecycle requests for the same id (e.g. two Enable clicks) cannot
// both pass an idempotency check built on a stale read and race each
// other's status-machine transition. Different ids stay fully
// concurrent. Never taken by handleStatusChange (the runtime.Manager
// supervision-goroutine callback) — that path only touches s.mu — so
// holding a lifecycleLocks entry while calling into PluginRuntime
// cannot deadlock against it.
lifecycleLocks *keyedMutex
pluginsDir string
store store.Store
registry *Registry
state *state.Store
eventBus bus.EventBus
log *logger.Logger
deliverer Deliverer
runtime PluginRuntime
secrets SecretVault
// Host data API (ADR 0043) service-layer dependencies, wired via
// SetDataSources and handed to every pluginHost hostForPlugin builds.
// nil until backendapp calls SetDataSources (see its doc comment); a
// pluginHost built before that falls back to Unimplemented for these
// accessors regardless of declared capabilities (see host_data.go's
// accessor nil-checks).
taskData taskDataSource
workflows workflowLister
workflowSteps workflowStepLister
agentProfiles agentProfileDataSource
sessionCodeStats sessionCodeStatsSource
messageData messageDataSource
taskWriter taskWriter
// Utility agent invocation (ADR 0048), wired via SetUtilityAgent.
utilityAgents utilityAgentSource
utilityRunner utilityRunner
// Host data API write dependencies wired late via SetWriteDeps (ADR
// 0043): the task-message delivery path and the orchestrator task-starter,
// both constructed after boot-active plugins spawn (see writeDeps on
// pluginHost). Mutex-guarded against the concurrent hostForPlugin reads.
messenger taskMessenger
taskStarter taskStarter
// authLogin establishes an authenticated browser session for an external
// identity an auth-capable plugin asserts via its webhook response
// (OIDC/SAML SSO), wired via SetAuthLoginBridge. nil until backendapp
// wires it (only when the auth service exists); a nil bridge means the SSO
// login directive is rejected.
authLogin AuthLoginBridge
// kandevVersion is the currently running kandev build version, used to
// enforce a package's manifest.min_kandev_version at Install (see
// SetKandevVersion / checkMinKandevVersion). Empty (the default) means
// no enforcement — no caller currently wires this in production; see
// SetKandevVersion's doc comment.
kandevVersion string
httpClient *http.Client
// marketplace is the plugin-discovery catalog service (nil until
// SetMarketplace is called by Provide). See marketplace.go.
marketplace *marketplace.Service
// settings persists instance-wide plugin preferences (the auto-update
// default). nil until SetSettings is called by Provide; the auto-update
// accessors treat a nil store as "default off, no overrides possible".
settings *settingsStore
}
// NewService wires a Service from its already-constructed dependencies.
// Provide is the usual entry point in production; NewService is exposed
// directly for tests that want a fake store.Store/PluginRuntime.
func NewService(pluginStore store.Store, registry *Registry, eventBus bus.EventBus, log *logger.Logger) *Service {
return &Service{
store: pluginStore,
registry: registry,
eventBus: eventBus,
log: log,
httpClient: &http.Client{},
lifecycleLocks: newKeyedMutex(),
}
}
// keyedMutex hands out a *sync.Mutex per key, creating it on first use and
// keeping it around for the process lifetime (the plugin id keyspace is
// small and long-lived, so there is nothing to garbage-collect). Mirrors the
// parentMutex pattern in internal/task/service/handoff_service.go.
type keyedMutex struct {
mu sync.Mutex
locks map[string]*sync.Mutex
}
func newKeyedMutex() *keyedMutex {
return &keyedMutex{locks: make(map[string]*sync.Mutex)}
}
// lockFor returns the mutex for key, creating it if this is the first call
// for that key. Callers must Lock/Unlock the returned mutex themselves.
func (k *keyedMutex) lockFor(key string) *sync.Mutex {
k.mu.Lock()
defer k.mu.Unlock()
m, ok := k.locks[key]
if !ok {
m = &sync.Mutex{}
k.locks[key] = m
}
return m
}
// SetDeliverer attaches the event-delivery subsystem. See the "Extension
// points" doc comment on Service. Safe to call at most once during startup
// wiring; not safe to call concurrently with Install/SetStatus/Uninstall.
func (s *Service) SetDeliverer(d Deliverer) {
s.mu.Lock()
defer s.mu.Unlock()
s.deliverer = d
}
// Deliverer returns the currently attached event-delivery subsystem, or nil
// if none has been attached yet.
func (s *Service) Deliverer() Deliverer {
s.mu.Lock()
defer s.mu.Unlock()
return s.deliverer
}
// SetState wires the already-constructed plugin_state store. Provide calls
// this; also exposed for tests (in this package and others, e.g.
// internal/backendapp) that build a Service without going through Provide.
func (s *Service) SetState(st *state.Store) {
s.state = st
}
// StateStore returns the plugin_state store Provide constructed, for the
// Host RPC implementation (host.go) and any HTTP wiring that needs it
// without re-initializing the schema.
func (s *Service) StateStore() *state.Store {
return s.state
}
// SetSecrets wires the secret vault Provide was constructed with.
func (s *Service) SetSecrets(v SecretVault) {
s.secrets = v
}
// SetDataSources wires the Host data API's (ADR 0043) service-layer
// dependencies, following the same post-construction "SetX" pattern as
// SetDeliverer/SetSecrets (see the "Extension points" doc comment on
// Service). backendapp calls this once, passing its already-constructed
// task, workflow, agent-settings, and analytics services directly — each
// argument's interface is a narrow slice of one of those services
// (host_data.go's taskDataSource/workflowLister/workflowStepLister/
// agentProfileDataSource/sessionCodeStatsSource), satisfied structurally, so
// no adapter type is needed. Not called by Provide itself: the plugins
// package cannot import internal/task/service, internal/workflow/service,
// etc. without an import cycle, mirroring why event delivery is wired the
// same way. Every pluginHost hostForPlugin builds after this call gets
// these dependencies; one built before (e.g. very early boot) falls back to
// Unimplemented for the Host data API regardless of declared capabilities.
func (s *Service) SetDataSources(
tasks taskDataSource,
workflows workflowLister,
workflowSteps workflowStepLister,
agentProfiles agentProfileDataSource,
sessionCodeStats sessionCodeStatsSource,
messages messageDataSource,
taskWrites taskWriter,
) {
s.taskData = tasks
s.workflows = workflows
s.workflowSteps = workflowSteps
s.agentProfiles = agentProfiles
s.sessionCodeStats = sessionCodeStats
s.messageData = messages
s.taskWriter = taskWrites
}
// SetWriteDeps wires the Host data API's late write dependencies (ADR 0043
// phase 2): the task-message delivery path behind the SendMessage RPC
// (api_write:messages) and the orchestrator task-starter behind CreateTask's
// start_agent flag. Wired LATE (not in SetDataSources) because the orchestrator
// is constructed after StartActivePlugins has spawned boot-active plugins, so
// hosts read these live via writeDependencies rather than snapshotting them —
// the write here is mutex-guarded against those concurrent reads. Either
// argument may be nil (feature-gated off), in which case the corresponding
// write path returns Unimplemented / is a best-effort no-op.
func (s *Service) SetWriteDeps(messenger taskMessenger, starter taskStarter) {
s.mu.Lock()
defer s.mu.Unlock()
s.messenger = messenger
s.taskStarter = starter
}
// writeDependencies returns the currently-wired task messenger and task
// starter. Read live (not snapshotted at hostForPlugin time) so a plugin
// spawned before SetWriteDeps still resolves them once it is called. Guarded by
// s.mu against the SetWriteDeps write.
func (s *Service) writeDependencies() (taskMessenger, taskStarter) {
s.mu.Lock()
defer s.mu.Unlock()
return s.messenger, s.taskStarter
}
// SetUtilityAgent wires the dependencies behind Host.InvokeUtilityAgent
// (ADR 0048): the service that resolves the utility agent selected in plugin
// configuration, and the sessionless runner that executes a one-shot
// completion. Wired by backendapp (not Provide) for the same import-cycle
// reason as SetDataSources. Unlike the data sources, this is wired LATE in boot
// (hostUtilityMgr is only available after agentctl control is healthy, by which
// point StartActivePlugins has already spawned boot-active plugins), so hosts
// read these live via utilityAgentDeps rather than snapshotting them — the
// write here is mutex-guarded against those concurrent reads.
func (s *Service) SetUtilityAgent(agents utilityAgentSource, runner utilityRunner) {
s.mu.Lock()
defer s.mu.Unlock()
s.utilityAgents = agents
s.utilityRunner = runner
}
// utilityAgentDeps returns the currently-wired utility-agent dependencies. Read
// live (not snapshotted at hostForPlugin time) so a plugin spawned before
// SetUtilityAgent still resolves them once it is called. Guarded by s.mu against
// the SetUtilityAgent write.
func (s *Service) utilityAgentDeps() (utilityAgentSource, utilityRunner) {
s.mu.Lock()
defer s.mu.Unlock()
return s.utilityAgents, s.utilityRunner
}
// SetAuthLoginBridge wires the SSO login bridge auth-capable plugins use to
// establish a browser session from a validated external identity (see
// auth_login.go). Called once during startup wiring, only when the auth
// service exists.
func (s *Service) SetAuthLoginBridge(b AuthLoginBridge) {
s.mu.Lock()
defer s.mu.Unlock()
s.authLogin = b
}
// authLoginBridge returns the wired SSO login bridge, or nil when auth is not
// available. Read under s.mu against the SetAuthLoginBridge write.
func (s *Service) authLoginBridge() AuthLoginBridge {
s.mu.Lock()
defer s.mu.Unlock()
return s.authLogin
}
// SetKandevVersion wires the currently running kandev build version,
// enabling Install to enforce a package's manifest.min_kandev_version
// (checkMinKandevVersion): a package requiring a newer kandev is rejected
// rather than installed and left to fail confusingly at spawn time. Not
// currently called by Provide — the running build version needs to be
// threaded down from internal/backendapp's ldflags-injected Version, which
// is outside this package; until a caller wires it, min_kandev_version
// remains parsed and stored but unenforced (the pre-existing behavior).
func (s *Service) SetKandevVersion(v string) {
s.kandevVersion = v
}
// SetRuntime wires the runtime.Manager Provide constructed.
func (s *Service) SetRuntime(rt PluginRuntime) {
s.runtime = rt
}
// Runtime returns the runtime manager Service spawns/supervises plugin
// processes through, for boot-time wiring (spawning every active plugin)
// and the HTTP layer (webhook/tool invocation).
func (s *Service) Runtime() PluginRuntime {
return s.runtime
}
// Shutdown stops every currently-running plugin process. Callers (e.g.
// backendapp's startPluginsSubsystems) register this with addCleanup for
// graceful backend shutdown.
func (s *Service) Shutdown() {
if s.runtime != nil {
s.runtime.StopAll()
}
}
// SetPluginsDir wires the root directory pkgtar.Install/pkgtar.Remove
// operate under (the same directory store.FSStore persists records in).
func (s *Service) SetPluginsDir(dir string) {
s.pluginsDir = dir
}
// RevealSecret resolves the cleartext value of the secret reference ref via
// the shared secret vault. Returns an error if no vault was wired (e.g. a
// test Service constructed via NewService directly) or if ref does not
// resolve.
func (s *Service) RevealSecret(ctx context.Context, ref string) (string, error) {
if s.secrets == nil {
return "", errors.New("plugins: secret vault not configured")
}
return s.secrets.Reveal(ctx, ref)
}
// ActiveUIPlugins returns every StatusActive plugin record that declares a
// native UI bundle (ui.bundle), used to populate the boot payload's Plugins
// list.
func (s *Service) ActiveUIPlugins() []store.Record {
var out []store.Record
for _, rec := range s.List() {
if rec.Status == StatusActive && rec.UI.Bundle != "" {
out = append(out, *rec)
}
}
return out
}
// Registry returns the underlying in-memory Registry.
func (s *Service) Registry() *Registry {
return s.registry
}
// EventBus returns the event bus Service was constructed with (may be nil
// in tests).
func (s *Service) EventBus() bus.EventBus {
return s.eventBus
}
// hostForPlugin builds the Host implementation bound to pluginID, gated by
// that plugin's currently-registered capabilities. Passed to
// PluginRuntime.Start as the hostFactory; the runtime manager calls it
// again on every restart, so a config/capability change takes effect on
// the plugin's next spawn.
func (s *Service) hostForPlugin(pluginID string) pluginsdk.Host {
rec, err := s.Get(pluginID)
if err != nil {
rec = &store.Record{} // every capability check below denies; should not happen in practice
}
return &pluginHost{
pluginID: pluginID,
capabilities: rec.Capabilities,
configSchema: rec.ConfigSchema,
state: s.state,
secrets: s.secrets,
bus: s.eventBus,
configs: s.store,
taskData: s.taskData,
workflows: s.workflows,
workflowSteps: s.workflowSteps,
agentProfiles: s.agentProfiles,
sessionCodeStats: s.sessionCodeStats,
messageData: s.messageData,
taskWriter: s.taskWriter,
utilityDeps: s.utilityAgentDeps,
writeDeps: s.writeDependencies,
}
}
// notifyDeliverer calls Refresh on the attached Deliverer, if any. Must be
// called without s.mu held (Deliverer implementations may call back into
// Service).
func (s *Service) notifyDeliverer() {
s.mu.Lock()
d := s.deliverer
s.mu.Unlock()
if d != nil {
d.Refresh()
}
}
// List returns every installed plugin, sorted by id.
func (s *Service) List() []*store.Record {
return s.registry.List()
}
// Get returns the record for id, or store.ErrNotFound.
func (s *Service) Get(id string) (*store.Record, error) {
rec, ok := s.registry.Get(id)
if !ok {
return nil, store.ErrNotFound
}
return rec, nil
}
// UpdateConfig replaces the operator-editable config for id. Incoming
// secret fields carrying the mask placeholder keep their stored value
// (mergeMaskedSecrets), the result is validated against the manifest's
// config_schema (ErrConfigInvalid on mismatch, mapped to 400 by the HTTP
// layer), secret fields are moved into the encrypted vault
// (storeConfigSecrets — the config file persists only a vault reference),
// and a currently-running plugin is restarted so the new config takes
// effect — hostForPlugin rebuilds the Host per spawn, and plugins read
// config at startup via the Host GetConfig RPC.
func (s *Service) UpdateConfig(ctx context.Context, id string, config map[string]any) error {
lock := s.lifecycleLocks.lockFor(id)
lock.Lock()
defer lock.Unlock()
rec, err := s.Get(id)
if err != nil {
return err
}
existing, err := s.store.GetConfig(id)
if err != nil {
return err
}
merged := mergeMaskedSecrets(config, existing, rec.ConfigSchema)
if err := validateConfigSchema(rec.ID, merged, rec.ConfigSchema); err != nil {
return err
}
stored, removedSecrets, rollbackVault, err := s.storeConfigSecrets(ctx, rec, merged)
if err != nil {
return err
}
if err := s.store.SetConfig(id, stored); err != nil {
// The config commit failed, so the still-current config file is
// unchanged — restore the vault to match it, otherwise a field's
// unchanged ref would resolve to the new (uncommitted) value and a
// request reported as failed would have changed effective config.
if rbErr := rollbackVault(); rbErr != nil {
return errors.Join(fmt.Errorf("plugins: persist config: %w", err),
fmt.Errorf("plugins: vault rollback failed, effective config may be inconsistent: %w", rbErr))
}
return err
}
// Vault entries for removed secret fields are deleted only AFTER the
// config commit succeeds: a failed SetConfig must never leave the old
// (still-current) config referencing an already-deleted vault entry. The
// delete runs on a context detached from the request (like the rollback
// path), so a client disconnect right after the commit cannot cancel it
// and orphan the now-unreferenced vault entries.
s.cleanupRemovedConfigSecrets(context.WithoutCancel(ctx), rec.ID, removedSecrets, existing)
return s.restartForConfigChange(rec)
}
// errSecretVaultRequired is returned by storeConfigSecrets when a plugin
// declares secret config fields but no vault is wired. It fails closed
// rather than silently persisting the secret in cleartext — production
// always wires the vault (Provide), so this only guards a misconfigured or
// test setup.
var errSecretVaultRequired = errors.New("plugins: a secret vault is required to store secret config fields")
// storeConfigSecrets moves each secret config field's cleartext value into
// the encrypted vault (id pluginConfigSecretID) and replaces it with the
// configVaultRef marker, so <id>.config.yml never persists a cleartext
// secret (validateConfigSchema has already rejected non-string secret
// values, so nothing can slip past the string path here). A field already
// carrying its ref (the mask-merge round trip) is left alone. Secret fields
// absent from merged are returned as removedSecrets for the caller to
// delete from the vault AFTER the config commit — deleting here would leave
// the still-current config pointing at a missing entry if SetConfig then
// failed. When a plugin declares secret fields but no vault is wired, it
// fails closed (errSecretVaultRequired) rather than writing cleartext.
//
// The returned rollback restores every vault entry this call overwrote to
// its prior value (or deletes it if it did not exist before), so the whole
// operation is failure-atomic: a vault.Set failure mid-loop rolls back the
// earlier writes before returning, and the caller runs rollback if the
// subsequent config commit fails — in both cases the vault ends up matching
// the unchanged config file, so a failed request never changes the value a
// still-current ref resolves to. Rollback writes run on a context detached
// from the caller's (context.WithoutCancel), so a request cancelled mid-save
// cannot abort the rollback and leave the vault inconsistent with the
// unchanged config file.
func (s *Service) storeConfigSecrets(
ctx context.Context, rec *store.Record, merged map[string]any,
) (stored map[string]any, removedSecrets []string, rollback func() error, err error) {
noRollback := func() error { return nil }
secretFields := secretPropertyKeys(rec.ConfigSchema)
if len(secretFields) == 0 {
return merged, nil, noRollback, nil
}
if s.secrets == nil {
return nil, nil, noRollback, fmt.Errorf("%w (plugin %q)", errSecretVaultRequired, rec.ID)
}
out := make(map[string]any, len(merged))
for k, v := range merged {
out[k] = v
}
rollbackCtx := context.WithoutCancel(ctx)
var restores []func() error
runRollback := func() error {
var errs []error
for i := len(restores) - 1; i >= 0; i-- {
if e := restores[i](); e != nil {
errs = append(errs, e)
}
}
return errors.Join(errs...)
}
for field := range secretFields {
value, present := out[field]
if !present {
removedSecrets = append(removedSecrets, field)
continue
}
cleartext, ok := value.(string)
if !ok || cleartext == "" || isConfigVaultRef(rec.ID, field, value) {
continue
}
vaultID := pluginConfigSecretID(rec.ID, field)
restore, snapErr := s.vaultRestoreFunc(ctx, rollbackCtx, vaultID)
if snapErr != nil {
s.warnIfRollbackFailed(rec.ID, runRollback())
return nil, nil, noRollback, snapErr
}
if err := s.secrets.Set(ctx, vaultID, vaultID, cleartext); err != nil {
s.warnIfRollbackFailed(rec.ID, runRollback())
return nil, nil, noRollback, fmt.Errorf("plugins: store secret config field %q: %w", field, err)
}
restores = append(restores, restore)
out[field] = configVaultRef(rec.ID, field)
}
return out, removedSecrets, runRollback, nil
}
// warnIfRollbackFailed logs a mid-loop vault rollback failure. A double
// fault (a vault write succeeded, then its rollback also failed) can leave
// earlier fields' vault entries at their new values while the config file is
// unchanged — making a failed request silently change effective config for
// those fields. It is very unlikely (needs a transient vault failure on both
// the write and the compensating write) and uninstall's namespace purge is a
// backstop, but surfacing it makes the inconsistency observable rather than
// silent.
func (s *Service) warnIfRollbackFailed(pluginID string, err error) {
if err != nil {
s.log.Warn("plugins: vault rollback failed after a store error; config may be inconsistent",
zap.String("plugin_id", pluginID), zap.Error(err))
}
}
// vaultRestoreFunc snapshots vaultID's current value (read on readCtx) and
// returns a closure that restores it (writes on restoreCtx): reset to the
// prior cleartext if the entry existed, or delete it if it did not. Used to
// undo a config-secret write when the config commit that would reference it
// fails. A not-found snapshot means "absent" (rollback deletes what we
// create); any other Reveal error is a genuine backend fault where the prior
// value cannot be determined — it returns an error so the caller aborts
// before writing rather than risk a rollback that deletes a real secret.
// restoreCtx is detached from the request so a cancelled save cannot abort
// the rollback.
func (s *Service) vaultRestoreFunc(readCtx, restoreCtx context.Context, vaultID string) (func() error, error) {
prior, err := s.secrets.Reveal(readCtx, vaultID)
switch {
case err == nil:
return func() error { return s.secrets.Set(restoreCtx, vaultID, vaultID, prior) }, nil
case isSecretNotFound(err):
return func() error {
if delErr := s.secrets.Delete(restoreCtx, vaultID); delErr != nil && !isSecretNotFound(delErr) {
return delErr
}
return nil
}, nil
default:
return nil, fmt.Errorf("plugins: cannot snapshot secret config field %q for rollback: %w", vaultID, err)
}
}
// cleanupRemovedConfigSecrets best-effort deletes the vault entries backing
// secret config fields that the just-committed config no longer contains,
// when the previous config actually pointed at them. Runs only after a
// successful SetConfig (see UpdateConfig); a deletion failure leaves an
// orphaned vault entry, which uninstall's namespace purge also sweeps.
func (s *Service) cleanupRemovedConfigSecrets(
ctx context.Context, pluginID string, removed []string, existing map[string]any,
) {
for _, field := range removed {
if !isConfigVaultRef(pluginID, field, existing[field]) {
continue
}
if err := s.secrets.Delete(ctx, pluginConfigSecretID(pluginID, field)); err != nil {
s.log.Warn("plugins: failed to delete removed secret config field from vault",
zap.String("plugin_id", pluginID), zap.String("field", field), zap.Error(err))
}
}
}
// GetMaskedConfig returns id's stored config with secret values (per the
// manifest's config_schema) replaced by the mask placeholder — the shape
// the operator settings UI is allowed to see.
func (s *Service) GetMaskedConfig(id string) (map[string]any, error) {
rec, err := s.Get(id)
if err != nil {
return nil, err
}
config, err := s.store.GetConfig(id)
if err != nil {
return nil, err
}
return maskSecrets(config, rec.ConfigSchema), nil
}
// restartForConfigChange bounces id's process after a config write so the
// plugin re-reads its config on the fresh spawn. A plugin that is not
// running (disabled, errored, or no runtime wired) is left alone — it will
// pick the config up on its next spawn anyway. The config is already
// persisted by the time this runs; a restart failure transitions the plugin
// to StatusError and is returned so the operator sees that the save
// succeeded but the plugin did not come back up.
func (s *Service) restartForConfigChange(rec *store.Record) error {
if s.runtime == nil || !s.runtime.Running(rec.ID) {
return nil
}
s.runtime.Stop(rec.ID)
ctx, cancel := context.WithTimeout(context.Background(), activateStartTimeout)
defer cancel()
if err := s.runtime.Start(ctx, rec, s.hostForPlugin); err != nil {
if setErr := s.SetStatus(rec.ID, StatusError); setErr != nil {
// The restart error stays the returned error (it is the primary
// signal), but a failed status write means the registry may show
// StatusActive with no process running — don't lose that.
s.log.Warn("plugins: could not transition to error status after restart failure",
zap.String("plugin_id", rec.ID), zap.Error(setErr))
}
s.notifyDeliverer()
return fmt.Errorf("plugins: config saved but restart of %q failed: %w", rec.ID, err)
}
return nil
}
// Install verifies and extracts r (a tar.gz plugin package) via pkgtar into
// the plugins directory, persists a fresh store.Record (status
// "registered"), adds it to the in-memory registry, and attempts to spawn
// and activate it. A pkgtar error (e.g. pkgtar.ErrVersionExists) is
// returned unchanged so callers can map it to the right HTTP status. If the
// package is valid but the initial spawn fails, the record is still
// persisted (status "error") and returned alongside the spawn error, so an
// operator can fix the issue and retry via Enable.
//
// Installing a new version of a plugin id that is currently active/running
// stops the old process first (activate's own "already running" idempotency
// check would otherwise skip spawning entirely, leaving the live subprocess
// running the OLD version's binary even though the record/install_path now
// point at the new one). If persisting the fresh record then fails,
// rollbackFailedInstall removes only the just-extracted version directory
// (every other installed version, and the plugin's writable data directory,
// survive) and restarts the previous version's process, so a failed upgrade
// attempt never destroys a previously working install.
func (s *Service) Install(ctx context.Context, r io.Reader) (*store.Record, error) {
result, err := pkgtar.Install(r, s.pluginsDir)
if err != nil {
return nil, err
}
if err := s.checkMinKandevVersion(result.Manifest.MinKandevVersion); err != nil {
_ = os.RemoveAll(result.InstallPath)
return nil, err
}
// The plugin id is only known once pkgtar.Install has parsed the
// package's manifest, so the per-plugin lock is acquired here rather
// than at the very top of the function — this still covers
// InstallFromURL, which calls through to Install. It serializes the
// rest of this method (the record/registry/activate mutation) against
// any other Enable/Disable/Install/Uninstall/UpdateConfig call for the
// same id.
lock := s.lifecycleLocks.lockFor(result.Manifest.ID)
lock.Lock()
defer lock.Unlock()
oldRec, hadOldRec := s.registry.Get(result.Manifest.ID)
wasRunning := s.runtime != nil && s.runtime.Running(result.Manifest.ID)
if wasRunning {
s.runtime.Stop(result.Manifest.ID)
}
rec := &store.Record{
Manifest: *result.Manifest,
Status: StatusRegistered,
InstallPath: result.InstallPath,
Signed: result.Signed,
InstalledAt: time.Now().UTC(),
}
// An in-place upgrade rebuilds the record from the new package's manifest,
// so the operator's per-plugin auto-update override (an operator choice,
// not a manifest fact) must be carried forward or an auto-update would
// silently reset the very toggle that triggered it.
if hadOldRec {
rec.AutoUpdate = oldRec.AutoUpdate
}
if err := s.store.Save(rec); err != nil {
s.rollbackFailedInstall(result.InstallPath, oldRec, hadOldRec && wasRunning)
return nil, fmt.Errorf("plugins: persist installed record: %w", err)
}
s.registry.Add(rec)
activateErr := s.activate(rec)
s.notifyDeliverer()
installed, getErr := s.Get(rec.ID)
if getErr != nil {
return rec, activateErr
}
return installed, activateErr
}
// checkMinKandevVersion rejects a package whose manifest declares a
// min_kandev_version newer than the currently running kandev build
// (manifest.CompareVersions). A no-op (nil error) when either side is
// unset: minVersion == "" (the manifest doesn't declare one, the common
// case today) or s.kandevVersion == "" (no running version wired via
// SetKandevVersion).
func (s *Service) checkMinKandevVersion(minVersion string) error {
if minVersion == "" || s.kandevVersion == "" {
return nil
}
if manifest.CompareVersions(s.kandevVersion, minVersion) < 0 {
return fmt.Errorf("plugins: requires kandev >= %s, running %s", minVersion, s.kandevVersion)
}
return nil
}
// rollbackFailedInstall cleans up after a store.Save failure partway
// through Install: it removes only freshInstallPath (the version directory
// pkgtar.Install just extracted), never the whole destRoot/<id>/ tree —
// other installed versions and the plugin's writable data directory
// (destRoot/<id>/data) must survive. If restartOld is true (an existing
// record was running and got stopped to make way for this install),
// oldRec's process is best-effort restarted so the failed upgrade attempt
// doesn't also take down the previously working version; a restart failure
// is logged, not returned, since Install is already returning the original
// Save error.
func (s *Service) rollbackFailedInstall(freshInstallPath string, oldRec *store.Record, restartOld bool) {
if err := os.RemoveAll(freshInstallPath); err != nil {
s.log.Warn("plugins: failed to remove extracted package after a persist failure",
zap.String("install_path", freshInstallPath), zap.Error(err))
}
if !restartOld || s.runtime == nil || oldRec == nil {
return
}
startCtx, cancel := context.WithTimeout(context.Background(), activateStartTimeout)
defer cancel()
if err := s.runtime.Start(startCtx, oldRec, s.hostForPlugin); err != nil {
s.log.Warn("plugins: failed to restart previous version after a failed upgrade",
zap.String("plugin_id", oldRec.ID), zap.Error(err))
}
}
// InstallFromURL downloads url (capped at maxDownloadSize, bounded by
// downloadTimeout) and installs it via Install. url is operator-provided
// (an admin installing a plugin from a URL), so this does not attempt full
// SSRF elimination, but validateInstallURL rejects non-http(s) schemes and
// URLs with no host before any request is built.
func (s *Service) InstallFromURL(ctx context.Context, url string) (*store.Record, error) {
if err := validateInstallURL(url); err != nil {
return nil, fmt.Errorf("plugins: %w", err)
}
reqCtx, cancel := context.WithTimeout(ctx, downloadTimeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("plugins: build download request: %w", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("plugins: download package: %w", err)
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("plugins: download package: server responded %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadSize+1))
if err != nil {
return nil, fmt.Errorf("plugins: read package: %w", err)
}
if int64(len(data)) > maxDownloadSize {
return nil, fmt.Errorf("plugins: package exceeds max download size of %d bytes", maxDownloadSize)
}
return s.Install(ctx, bytes.NewReader(data))
}
// validateInstallURL is the sink-level guard InstallFromURL applies before
// building any outbound request: raw must parse as a URL with an http or
// https scheme and a non-empty host. It rejects file://, gopher://, and
// other schemes that would let an operator-supplied string reach something
// other than a plain HTTP(S) fetch. This narrows, but does not eliminate,
// the residual SSRF surface inherent to letting an operator point the
// installer at an arbitrary http(s) URL (including internal hosts).
func validateInstallURL(raw string) error {
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid install URL: %w", err)
}
switch parsed.Scheme {
case "http", "https":
default:
return fmt.Errorf("invalid install URL: unsupported scheme %q (must be http or https)", parsed.Scheme)
}
if parsed.Hostname() == "" {
return errors.New("invalid install URL: missing host")
}
return nil
}
// Uninstall stops id's process (if running), purges its vault namespace,
// removes its extracted package tree from disk, deletes its record from both
// the store and the in-memory registry, and deletes every plugin_state row
// scoped to id (best-effort — a failure there is logged but does not fail
// the overall Uninstall, since the package/record are already gone by that
// point), then notifies the attached Deliverer. Clearing plugin_state and
// the vault namespace matters so a plugin reinstalled under the same id (or
// an id later reused by a different plugin) never silently inherits stale
// state or secrets.
//
// Ordering is deliberate: the process is stopped FIRST so the plugin can no
// longer race the cleanup by writing a fresh secret (SetSecret) between the
// vault list and the deletes; then the vault namespace is purged and any
// failure aborts the uninstall — nothing destructive (package/record
// removal) has happened yet, so the operator simply retries (Stop and the
// vault deletes are both idempotent). A failed uninstall therefore leaves
// the plugin stopped-but-installed, resolved by a retry.
func (s *Service) Uninstall(ctx context.Context, id string) error {
lock := s.lifecycleLocks.lockFor(id)
lock.Lock()
defer lock.Unlock()
if _, err := s.Get(id); err != nil {
return err
}
wasRunning := s.runtime != nil && s.runtime.Running(id)
if s.runtime != nil {
s.runtime.Stop(id)
}
if err := s.deletePluginSecrets(ctx, id); err != nil {
// The process is stopped but nothing else was removed. If it had been
// running, its persisted status still says active — reconcile it to
// error and notify observers so it isn't reported as running while no
// process is, before returning the retryable failure.
if wasRunning {
if setErr := s.SetStatus(id, StatusError); setErr != nil {
s.log.Warn("plugins: could not mark plugin errored after an aborted uninstall",
zap.String("plugin_id", id), zap.Error(setErr))
}
s.notifyDeliverer()
}
return fmt.Errorf("plugins: uninstall aborted, could not purge plugin secrets: %w", err)
}
if err := pkgtar.Remove(s.pluginsDir, id); err != nil {
return fmt.Errorf("plugins: remove installed package: %w", err)
}
if err := s.store.Delete(id); err != nil {
return err
}
s.registry.Remove(id)
s.deletePluginState(id)
s.notifyDeliverer()
return nil
}
// deletePluginSecrets removes every vault entry in id's namespace
// ("plugin:<id>:..." — both SetSecret-owned and config-backed entries), so
// a reinstall under the same id never inherits stale secrets. Unlike
// deletePluginState it is NOT best-effort: it runs before any destructive
// uninstall step (after the process is stopped, so no concurrent writes can
// re-populate the namespace), and a failure aborts the uninstall while it
// can still be retried. Deletion is idempotent, so a partial failure that
// deleted some entries is safely resumed by a retry. A nil vault (no
// secrets possible) is a no-op.
func (s *Service) deletePluginSecrets(ctx context.Context, id string) error {
if s.secrets == nil {
return nil
}
ids, err := s.secrets.ListIDs(ctx)
if err != nil {
return fmt.Errorf("list vault ids: %w", err)
}
var errs []error
for _, vaultID := range ids {
if !hasPluginVaultPrefix(vaultID, id) {
continue
}
if err := s.secrets.Delete(ctx, vaultID); err != nil {
errs = append(errs, fmt.Errorf("delete %s: %w", vaultID, err))
}
}
return errors.Join(errs...)
}
// deletePluginState best-effort removes every plugin_state row for id. A
// nil state store (e.g. a Service constructed without SetState in tests, or
// before backendapp finishes wiring) is a silent no-op.
func (s *Service) deletePluginState(id string) {
if s.state == nil {
return
}
if err := s.state.DeleteAll(context.Background(), id); err != nil {
s.log.Warn("plugins: failed to delete plugin_state on uninstall", zap.String("plugin_id", id), zap.Error(err))
}
}
// Enable transitions id to StatusActive, spawning its process first if it
// is not already running. Idempotent: a no-op (nil error) if id is already
// active.
func (s *Service) Enable(id string) error {
lock := s.lifecycleLocks.lockFor(id)
lock.Lock()
defer lock.Unlock()
rec, err := s.Get(id)
if err != nil {
return err
}
if rec.Status == StatusActive {
return nil
}
if err := s.activate(rec); err != nil {
return err
}
s.notifyDeliverer()
return nil
}
// Disable stops id's process (if running) and transitions it to
// StatusDisabled. Idempotent: a no-op (nil error) if id is already
// disabled.
func (s *Service) Disable(id string) error {
lock := s.lifecycleLocks.lockFor(id)
lock.Lock()
defer lock.Unlock()
rec, err := s.Get(id)
if err != nil {
return err
}
if rec.Status == StatusDisabled {
return nil
}
if s.runtime != nil {
s.runtime.Stop(id)
}
if err := s.SetStatus(id, StatusDisabled); err != nil {
return err
}
s.notifyDeliverer()
return nil
}
// activateStartTimeout bounds the context activate hands to runtime.Start,
// so a hung plugin binary cannot block Enable/Install indefinitely. The
// runtime.Manager itself also enforces a startTimeout on the underlying
// go-plugin handshake (the actual blocking call is not context-aware); this
// context bound is defense-in-depth and gives Start a chance to short-circuit
// on ctx.Err() before ever spawning.
const activateStartTimeout = 30 * time.Second
// activate spawns rec's process (if not already running) and transitions it
// to StatusActive. If the spawn fails, it best-effort transitions the
// record to StatusError (ignoring an invalid-transition failure, e.g. from
// "disabled") and returns the spawn error.
func (s *Service) activate(rec *store.Record) error {
if s.runtime != nil && !s.runtime.Running(rec.ID) {
ctx, cancel := context.WithTimeout(context.Background(), activateStartTimeout)
defer cancel()
if err := s.runtime.Start(ctx, rec, s.hostForPlugin); err != nil {
_ = s.SetStatus(rec.ID, StatusError)
return fmt.Errorf("plugins: start %q: %w", rec.ID, err)
}
}
return s.SetStatus(rec.ID, StatusActive)
}
// SetStatus applies a single-hop status transition for id, enforcing the
// state machine (allowedTransitions in types.go). On success the change is
// persisted to the store and applied to the in-memory registry. Returns
// *ErrInvalidTransition without mutating anything if the transition is not
// legal, and store.ErrNotFound if id is not installed. Callers that need
// the attached Deliverer notified (most of them) call notifyDeliverer
// separately — SetStatus itself does not, since activate/Disable call it
// both for the runtime spawn/stop and the status transition, and only want
// a single Refresh for the whole operation.
func (s *Service) SetStatus(id string, status Status) error {
s.mu.Lock()
rec, ok := s.registry.Get(id)
if !ok {
s.mu.Unlock()
return store.ErrNotFound
}
if !canTransition(rec.Status, status) {
s.mu.Unlock()
return &ErrInvalidTransition{ID: id, From: rec.Status, To: status}
}
updated, ok := s.registry.SetStatus(id, status)
if !ok {
s.mu.Unlock()
return store.ErrNotFound
}
if err := s.store.Save(updated); err != nil {
// Roll back the in-memory change so registry and disk stay in sync.
s.registry.SetStatus(id, rec.Status)
s.mu.Unlock()
return err
}
s.mu.Unlock()
return nil
}
// handleStatusChange is the runtime.Manager OnStatusChange callback (see
// Provide, where it is bound as a Manager constructor argument): invoked
// from the supervision loop's own goroutine whenever a running plugin's
// health transitions. healthy=false drives active -> error; healthy=true
// drives error -> active plus a Deliverer.Flush (the buffered-event
// recovery replay). Restart count is persisted best-effort afterward.
func (s *Service) handleStatusChange(id string, healthy bool) {
newStatus := StatusError
if healthy {
newStatus = StatusActive
}
if err := s.SetStatus(id, newStatus); err != nil {
s.log.Warn("plugins: health transition failed",
zap.String("plugin_id", id), zap.Bool("healthy", healthy), zap.Error(err))
} else {
s.notifyDeliverer()
if healthy {
if d := s.Deliverer(); d != nil {
d.Flush(id)
}
}
}
s.recordRestartCount(id)
}
// recordRestartCount best-effort persists the runtime manager's current
// restart count for id onto its store.Record.
func (s *Service) recordRestartCount(id string) {
if s.runtime == nil {
return
}
updated, ok := s.registry.SetRestartCount(id, s.runtime.RestartCount(id))
if !ok {
return
}
if err := s.store.Save(updated); err != nil {
s.log.Warn("plugins: persist restart count failed", zap.String("plugin_id", id), zap.Error(err))
}
}
// StartActivePlugins runs the conservative boot filesystem scan (dir
// sideloads registered disabled, missing-install detection — see
// service_sync.go's bootScan) and then spawns every currently-StatusActive,
// runtime-managed plugin's process. Called once at boot (backendapp's
// startPluginsSubsystems) so plugins that were active before a restart
// resume running. A spawn failure is logged and the plugin transitions to
// StatusError rather than aborting the rest of the boot sequence.
func (s *Service) StartActivePlugins(ctx context.Context) {
s.logBootScanResult(s.bootScan(ctx))
if s.runtime == nil {
return
}
for _, rec := range s.List() {
if rec.Status != StatusActive || !rec.IsManaged() || s.runtime.Running(rec.ID) {
continue
}
if err := s.runtime.Start(ctx, rec, s.hostForPlugin); err != nil {
s.log.Warn("plugins: failed to spawn active plugin at boot",
zap.String("plugin_id", rec.ID), zap.Error(err))
_ = s.SetStatus(rec.ID, StatusError)
}
}
}
// logBootScanResult logs what the boot filesystem scan found, if anything —
// a silent no-op scan (the common case) logs nothing.
func (s *Service) logBootScanResult(result *SyncResult) {
if result == nil || (len(result.Added) == 0 && len(result.Missing) == 0 && len(result.Errors) == 0) {
return
}
s.log.Info("plugins: boot filesystem scan found changes",
zap.Strings("sideloaded", result.Added),
zap.Strings("missing", result.Missing),
zap.Int("errors", len(result.Errors)))
for _, e := range result.Errors {
s.log.Warn("plugins: boot scan error", zap.String("path", e.Path), zap.String("reason", e.Reason))
}
}