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>
This commit is contained in:
João Salavisa
2026-07-17 22:13:58 +01:00
committed by GitHub
parent 4dd1d333e7
commit fae2303db3
40 changed files with 4393 additions and 318 deletions
@@ -24,6 +24,7 @@ runtime:
capabilities:
events: ["task.created"]
state: true
secrets: true
tools:
- name: "echo"
@@ -37,3 +38,16 @@ webhooks:
ui:
bundle: "/ui/bundle.js"
config_schema:
type: object
required: ["api_token"]
properties:
api_token:
type: string
title: "API token"
description: "Secret token; masked on the operator API, delivered to the plugin via the Host GetConfig RPC."
secret: true
greeting:
type: string
title: "Greeting"
+5 -1
View File
@@ -184,7 +184,11 @@ func (h *fakeHost) ListState(context.Context, string, string) ([]pluginsdk.State
return nil, nil
}
func (h *fakeHost) RevealSecret(context.Context, string) (string, error) { return "", 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 }
+55 -5
View File
@@ -15,8 +15,10 @@ import (
)
const (
deliveriesFileName = "deliveries.jsonl"
webhooksFileName = "webhooks.jsonl"
deliveriesFileName = "deliveries.jsonl"
webhooksFileName = "webhooks.jsonl"
configSnapshotFileName = "config.json"
secretProbeFileName = "secret-probe.json"
toolNameEcho = "echo"
)
@@ -123,16 +125,64 @@ func (p *fixturePlugin) InvokeTool(_ context.Context, req *pluginsdk.ToolRequest
return &pluginsdk.ToolResponse{Output: req.Input}, nil
}
// HandleWebhook appends a webhooks.jsonl line recording the delivery, and
// responds 200 "ok".
func (p *fixturePlugin) HandleWebhook(_ context.Context, req *pluginsdk.WebhookRequest) (*pluginsdk.WebhookResponse, error) {
// HandleWebhook appends a webhooks.jsonl line recording the delivery,
// best-effort snapshots the plugin's current operator config to
// config.json (evidence for e2e that the Host GetConfig RPC delivers the
// values set in Settings > Plugins, secrets in cleartext), and responds
// 200 "ok".
func (p *fixturePlugin) HandleWebhook(ctx context.Context, req *pluginsdk.WebhookRequest) (*pluginsdk.WebhookResponse, error) {
rec := webhookRecord{WebhookKey: req.WebhookKey, Method: req.Method}
if err := appendJSONLine(filepath.Join(p.dataDir, webhooksFileName), rec); err != nil {
return nil, err
}
p.snapshotConfigBestEffort(ctx)
p.snapshotSecretProbeBestEffort(ctx)
return &pluginsdk.WebhookResponse{Status: 200, Body: []byte("ok")}, nil
}
// snapshotSecretProbeBestEffort exercises the plugin-scoped secret
// primitives end to end: SetSecret then GetSecret through the Host, writing
// the read-back value to secret-probe.json as evidence for e2e that a
// plugin-owned secret survives a vault round trip over the real transport.
func (p *fixturePlugin) snapshotSecretProbeBestEffort(ctx context.Context) {
host := p.Host()
if host == nil {
return
}
if err := host.SetSecret(ctx, "probe", "s3cret-roundtrip"); err != nil {
return
}
value, found, err := host.GetSecret(ctx, "probe")
if err != nil || !found {
return
}
data, err := json.Marshal(map[string]string{"probe": value})
if err != nil {
return
}
_ = os.WriteFile(filepath.Join(p.dataDir, secretProbeFileName), data, 0o600)
}
// snapshotConfigBestEffort writes the current Host.GetConfig result to
// config.json (overwriting any previous snapshot). Errors — including "no
// Host injected yet" — are silently ignored: like recordLastEventBestEffort,
// this exists purely as e2e coverage of the Host round trip.
func (p *fixturePlugin) snapshotConfigBestEffort(ctx context.Context) {
host := p.Host()
if host == nil {
return
}
config, err := host.GetConfig(ctx)
if err != nil {
return
}
data, err := json.Marshal(config)
if err != nil {
return
}
_ = os.WriteFile(filepath.Join(p.dataDir, configSnapshotFileName), data, 0o600)
}
// appendJSONLine marshals v to a single JSON line and appends it to path,
// creating path's parent directory and the file itself as needed.
func appendJSONLine(path string, v any) error {
@@ -55,6 +55,22 @@ func (a *Adapter) Delete(ctx context.Context, id string) error {
return a.store.Delete(ctx, id)
}
// ListIDs returns the ids of every stored secret (metadata only, no
// values). Used by consumers that own a namespaced id range (e.g. the
// plugin service's "plugin:<id>:..." entries) to find their own rows for
// bulk cleanup.
func (a *Adapter) ListIDs(ctx context.Context) ([]string, error) {
items, err := a.store.List(ctx)
if err != nil {
return nil, err
}
ids := make([]string, len(items))
for i, item := range items {
ids[i] = item.ID
}
return ids, nil
}
// Exists reports whether a secret with id exists. Returns (false, nil) when
// the row is absent, and (false, err) on any other error so callers can
// distinguish "not configured" from a backend outage.
@@ -69,7 +69,11 @@ func (f *fakeStore) Delete(_ context.Context, id string) error {
}
func (f *fakeStore) List(_ context.Context) ([]*secrets.SecretListItem, error) {
return nil, errors.New("not implemented")
items := make([]*secrets.SecretListItem, 0, len(f.rows))
for id := range f.rows {
items = append(items, &secrets.SecretListItem{ID: id})
}
return items, nil
}
func (f *fakeStore) Close() error { return nil }
@@ -157,3 +161,28 @@ func TestAdapter_Delete_DelegatesToStore(t *testing.T) {
t.Error("Delete did not remove the row")
}
}
func TestAdapter_ListIDs(t *testing.T) {
store := newFakeStore()
a := New(store)
ctx := context.Background()
if err := a.Set(ctx, "plugin:p1:secret:a", "plugin:p1:secret:a", "v1"); err != nil {
t.Fatalf("Set: %v", err)
}
if err := a.Set(ctx, "other", "other", "v2"); err != nil {
t.Fatalf("Set: %v", err)
}
ids, err := a.ListIDs(ctx)
if err != nil {
t.Fatalf("ListIDs: %v", err)
}
got := map[string]bool{}
for _, id := range ids {
got[id] = true
}
if len(ids) != 2 || !got["plugin:p1:secret:a"] || !got["other"] {
t.Fatalf("ListIDs = %v, want both stored ids", ids)
}
}
+343
View File
@@ -0,0 +1,343 @@
// config.go implements the operator-facing plugin settings mechanism: the
// manifest's config_schema (a JSON-Schema-like object the plugin author
// declares) drives a settings form in Settings > Plugins > <plugin>, and the
// helpers here mask/merge/validate the values flowing through it.
//
// Secret fields (a schema property with `secret: true` or
// `format: "password"`, e.g. a GitHub PAT) never leave the backend in
// cleartext on the operator API: GetMaskedConfig replaces stored values with
// configSecretMask, and mergeMaskedSecrets treats an incoming masked value as
// "keep what is stored" so re-submitting the form unchanged never clobbers a
// secret. The plugin process itself reads the REAL values via the Host
// GetConfig RPC (host.go) — that is how the configured secret reaches the
// plugin.
package plugins
import (
"errors"
"fmt"
"reflect"
"regexp"
"strings"
)
// configSecretMask is the placeholder returned in place of a secret config
// value on the operator API, and recognized on write as "leave the stored
// value unchanged". Deliberately implausible as a real credential.
const configSecretMask = "********"
// secretNotFoundPrefix is how the secrets layer signals an absent entry
// (fmt.Errorf("secret not found: ...") in internal/secrets). Centralized
// here so the several call sites that must tell "absent" apart from a real
// backend error share one definition instead of each re-hardcoding the
// prefix (host.go's GetSecret/DeleteSecret, service.go's vault rollback).
const secretNotFoundPrefix = "secret not found:"
// isSecretNotFound reports whether err is the secrets layer's "absent entry"
// signal rather than a genuine backend failure.
func isSecretNotFound(err error) bool {
return err != nil && strings.HasPrefix(err.Error(), secretNotFoundPrefix)
}
// pluginVaultIDPrefix is the reserved id namespace every plugin-owned vault
// entry lives under: "plugin:<plugin_id>:...". Uninstall deletes the whole
// prefix.
const pluginVaultIDPrefix = "plugin:"
// pluginSecretKeyPattern bounds the keys plugins may use with the
// GetSecret/SetSecret/DeleteSecret Host RPCs: a single sane identifier, so
// a key can never smuggle separators into the vault-id namespace.
var pluginSecretKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`)
// pluginVaultPrefix returns the vault-id prefix owned by pluginID.
func pluginVaultPrefix(pluginID string) string {
return pluginVaultIDPrefix + pluginID + ":"
}
// pluginSecretID is the vault id for a plugin-owned secret stored via the
// SetSecret Host RPC.
func pluginSecretID(pluginID, key string) string {
return pluginVaultPrefix(pluginID) + "secret:" + key
}
// pluginConfigSecretID is the vault id backing a secret config_schema field
// saved from the plugin settings page. Distinct sub-namespace from
// pluginSecretID so a plugin's own SetSecret keys can never collide with
// config-managed entries.
func pluginConfigSecretID(pluginID, field string) string {
return pluginVaultPrefix(pluginID) + "config:" + field
}
// configVaultRef is the value persisted in <id>.config.yml in place of a
// secret config field's cleartext: "vault:" + the exact vault id holding
// the value. isConfigVaultRef only treats a value as a reference when it
// equals the ref derived for that specific plugin+field, so a cleartext
// secret that merely starts with "vault:" can never be misread.
func configVaultRef(pluginID, field string) string {
return "vault:" + pluginConfigSecretID(pluginID, field)
}
// isConfigVaultRef reports whether value is the vault reference marker for
// pluginID's config field.
func isConfigVaultRef(pluginID, field string, value any) bool {
s, ok := value.(string)
return ok && s == configVaultRef(pluginID, field)
}
// hasPluginVaultPrefix reports whether vault id belongs to pluginID's
// namespace.
func hasPluginVaultPrefix(id, pluginID string) bool {
return strings.HasPrefix(id, pluginVaultPrefix(pluginID))
}
// ErrConfigInvalid marks a config rejected by validateConfigSchema so the
// HTTP layer can map it to 400 instead of 500.
var ErrConfigInvalid = errors.New("plugin config invalid")
// schemaProperties extracts the "properties" object from a config_schema.
// Returns nil (no declared properties, everything permissive) when the
// schema is absent or not shaped like a JSON-Schema object.
func schemaProperties(schema map[string]any) map[string]any {
props, _ := schema["properties"].(map[string]any)
return props
}
// secretPropertyKeys returns the set of property names the schema marks as
// secret: `secret: true` or `format: "password"`.
func secretPropertyKeys(schema map[string]any) map[string]bool {
keys := map[string]bool{}
for name, raw := range schemaProperties(schema) {
prop, ok := raw.(map[string]any)
if !ok {
continue
}
if prop["secret"] == true || prop["format"] == "password" {
keys[name] = true
}
}
return keys
}
// maskSecrets returns a copy of config with every set secret value replaced
// by configSecretMask — regardless of runtime type, since masking is the
// primary cleartext boundary on the operator API and a schema may declare a
// non-string property secret (or a stored value may be malformed). Only
// zero values (nil, "", false, 0) pass through, so the UI can distinguish
// "not set" from "set".
func maskSecrets(config map[string]any, schema map[string]any) map[string]any {
secrets := secretPropertyKeys(schema)
out := make(map[string]any, len(config))
for k, v := range config {
if secrets[k] && !isZeroConfigValue(v) {
out[k] = configSecretMask
continue
}
out[k] = v
}
return out
}
// isZeroConfigValue reports whether v is an unset/zero config value that
// maskSecrets leaves unmasked: nil, empty string, false, or numeric zero.
func isZeroConfigValue(v any) bool {
switch value := v.(type) {
case nil:
return true
case string:
return value == ""
case bool:
return !value
default:
if f, ok := numericValue(v); ok {
return f == 0
}
return false
}
}
// mergeMaskedSecrets resolves an incoming config write against the stored
// one: a secret field submitted as the mask placeholder keeps its stored
// value (or is dropped if nothing is stored). Everything else is taken from
// incoming verbatim — the write is a full replace, not a patch.
func mergeMaskedSecrets(incoming, existing map[string]any, schema map[string]any) map[string]any {
secrets := secretPropertyKeys(schema)
out := make(map[string]any, len(incoming))
for k, v := range incoming {
out[k] = v
}
for k := range secrets {
if out[k] != configSecretMask {
continue
}
if stored, ok := existing[k]; ok {
out[k] = stored
} else {
delete(out, k)
}
}
return out
}
// validateConfigSchema checks config against the author-declared
// config_schema for pluginID. Deliberately a small JSON-Schema subset —
// required fields, primitive types (string/boolean/number/integer), and enum
// membership on declared properties. Undeclared keys and richer schema
// constructs are permitted: the schema is advisory authoring metadata, not a
// hard sandbox.
//
// A secret field that was left unchanged carries its own vault reference (an
// internal storage marker) rather than a user value — the cleartext was
// already validated when it was first set. Such a value is skipped, so a
// field declaring both `secret: true` and e.g. `enum: [...]` does not 400 on
// every save that keeps the mask (the ref is not one of the enum values).
func validateConfigSchema(pluginID string, config map[string]any, schema map[string]any) error {
if err := checkRequiredKeys(config, schema); err != nil {
return err
}
if err := checkSecretFieldsAreStrings(config, schema); err != nil {
return err
}
props := schemaProperties(schema)
for name, raw := range props {
prop, ok := raw.(map[string]any)
if !ok {
continue
}
value, present := config[name]
if !present {
continue
}
if isConfigVaultRef(pluginID, name, value) {
continue
}
if err := checkPropertyValue(name, value, prop); err != nil {
return err
}
}
return nil
}
// checkSecretFieldsAreStrings rejects a non-string value on any declared
// secret field. Secrets are vault-backed and the vault stores strings; a
// numeric/boolean secret would otherwise skip vault storage in
// storeConfigSecrets and persist in cleartext — the exact invariant this
// feature exists to uphold. Credentials are strings in practice, so this is
// a contract, not a limitation: authors wanting a secret value declare it
// `type: string`. nil (unset) is allowed.
func checkSecretFieldsAreStrings(config map[string]any, schema map[string]any) error {
for field := range secretPropertyKeys(schema) {
value, present := config[field]
if !present || value == nil {
continue
}
if _, ok := value.(string); !ok {
return fmt.Errorf("%w: secret field %q must be a string", ErrConfigInvalid, field)
}
}
return nil
}
// checkRequiredKeys enforces the schema's "required" list.
func checkRequiredKeys(config map[string]any, schema map[string]any) error {
required, _ := schema["required"].([]any)
for _, raw := range required {
name, ok := raw.(string)
if !ok {
continue
}
if _, present := config[name]; !present {
return fmt.Errorf("%w: missing required field %q", ErrConfigInvalid, name)
}
}
return nil
}
// checkPropertyValue validates one present value against its declared
// property: primitive type match plus enum membership.
func checkPropertyValue(name string, value any, prop map[string]any) error {
if typeName, ok := prop["type"].(string); ok {
if !valueMatchesType(value, typeName) {
return fmt.Errorf("%w: field %q must be a %s", ErrConfigInvalid, name, typeName)
}
}
if enum, ok := prop["enum"].([]any); ok && len(enum) > 0 {
for _, allowed := range enum {
if enumValueMatches(value, allowed) {
return nil
}
}
return fmt.Errorf("%w: field %q must be one of the declared enum values", ErrConfigInvalid, name)
}
return nil
}
// enumValueMatches compares a submitted value against a declared enum
// entry. Numeric values are normalized first: the manifest YAML decodes an
// enum entry like 5 as int, while the same value submitted over HTTP JSON
// arrives as float64 — reflect.DeepEqual would wrongly reject that valid
// selection. Everything else falls back to DeepEqual.
func enumValueMatches(value, allowed any) bool {
vf, vOK := numericValue(value)
af, aOK := numericValue(allowed)
if vOK && aOK {
return vf == af
}
return reflect.DeepEqual(value, allowed)
}
// numericValue converts the numeric types a config value can realistically
// carry (JSON float64, YAML int/int64/uint64) to float64 for comparison.
func numericValue(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case int:
return float64(n), true
case int64:
return float64(n), true
case uint64:
return float64(n), true
default:
return 0, false
}
}
// valueMatchesType reports whether value satisfies the JSON-Schema primitive
// typeName. Numeric values may arrive as float64 (JSON decoding) or int
// (YAML round-trip of a stored config); "integer" additionally requires an
// integral float. Unknown type names (object/array/…) are not checked.
func valueMatchesType(value any, typeName string) bool {
switch typeName {
case "string":
_, ok := value.(string)
return ok
case "boolean":
_, ok := value.(bool)
return ok
case "number":
return isNumeric(value)
case "integer":
if f, ok := value.(float64); ok {
return f == float64(int64(f))
}
return isInt(value)
default:
return true
}
}
func isNumeric(value any) bool {
if _, ok := value.(float64); ok {
return true
}
return isInt(value)
}
func isInt(value any) bool {
switch value.(type) {
case int, int64, uint64:
return true
default:
return false
}
}
@@ -0,0 +1,632 @@
package plugins
import (
"bytes"
"context"
"errors"
"fmt"
"os"
goruntime "runtime"
"testing"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest"
"github.com/kandev/kandev/internal/plugins/store"
)
// --- vault-backed config + plugin-scoped secret tests ---
func newTestServiceWithVault(t *testing.T) (*Service, *store.FSStore, *fakeSecretRevealer) {
t.Helper()
svc, fsStore, _ := newTestService(t)
vault := newFakeSecretRevealer()
svc.SetSecrets(vault)
return svc, fsStore, vault
}
func TestServiceUpdateConfigStoresSecretInVaultNotConfigFile(t *testing.T) {
svc, fsStore, vault := newTestServiceWithVault(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{
"github_token": "ghp_real", "org": "kdlbs",
})
if err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
stored, err := fsStore.GetConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
wantRef := configVaultRef("kandev-plugin-github", "github_token")
if stored["github_token"] != wantRef {
t.Fatalf("stored github_token = %v, want vault ref %q", stored["github_token"], wantRef)
}
if value, ok := vault.get(pluginConfigSecretID("kandev-plugin-github", "github_token")); !ok || value != "ghp_real" {
t.Fatalf("vault entry = %q (found=%v), want cleartext ghp_real", value, ok)
}
masked, err := svc.GetMaskedConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetMaskedConfig: %v", err)
}
if masked["github_token"] != configSecretMask {
t.Fatalf("masked github_token = %v, want mask", masked["github_token"])
}
}
func TestServiceUpdateConfigMaskRoundTripKeepsVaultValue(t *testing.T) {
svc, fsStore, vault := newTestServiceWithVault(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
ctx := context.Background()
if err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{"github_token": "ghp_real"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
// Re-submit with the mask: the ref stays a ref, the vault keeps the value.
if err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{
"github_token": configSecretMask, "org": "kdlbs",
}); err != nil {
t.Fatalf("UpdateConfig (mask round trip): %v", err)
}
stored, err := fsStore.GetConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if stored["github_token"] != configVaultRef("kandev-plugin-github", "github_token") {
t.Fatalf("stored github_token = %v, want vault ref", stored["github_token"])
}
if value, _ := vault.get(pluginConfigSecretID("kandev-plugin-github", "github_token")); value != "ghp_real" {
t.Fatalf("vault value = %q, want preserved ghp_real", value)
}
}
func TestServiceUpdateConfigRemovedSecretDeletesVaultEntry(t *testing.T) {
svc, _, vault := newTestServiceWithVault(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
ctx := context.Background()
if err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{"github_token": "ghp_real"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
// github_token is required by the schema, so drop the optional secret
// path via a schema-less check: submit without the field entirely is
// rejected; instead prove deletion through Uninstall below and via the
// optional webhook_key secret here.
if err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{
"github_token": configSecretMask, "webhook_key": "whsec_1",
}); err != nil {
t.Fatalf("UpdateConfig (add webhook_key): %v", err)
}
if _, ok := vault.get(pluginConfigSecretID("kandev-plugin-github", "webhook_key")); !ok {
t.Fatalf("webhook_key should be in the vault")
}
if err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{
"github_token": configSecretMask,
}); err != nil {
t.Fatalf("UpdateConfig (remove webhook_key): %v", err)
}
if _, ok := vault.get(pluginConfigSecretID("kandev-plugin-github", "webhook_key")); ok {
t.Fatalf("removed secret field should be deleted from the vault")
}
}
func TestServiceUninstallPurgesPluginVaultNamespace(t *testing.T) {
svc, _, vault := newTestServiceWithVault(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
ctx := context.Background()
if err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{"github_token": "ghp_real"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
vault.set(pluginSecretID("kandev-plugin-github", "own-key"), "own-value")
vault.set("unrelated", "keep-me")
if err := svc.Uninstall(context.Background(), "kandev-plugin-github"); err != nil {
t.Fatalf("Uninstall: %v", err)
}
if _, ok := vault.get(pluginConfigSecretID("kandev-plugin-github", "github_token")); ok {
t.Fatalf("config secret should be purged on uninstall")
}
if _, ok := vault.get(pluginSecretID("kandev-plugin-github", "own-key")); ok {
t.Fatalf("plugin-owned secret should be purged on uninstall")
}
if _, ok := vault.get("unrelated"); !ok {
t.Fatalf("secrets outside the plugin namespace must survive uninstall")
}
}
func TestPluginHostGetConfigResolvesVaultRef(t *testing.T) {
svc, fsStore, vault := newTestServiceWithVault(t)
rec := installConfigPlugin(t, svc, "kandev-plugin-github")
ctx := context.Background()
if err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{"github_token": "ghp_real"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
host := &pluginHost{
pluginID: "kandev-plugin-github",
configSchema: rec.ConfigSchema,
configs: fsStore,
secrets: vault,
}
config, err := host.GetConfig(ctx)
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if config["github_token"] != "ghp_real" {
t.Fatalf("host GetConfig github_token = %v, want resolved cleartext", config["github_token"])
}
}
func TestPluginHostSecretPrimitives(t *testing.T) {
vault := newFakeSecretRevealer()
host := &pluginHost{
pluginID: "kandev-plugin-github",
capabilities: manifestCapsWithSecrets(),
secrets: vault,
}
ctx := context.Background()
_, found, err := host.GetSecret(ctx, "pat")
if err != nil || found {
t.Fatalf("GetSecret(missing) = found=%v err=%v, want false,nil", found, err)
}
if err := host.SetSecret(ctx, "pat", "ghp_owned"); err != nil {
t.Fatalf("SetSecret: %v", err)
}
if value, ok := vault.get("plugin:kandev-plugin-github:secret:pat"); !ok || value != "ghp_owned" {
t.Fatalf("vault entry = %q (found=%v), want namespaced ghp_owned", value, ok)
}
value, found, err := host.GetSecret(ctx, "pat")
if err != nil || !found || value != "ghp_owned" {
t.Fatalf("GetSecret = %q,%v,%v, want ghp_owned,true,nil", value, found, err)
}
if err := host.DeleteSecret(ctx, "pat"); err != nil {
t.Fatalf("DeleteSecret: %v", err)
}
if err := host.DeleteSecret(ctx, "pat"); err != nil {
t.Fatalf("DeleteSecret(missing) should be a no-op, got %v", err)
}
}
func TestPluginHostSecretPrimitivesRequireCapabilityAndValidKey(t *testing.T) {
host := &pluginHost{pluginID: "p", secrets: newFakeSecretRevealer()}
if err := host.SetSecret(context.Background(), "k", "v"); err == nil {
t.Fatalf("SetSecret without secrets capability should be denied")
}
host.capabilities = manifestCapsWithSecrets()
for _, bad := range []string{"", "a b", "x:y", "../etc", ".hidden"} {
if err := host.SetSecret(context.Background(), bad, "v"); err == nil {
t.Fatalf("SetSecret(%q) should reject invalid key", bad)
}
}
}
func manifestCapsWithSecrets() manifest.Capabilities {
return manifest.Capabilities{Secrets: true}
}
func TestMaskSecretsMasksNonStringSecretValues(t *testing.T) {
schema := map[string]any{
"properties": map[string]any{
"pin": map[string]any{"type": "integer", "secret": true},
"enabled": map[string]any{"type": "boolean", "secret": true},
},
}
masked := maskSecrets(map[string]any{"pin": 1234, "enabled": true}, schema)
if masked["pin"] != configSecretMask || masked["enabled"] != configSecretMask {
t.Fatalf("non-string secrets must be masked, got %v", masked)
}
// Zero values stay visible so the UI can tell "not set" apart.
masked = maskSecrets(map[string]any{"pin": 0, "enabled": false}, schema)
if masked["pin"] != 0 || masked["enabled"] != false {
t.Fatalf("zero-value secrets should pass through, got %v", masked)
}
}
func TestValidateConfigSchemaNumericEnumAcceptsJSONFloat(t *testing.T) {
schema := map[string]any{
"properties": map[string]any{
// Manifest YAML decodes these as int...
"level": map[string]any{"type": "integer", "enum": []any{1, 2, 3}},
},
}
// ...while an HTTP JSON submit arrives as float64.
if err := validateConfigSchema("test-plugin", map[string]any{"level": float64(2)}, schema); err != nil {
t.Fatalf("numeric enum should accept float64(2) against int enum: %v", err)
}
if err := validateConfigSchema("test-plugin", map[string]any{"level": float64(9)}, schema); !errors.Is(err, ErrConfigInvalid) {
t.Fatalf("out-of-enum numeric should still be rejected, got %v", err)
}
}
func TestValidateConfigSchemaSkipsOwnVaultRefForSecretEnumField(t *testing.T) {
const pluginID = "kandev-plugin-github"
// A field that is BOTH secret and enum: on an unchanged save it carries
// its vault ref, which is none of the enum values.
schema := map[string]any{
"properties": map[string]any{
"env": map[string]any{"type": "string", "secret": true, "enum": []any{"dev", "ops"}},
},
}
// The field's own vault ref (an internal marker) must validate — it was
// checked when the cleartext was first set.
if err := validateConfigSchema(pluginID, map[string]any{"env": configVaultRef(pluginID, "env")}, schema); err != nil {
t.Fatalf("own vault ref for a secret+enum field should validate, got %v", err)
}
// A freshly-entered value is still validated against the enum.
if err := validateConfigSchema(pluginID, map[string]any{"env": "dev"}, schema); err != nil {
t.Fatalf("valid enum value should pass, got %v", err)
}
if err := validateConfigSchema(pluginID, map[string]any{"env": "prod"}, schema); !errors.Is(err, ErrConfigInvalid) {
t.Fatalf("out-of-enum value should still be rejected, got %v", err)
}
// A ref belonging to a DIFFERENT plugin is not this field's ref, so it is
// not skipped and fails the enum check.
if err := validateConfigSchema(pluginID, map[string]any{"env": configVaultRef("other", "env")}, schema); !errors.Is(err, ErrConfigInvalid) {
t.Fatalf("a foreign vault ref must not bypass validation, got %v", err)
}
}
// testPackageWithSecretEnumSchema builds a package whose manifest declares a
// field that is both secret and enum — the combination Greptile flagged as
// 400-ing on every unchanged save.
func testPackageWithSecretEnumSchema(t *testing.T, id string) *bytes.Buffer {
t.Helper()
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
manifestYAML := fmt.Sprintf(`
id: %s
api_version: 1
version: 1.0.0
display_name: Secret Enum Plugin
capabilities:
state: true
config_schema:
type: object
required: ["env"]
properties:
env:
type: string
secret: true
enum: ["dev", "ops"]
runtime:
type: binary
executables:
%s: server/plugin
`, id, 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
}
func TestServiceUpdateConfigUnchangedSecretEnumFieldSaves(t *testing.T) {
svc, _, _ := newTestService(t)
svc.SetSecrets(newFakeSecretRevealer())
if _, err := svc.Install(context.Background(), testPackageWithSecretEnumSchema(t, "kandev-plugin-se")); err != nil {
t.Fatalf("Install: %v", err)
}
ctx := context.Background()
// First save with a valid enum value.
if err := svc.UpdateConfig(ctx, "kandev-plugin-se", map[string]any{"env": "dev"}); err != nil {
t.Fatalf("initial UpdateConfig: %v", err)
}
// Re-save unchanged (mask): the stored value is now a vault ref, which must
// not 400 on the enum check.
if err := svc.UpdateConfig(ctx, "kandev-plugin-se", map[string]any{"env": configSecretMask}); err != nil {
t.Fatalf("unchanged re-save of a secret+enum field must succeed, got %v", err)
}
// Changing to an out-of-enum value is still rejected.
if err := svc.UpdateConfig(ctx, "kandev-plugin-se", map[string]any{"env": "prod"}); !errors.Is(err, ErrConfigInvalid) {
t.Fatalf("out-of-enum value should be rejected, got %v", err)
}
}
func TestValidateConfigSchemaRejectsNonStringSecretValues(t *testing.T) {
schema := map[string]any{
"properties": map[string]any{
"pin": map[string]any{"type": "integer", "secret": true},
},
}
// A non-string secret would bypass vault storage and persist cleartext —
// it must be rejected before it can ever reach the store.
if err := validateConfigSchema("test-plugin", map[string]any{"pin": float64(1234)}, schema); !errors.Is(err, ErrConfigInvalid) {
t.Fatalf("non-string secret must be rejected, got %v", err)
}
// Absent stays allowed (an explicit null is already rejected by the
// property type check — clients omit unset keys).
if err := validateConfigSchema("test-plugin", map[string]any{}, schema); err != nil {
t.Fatalf("absent secret should be fine, got %v", err)
}
}
func TestServiceUpdateConfigNonStringSecretRejectedNothingPersisted(t *testing.T) {
svc, fsStore, vault := newTestServiceWithVault(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
// webhook_key is declared type string + format password; submit a number.
err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{
"github_token": "ghp_x", "webhook_key": float64(42),
})
if !errors.Is(err, ErrConfigInvalid) {
t.Fatalf("error = %v, want ErrConfigInvalid", err)
}
stored, err := fsStore.GetConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if len(stored) != 0 {
t.Fatalf("rejected config must not persist, got %v", stored)
}
if _, ok := vault.get(pluginConfigSecretID("kandev-plugin-github", "github_token")); ok {
t.Fatalf("rejected config must not reach the vault")
}
}
// failingVault wraps fakeSecretRevealer to inject failures for the
// fail-closed uninstall and delete-after-commit paths.
type failingVault struct {
*fakeSecretRevealer
listErr error
}
func (v *failingVault) ListIDs(ctx context.Context) ([]string, error) {
if v.listErr != nil {
return nil, v.listErr
}
return v.fakeSecretRevealer.ListIDs(ctx)
}
func TestServiceUninstallFailsClosedWhenSecretCleanupFails(t *testing.T) {
svc, _, rt := newTestService(t)
vault := &failingVault{fakeSecretRevealer: newFakeSecretRevealer(), listErr: errors.New("vault down")}
svc.SetSecrets(vault)
rec := installConfigPlugin(t, svc, "kandev-plugin-github")
err := svc.Uninstall(context.Background(), "kandev-plugin-github")
if err == nil {
t.Fatalf("Uninstall must fail when secret cleanup cannot run")
}
// The process is stopped BEFORE the vault purge, so the plugin can't race
// the cleanup by writing a fresh secret — even on the failure path.
if !rt.stopped("kandev-plugin-github") {
t.Fatalf("plugin must be stopped before the vault purge, even when the purge fails")
}
// Since the process was stopped, the persisted status must reflect that
// (error) rather than lie that the plugin is still active.
stoppedRec, getErr := svc.Get("kandev-plugin-github")
if getErr != nil {
t.Fatalf("record should survive a failed uninstall, got %v", getErr)
}
if stoppedRec.Status != StatusError {
t.Fatalf("status = %q, want error after an aborted uninstall stopped the process", stoppedRec.Status)
}
if _, statErr := os.Stat(rec.InstallPath); statErr != nil {
t.Fatalf("package dir should survive a failed uninstall, got %v", statErr)
}
// Vault recovers -> retry succeeds.
vault.listErr = nil
if err := svc.Uninstall(context.Background(), "kandev-plugin-github"); err != nil {
t.Fatalf("retry after vault recovery should succeed: %v", err)
}
}
// failingStore wraps store.Store to fail SetConfig, proving vault entries
// referenced by the still-current config survive a failed commit.
type failingStore struct {
store.Store
setConfigErr error
}
func (s *failingStore) SetConfig(id string, config map[string]any) error {
if s.setConfigErr != nil {
return s.setConfigErr
}
return s.Store.SetConfig(id, config)
}
func TestServiceUpdateConfigFailedCommitKeepsReferencedVaultEntry(t *testing.T) {
svc, fsStore, vault := newTestServiceWithVault(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
ctx := context.Background()
// Store token + optional webhook_key secret.
if err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{
"github_token": "ghp_x", "webhook_key": "whsec_1",
}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
// Now remove webhook_key, but make the config commit fail.
failing := &failingStore{Store: fsStore, setConfigErr: errors.New("disk full")}
svc.store = failing
err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{
"github_token": configSecretMask,
})
if err == nil {
t.Fatalf("UpdateConfig should surface the commit failure")
}
// The still-current config references webhook_key's vault entry — it
// must NOT have been deleted (delete happens only after a successful
// commit).
if _, ok := vault.get(pluginConfigSecretID("kandev-plugin-github", "webhook_key")); !ok {
t.Fatalf("vault entry referenced by the current config must survive a failed commit")
}
// Commit works again -> removal now deletes the entry.
svc.store = fsStore
if err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{
"github_token": configSecretMask,
}); err != nil {
t.Fatalf("UpdateConfig retry: %v", err)
}
if _, ok := vault.get(pluginConfigSecretID("kandev-plugin-github", "webhook_key")); ok {
t.Fatalf("vault entry should be deleted after the successful commit")
}
}
func TestServiceUpdateConfigFailedCommitRollsBackOverwrittenSecret(t *testing.T) {
svc, fsStore, vault := newTestServiceWithVault(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
ctx := context.Background()
if err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{"github_token": "ghp_old"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
vaultID := pluginConfigSecretID("kandev-plugin-github", "github_token")
// Overwrite the token with a new value, but make the config commit fail.
svc.store = &failingStore{Store: fsStore, setConfigErr: errors.New("disk full")}
err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{"github_token": "ghp_new"})
if err == nil {
t.Fatalf("UpdateConfig should surface the commit failure")
}
// The config file was never rewritten, so the vault must resolve to the
// OLD value — a failed request must not change effective config.
if v, _ := vault.get(vaultID); v != "ghp_old" {
t.Fatalf("vault value = %q, want rolled-back ghp_old (a failed commit must not change effective config)", v)
}
}
func TestServiceUpdateConfigFailedCommitRollsBackNewlyCreatedSecret(t *testing.T) {
svc, fsStore, vault := newTestServiceWithVault(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
ctx := context.Background()
if err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{"github_token": "ghp_x"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
// Add a brand-new optional secret (no prior vault entry) with a failing commit.
svc.store = &failingStore{Store: fsStore, setConfigErr: errors.New("disk full")}
err := svc.UpdateConfig(ctx, "kandev-plugin-github", map[string]any{
"github_token": configSecretMask, "webhook_key": "whsec_new",
})
if err == nil {
t.Fatalf("UpdateConfig should surface the commit failure")
}
// The newly-created vault entry had no prior value, so rollback deletes
// it — no orphan left behind by a failed request.
if _, ok := vault.get(pluginConfigSecretID("kandev-plugin-github", "webhook_key")); ok {
t.Fatalf("newly-created secret must be rolled back (deleted) on a failed commit")
}
}
func TestServiceUpdateConfigFailsClosedWithoutVault(t *testing.T) {
svc, fsStore, _ := newTestService(t) // no vault wired
installConfigPlugin(t, svc, "kandev-plugin-github")
// A plugin declaring a secret field cannot store config without a vault:
// fail closed rather than persist the secret in cleartext.
err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_x"})
if !errors.Is(err, errSecretVaultRequired) {
t.Fatalf("error = %v, want errSecretVaultRequired", err)
}
stored, err := fsStore.GetConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if len(stored) != 0 {
t.Fatalf("nothing must persist when failing closed, got %v", stored)
}
}
// ctxAwareVault wraps fakeSecretRevealer and honors context cancellation on
// Set/Delete, so a test can prove rollback writes run on a context detached
// from a cancelled request.
type ctxAwareVault struct {
*fakeSecretRevealer
}
func (v *ctxAwareVault) Set(ctx context.Context, id, name, value string) error {
if err := ctx.Err(); err != nil {
return err
}
return v.fakeSecretRevealer.Set(ctx, id, name, value)
}
func (v *ctxAwareVault) Delete(ctx context.Context, id string) error {
if err := ctx.Err(); err != nil {
return err
}
return v.fakeSecretRevealer.Delete(ctx, id)
}
func TestStoreConfigSecretsRollbackUsesDetachedContext(t *testing.T) {
svc, _, _ := newTestService(t)
vault := &ctxAwareVault{fakeSecretRevealer: newFakeSecretRevealer()}
svc.SetSecrets(vault)
rec := installConfigPlugin(t, svc, "kandev-plugin-github")
vaultID := pluginConfigSecretID("kandev-plugin-github", "github_token")
vault.set(vaultID, "ghp_old")
// Stage a new value under a context that we then cancel before rollback,
// simulating the operator's browser closing mid-save.
ctx, cancel := context.WithCancel(context.Background())
_, _, rollback, err := svc.storeConfigSecrets(ctx, rec, map[string]any{"github_token": "ghp_new"})
if err != nil {
t.Fatalf("storeConfigSecrets: %v", err)
}
cancel()
// Rollback must still restore the prior value despite the cancelled ctx —
// it runs on a detached context.
if rbErr := rollback(); rbErr != nil {
t.Fatalf("rollback should succeed on a detached context, got %v", rbErr)
}
if v, _ := vault.get(vaultID); v != "ghp_old" {
t.Fatalf("vault value = %q, want restored ghp_old", v)
}
}
// revealErrVault injects a non-not-found Reveal error for one id, to prove
// storeConfigSecrets refuses to write when a prior value cannot be
// determined (rather than risk a rollback that deletes a real secret).
type revealErrVault struct {
*fakeSecretRevealer
failRevealID string
}
func (v *revealErrVault) Reveal(ctx context.Context, id string) (string, error) {
if id == v.failRevealID {
return "", errors.New("vault backend unavailable")
}
return v.fakeSecretRevealer.Reveal(ctx, id)
}
func TestServiceUpdateConfigAbortsWhenPriorSecretUnreadable(t *testing.T) {
svc, fsStore, _ := newTestService(t)
vaultID := pluginConfigSecretID("kandev-plugin-github", "github_token")
vault := &revealErrVault{fakeSecretRevealer: newFakeSecretRevealer(), failRevealID: vaultID}
svc.SetSecrets(vault)
installConfigPlugin(t, svc, "kandev-plugin-github")
err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_x"})
if err == nil {
t.Fatalf("UpdateConfig must abort when the prior secret cannot be read")
}
if _, ok := vault.get(vaultID); ok {
t.Fatalf("no vault write should happen when the snapshot read fails")
}
stored, err := fsStore.GetConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if len(stored) != 0 {
t.Fatalf("nothing must persist when the update aborts, got %v", stored)
}
}
@@ -0,0 +1,411 @@
package plugins
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
goruntime "runtime"
"testing"
"github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest"
"github.com/kandev/kandev/internal/plugins/store"
)
// testConfigSchema mirrors the JSON shape a manifest's config_schema decodes
// to: a JSON-Schema-like object with a secret token field (the GitHub-PAT
// case), a plain string, an integer, an enum, and a boolean.
func testConfigSchema() map[string]any {
return map[string]any{
"type": "object",
"required": []any{"github_token"},
"properties": map[string]any{
"github_token": map[string]any{"type": "string", "secret": true},
"webhook_key": map[string]any{"type": "string", "format": "password"},
"org": map[string]any{"type": "string"},
"max_items": map[string]any{"type": "integer"},
"channel": map[string]any{"type": "string", "enum": []any{"dev", "ops"}},
"verbose": map[string]any{"type": "boolean"},
},
}
}
// testPackageWithConfigSchema builds a valid runtime-managed package whose
// manifest declares the config schema above.
func testPackageWithConfigSchema(t *testing.T, id string) *bytes.Buffer {
t.Helper()
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
manifestYAML := fmt.Sprintf(`
id: %s
api_version: 1
version: 1.0.0
display_name: Test Plugin
capabilities:
state: true
config_schema:
type: object
required: ["github_token"]
properties:
github_token:
type: string
secret: true
webhook_key:
type: string
format: password
org:
type: string
max_items:
type: integer
channel:
type: string
enum: ["dev", "ops"]
verbose:
type: boolean
runtime:
type: binary
executables:
%s: server/plugin
`, id, 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
}
func installConfigPlugin(t *testing.T, svc *Service, id string) *store.Record {
t.Helper()
rec, err := svc.Install(context.Background(), testPackageWithConfigSchema(t, id))
if err != nil {
t.Fatalf("Install(%q): %v", id, err)
}
return rec
}
// --- helper unit tests ---
func TestSecretPropertyKeysDetectsSecretAndPasswordFormat(t *testing.T) {
keys := secretPropertyKeys(testConfigSchema())
if !keys["github_token"] || !keys["webhook_key"] {
t.Fatalf("secretPropertyKeys() = %v, want github_token and webhook_key", keys)
}
if keys["org"] {
t.Fatalf("secretPropertyKeys() flagged non-secret field org")
}
}
func TestMaskSecretsMasksOnlyNonEmptySecretStrings(t *testing.T) {
masked := maskSecrets(map[string]any{
"github_token": "ghp_real",
"webhook_key": "",
"org": "kdlbs",
}, testConfigSchema())
if masked["github_token"] != configSecretMask {
t.Fatalf("github_token = %v, want mask", masked["github_token"])
}
if masked["webhook_key"] != "" {
t.Fatalf("empty secret should stay empty, got %v", masked["webhook_key"])
}
if masked["org"] != "kdlbs" {
t.Fatalf("org = %v, want kdlbs", masked["org"])
}
}
func TestMergeMaskedSecretsKeepsStoredValueForMask(t *testing.T) {
merged := mergeMaskedSecrets(
map[string]any{"github_token": configSecretMask, "org": "new-org"},
map[string]any{"github_token": "ghp_real", "org": "old-org"},
testConfigSchema(),
)
if merged["github_token"] != "ghp_real" {
t.Fatalf("github_token = %v, want stored ghp_real", merged["github_token"])
}
if merged["org"] != "new-org" {
t.Fatalf("org = %v, want new-org (full replace for non-secrets)", merged["org"])
}
}
func TestMergeMaskedSecretsDropsMaskWithNoStoredValue(t *testing.T) {
merged := mergeMaskedSecrets(
map[string]any{"github_token": configSecretMask},
map[string]any{},
testConfigSchema(),
)
if _, present := merged["github_token"]; present {
t.Fatalf("mask with no stored value should be dropped, got %v", merged)
}
}
func TestValidateConfigSchema(t *testing.T) {
schema := testConfigSchema()
cases := []struct {
name string
config map[string]any
wantErr bool
}{
{"valid full", map[string]any{
"github_token": "ghp_x", "org": "kdlbs", "max_items": float64(10),
"channel": "dev", "verbose": true,
}, false},
{"missing required", map[string]any{"org": "kdlbs"}, true},
{"wrong string type", map[string]any{"github_token": "x", "org": float64(3)}, true},
{"wrong boolean type", map[string]any{"github_token": "x", "verbose": "yes"}, true},
{"non-integral integer", map[string]any{"github_token": "x", "max_items": 2.5}, true},
{"integral float accepted", map[string]any{"github_token": "x", "max_items": float64(5)}, false},
{"yaml int accepted", map[string]any{"github_token": "x", "max_items": 5}, false},
{"enum mismatch", map[string]any{"github_token": "x", "channel": "prod"}, true},
{"undeclared key allowed", map[string]any{"github_token": "x", "extra": "ok"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateConfigSchema("test-plugin", tc.config, schema)
if tc.wantErr && !errors.Is(err, ErrConfigInvalid) {
t.Fatalf("error = %v, want ErrConfigInvalid", err)
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestValidateConfigSchemaNilSchemaIsPermissive(t *testing.T) {
if err := validateConfigSchema("test-plugin", map[string]any{"anything": 1}, nil); err != nil {
t.Fatalf("nil schema should accept anything, got %v", err)
}
}
// --- service tests ---
func TestServiceGetMaskedConfigMasksSecrets(t *testing.T) {
svc, fsStore, vault := newTestServiceWithVault(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{
"github_token": "ghp_real", "org": "kdlbs",
})
if err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
masked, err := svc.GetMaskedConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetMaskedConfig: %v", err)
}
if masked["github_token"] != configSecretMask {
t.Fatalf("masked github_token = %v, want mask", masked["github_token"])
}
if masked["org"] != "kdlbs" {
t.Fatalf("masked org = %v, want kdlbs", masked["org"])
}
// The config file holds a vault ref, never the cleartext; the vault holds
// the cleartext.
stored, err := fsStore.GetConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("store GetConfig: %v", err)
}
if stored["github_token"] != configVaultRef("kandev-plugin-github", "github_token") {
t.Fatalf("stored github_token = %v, want vault ref", stored["github_token"])
}
if v, _ := vault.get(pluginConfigSecretID("kandev-plugin-github", "github_token")); v != "ghp_real" {
t.Fatalf("vault value = %q, want cleartext ghp_real", v)
}
}
func TestServiceUpdateConfigPreservesMaskedSecret(t *testing.T) {
svc, fsStore, vault := newTestServiceWithVault(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
}
must(svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_real"}))
// Re-submitting the form: token comes back as the mask, org changes.
must(svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{
"github_token": configSecretMask, "org": "kdlbs",
}))
// The masked round trip keeps the vault's cleartext value; org updates.
if v, _ := vault.get(pluginConfigSecretID("kandev-plugin-github", "github_token")); v != "ghp_real" {
t.Fatalf("vault value = %q, want preserved ghp_real", v)
}
stored, err := fsStore.GetConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if stored["org"] != "kdlbs" {
t.Fatalf("stored org = %v, want kdlbs", stored["org"])
}
}
func TestServiceUpdateConfigInvalidRejectedAndNotPersisted(t *testing.T) {
svc, fsStore, _ := newTestService(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"org": "no-token"})
if !errors.Is(err, ErrConfigInvalid) {
t.Fatalf("error = %v, want ErrConfigInvalid", err)
}
stored, err := fsStore.GetConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if len(stored) != 0 {
t.Fatalf("invalid config must not persist, got %v", stored)
}
}
func TestServiceUpdateConfigRestartsRunningPlugin(t *testing.T) {
svc, _, rt := newTestService(t)
svc.SetSecrets(newFakeSecretRevealer())
installConfigPlugin(t, svc, "kandev-plugin-github") // Install activates -> running
if err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_x"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
if !rt.stopped("kandev-plugin-github") {
t.Fatalf("running plugin should be stopped on config change")
}
if got := rt.startCallCount("kandev-plugin-github"); got != 2 {
t.Fatalf("start calls = %d, want 2 (install + config restart)", got)
}
rec, _ := svc.Get("kandev-plugin-github")
if rec.Status != StatusActive {
t.Fatalf("status = %q, want active after restart", rec.Status)
}
}
func TestServiceUpdateConfigDoesNotSpawnStoppedPlugin(t *testing.T) {
svc, _, rt := newTestService(t)
svc.SetSecrets(newFakeSecretRevealer())
installConfigPlugin(t, svc, "kandev-plugin-github")
if err := svc.Disable("kandev-plugin-github"); err != nil {
t.Fatalf("Disable: %v", err)
}
before := rt.startCallCount("kandev-plugin-github")
if err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_x"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
if got := rt.startCallCount("kandev-plugin-github"); got != before {
t.Fatalf("start calls = %d, want %d (no spawn for a stopped plugin)", got, before)
}
}
func TestServiceUpdateConfigRestartFailurePersistsConfigAndSetsError(t *testing.T) {
svc, fsStore, rt := newTestService(t)
vault := newFakeSecretRevealer()
svc.SetSecrets(vault)
installConfigPlugin(t, svc, "kandev-plugin-github")
rt.setStartErr("kandev-plugin-github", errors.New("spawn boom"))
err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_x"})
if err == nil {
t.Fatalf("UpdateConfig should surface the restart failure")
}
// The config commit and vault write succeeded (only the restart failed):
// the config file keeps the ref, the vault keeps the value.
stored, err := fsStore.GetConfig("kandev-plugin-github")
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if stored["github_token"] != configVaultRef("kandev-plugin-github", "github_token") {
t.Fatalf("config should persist despite restart failure, got %v", stored)
}
if v, _ := vault.get(pluginConfigSecretID("kandev-plugin-github", "github_token")); v != "ghp_x" {
t.Fatalf("vault value = %q, want ghp_x", v)
}
rec, _ := svc.Get("kandev-plugin-github")
if rec.Status != StatusError {
t.Fatalf("status = %q, want error after failed restart", rec.Status)
}
}
// --- host RPC tests ---
func TestPluginHostGetConfigReturnsCleartextConfig(t *testing.T) {
svc, fsStore, vault := newTestServiceWithVault(t)
rec := installConfigPlugin(t, svc, "kandev-plugin-github")
if err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_real"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
// The host resolves the config file's vault ref back to cleartext for the
// plugin process.
host := &pluginHost{
pluginID: "kandev-plugin-github",
configSchema: rec.ConfigSchema,
configs: fsStore,
secrets: vault,
}
config, err := host.GetConfig(context.Background())
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if config["github_token"] != "ghp_real" {
t.Fatalf("host GetConfig github_token = %v, want cleartext ghp_real", config["github_token"])
}
}
func TestPluginHostGetConfigWithoutStoreReturnsEmpty(t *testing.T) {
host := &pluginHost{pluginID: "p"}
config, err := host.GetConfig(context.Background())
if err != nil {
t.Fatalf("GetConfig: %v", err)
}
if config == nil || len(config) != 0 {
t.Fatalf("GetConfig = %v, want empty non-nil map", config)
}
}
// --- handler tests ---
func TestGetConfigHandlerReturnsMaskedConfig(t *testing.T) {
router, svc := newTestRouter(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
if err := svc.UpdateConfig(context.Background(), "kandev-plugin-github", map[string]any{"github_token": "ghp_real"}); err != nil {
t.Fatalf("UpdateConfig: %v", err)
}
rec := doRequest(router, http.MethodGet, "/api/plugins/kandev-plugin-github/config", "", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !bytes.Contains([]byte(body), []byte(configSecretMask)) {
t.Fatalf("body should carry the mask, got %s", body)
}
if bytes.Contains([]byte(body), []byte("ghp_real")) {
t.Fatalf("cleartext secret leaked on the operator API: %s", body)
}
}
func TestGetConfigHandlerMissingReturns404(t *testing.T) {
router, _ := newTestRouter(t)
rec := doRequest(router, http.MethodGet, "/api/plugins/missing/config", "", nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestUpdateConfigHandlerInvalidSchemaReturns400(t *testing.T) {
router, svc := newTestRouter(t)
installConfigPlugin(t, svc, "kandev-plugin-github")
rec := doRequest(router, http.MethodPatch, "/api/plugins/kandev-plugin-github",
`{"config":{"org":"missing-token"}}`, nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body=%s", rec.Code, rec.Body.String())
}
}
+19 -2
View File
@@ -48,6 +48,7 @@ func RegisterRoutes(router *gin.Engine, svc *Service, _ Deliverer, log *logger.L
api.GET("", ctrl.list)
api.GET("/tools", ctrl.listTools)
api.GET("/:id", ctrl.get)
api.GET("/:id/config", ctrl.getConfig)
api.PATCH("/:id", ctrl.updateConfig)
api.DELETE("/:id", ctrl.uninstall)
api.POST("/:id/enable", ctrl.enable)
@@ -155,13 +156,29 @@ func (c *Controller) get(ctx *gin.Context) {
ctx.JSON(http.StatusOK, record)
}
// getConfig serves GET /api/plugins/:id/config: the stored operator config
// with secret values (per the manifest's config_schema) masked — cleartext
// secrets never leave the backend on this surface.
func (c *Controller) getConfig(ctx *gin.Context) {
config, err := c.svc.GetMaskedConfig(ctx.Param("id"))
if err != nil {
c.writeLookupError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"config": config})
}
func (c *Controller) updateConfig(ctx *gin.Context) {
var req UpdateConfigRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if err := c.svc.UpdateConfig(ctx.Param("id"), req.Config); err != nil {
if err := c.svc.UpdateConfig(ctx.Request.Context(), ctx.Param("id"), req.Config); err != nil {
if errors.Is(err, ErrConfigInvalid) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.writeLookupError(ctx, err)
return
}
@@ -169,7 +186,7 @@ func (c *Controller) updateConfig(ctx *gin.Context) {
}
func (c *Controller) uninstall(ctx *gin.Context) {
if err := c.svc.Uninstall(ctx.Param("id")); err != nil {
if err := c.svc.Uninstall(ctx.Request.Context(), ctx.Param("id")); err != nil {
c.writeLookupError(ctx, err)
return
}
@@ -48,6 +48,10 @@ func newTestRouter(t *testing.T) (*gin.Engine, *Service) {
gin.SetMode(gin.TestMode)
svc, _, _ := newTestService(t)
svc.SetState(newTestStateStore(t))
// A vault is mandatory for plugins with secret config fields (Service
// fails closed without one), so wire an in-memory one, matching prod
// where Provide always attaches the shared vault.
svc.SetSecrets(newFakeSecretRevealer())
router := gin.New()
RegisterRoutes(router, svc, nil, testLogger(t))
return router, svc
+122 -1
View File
@@ -40,10 +40,20 @@ type pluginHost struct {
pluginID string
capabilities manifest.Capabilities
// configSchema is the plugin's manifest config_schema, used by
// GetConfig to know which fields are secret (and therefore stored as
// vault references to resolve back to cleartext).
configSchema map[string]any
state *state.Store
secrets SecretRevealer
secrets SecretVault
bus bus.EventBus
// configs reads the plugin's operator-editable config for the ungated
// GetConfig RPC. Satisfied by store.Store; nil in tests that build a
// pluginHost without one (GetConfig then returns an empty map).
configs configReader
// Host data API (ADR 0043) service-layer dependencies, wired by
// Service.hostForPlugin from Service.SetDataSources. See host_data.go.
taskData taskDataSource
@@ -127,6 +137,117 @@ func (h *pluginHost) ListState(ctx context.Context, scope, scopeID string) ([]pl
return out, nil
}
// configReader is the narrow slice of store.Store pluginHost needs for the
// GetConfig RPC.
type configReader interface {
GetConfig(id string) (map[string]any, error)
}
// GetConfig returns the plugin's own operator-editable config, secret values
// included — this RPC is how a configured credential (e.g. a PAT) reaches
// the plugin process. Secret fields are stored as vault references
// (configVaultRef) and resolved back to cleartext here; this resolution is
// deliberately NOT gated on capabilities.secrets, since it is the plugin's
// own config, set by the operator specifically for it.
func (h *pluginHost) GetConfig(ctx context.Context) (map[string]any, error) {
if h.configs == nil {
return map[string]any{}, nil
}
config, err := h.configs.GetConfig(h.pluginID)
if err != nil {
return nil, err
}
if config == nil {
return map[string]any{}, nil
}
return h.resolveConfigSecrets(ctx, config)
}
// resolveConfigSecrets replaces each secret config field's vault reference
// with the cleartext value from the vault. A resolution failure is an error
// (not a silent drop): the plugin must never mistake a broken vault for
// "the operator cleared this setting". Works on a copy — writing cleartext
// into the caller's map would leak it into any future cached configReader
// value, even though today's FSStore.GetConfig always returns a fresh map.
func (h *pluginHost) resolveConfigSecrets(ctx context.Context, config map[string]any) (map[string]any, error) {
out := make(map[string]any, len(config))
for k, v := range config {
out[k] = v
}
for field := range secretPropertyKeys(h.configSchema) {
if !isConfigVaultRef(h.pluginID, field, out[field]) {
continue
}
if h.secrets == nil {
return nil, errors.New("plugins: secret vault not configured")
}
cleartext, err := h.secrets.Reveal(ctx, pluginConfigSecretID(h.pluginID, field))
if err != nil {
return nil, fmt.Errorf("plugins: resolve secret config field %q: %w", field, err)
}
out[field] = cleartext
}
return out, nil
}
// GetSecret reads a plugin-owned secret previously stored via SetSecret.
// found is false (with a nil error) when the key was never set. Requires
// capabilities.secrets, like every secret-touching RPC.
func (h *pluginHost) GetSecret(ctx context.Context, key string) (string, bool, error) {
if err := h.checkSecretAccess(key); err != nil {
return "", false, err
}
value, err := h.secrets.Reveal(ctx, pluginSecretID(h.pluginID, key))
if err != nil {
if isSecretNotFound(err) {
return "", false, nil
}
return "", false, err
}
return value, true, nil
}
// SetSecret upserts a plugin-owned secret into kandev's encrypted vault,
// namespaced to this plugin (vault id "plugin:<id>:secret:<key>") — a
// plugin can never write outside its own namespace.
func (h *pluginHost) SetSecret(ctx context.Context, key, value string) error {
if err := h.checkSecretAccess(key); err != nil {
return err
}
vaultID := pluginSecretID(h.pluginID, key)
return h.secrets.Set(ctx, vaultID, vaultID, value)
}
// DeleteSecret removes a plugin-owned secret. Deleting a missing key is not
// an error, matching DeleteState's semantics.
func (h *pluginHost) DeleteSecret(ctx context.Context, key string) error {
if err := h.checkSecretAccess(key); err != nil {
return err
}
err := h.secrets.Delete(ctx, pluginSecretID(h.pluginID, key))
if isSecretNotFound(err) {
return nil
}
return err
}
// checkSecretAccess is the shared gate for the plugin-scoped secret RPCs:
// capability secrets declared, a wired vault, and a key that is a single
// sane identifier (pluginSecretKeyPattern) so it can never smuggle
// separators into the vault-id namespace.
func (h *pluginHost) checkSecretAccess(key string) error {
if !h.capabilities.Secrets {
return permissionDenied("secrets")
}
if h.secrets == nil {
return errors.New("plugins: secret vault not configured")
}
if !pluginSecretKeyPattern.MatchString(key) {
return status.Errorf(codes.InvalidArgument, "invalid secret key %q", key)
}
return nil
}
func (h *pluginHost) RevealSecret(ctx context.Context, ref string) (string, error) {
if !h.capabilities.Secrets {
return "", permissionDenied("secrets")
+1 -1
View File
@@ -43,7 +43,7 @@ const pluginsSubdir = "plugins"
// "Extension points" doc comment on Service for how backendapp attaches
// them after calling Provide. cleanup stops the runtime manager (kills any
// spawned processes); callers should register it with addCleanup.
func Provide(cfg *config.Config, dbPool *db.Pool, secrets SecretRevealer, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error) {
func Provide(cfg *config.Config, dbPool *db.Pool, secrets SecretVault, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error) {
dir := filepath.Join(cfg.ResolvedHomeDir(), pluginsSubdir)
pluginStore := store.NewFSStore(dir)
pluginStore.SetLogger(log)
@@ -55,6 +55,10 @@ func (h *fakeHost) DeleteState(context.Context, string, string, string) error {
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 }
+296 -16
View File
@@ -79,7 +79,7 @@ type Service struct {
deliverer Deliverer
runtime PluginRuntime
secrets SecretRevealer
secrets SecretVault
// Host data API (ADR 0043) service-layer dependencies, wired via
// SetDataSources and handed to every pluginHost hostForPlugin builds.
@@ -174,8 +174,8 @@ func (s *Service) StateStore() *state.Store {
return s.state
}
// SetSecrets wires the secret revealer Provide was constructed with.
func (s *Service) SetSecrets(v SecretRevealer) {
// SetSecrets wires the secret vault Provide was constructed with.
func (s *Service) SetSecrets(v SecretVault) {
s.secrets = v
}
@@ -294,9 +294,11 @@ func (s *Service) hostForPlugin(pluginID string) pluginsdk.Host {
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,
@@ -331,16 +333,241 @@ func (s *Service) Get(id string) (*store.Record, error) {
return rec, nil
}
// UpdateConfig replaces the operator-editable config for id.
func (s *Service) UpdateConfig(id string, config map[string]any) error {
// 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()
if _, err := s.Get(id); err != nil {
rec, err := s.Get(id)
if err != nil {
return err
}
return s.store.SetConfig(id, config)
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
@@ -516,15 +743,24 @@ func validateInstallURL(raw string) error {
return nil
}
// Uninstall stops id's process (if running), 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 here 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 matters so a plugin reinstalled
// under the same id (or an id later reused by a different plugin) never
// silently inherits stale state from this install.
func (s *Service) Uninstall(id string) error {
// 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()
@@ -532,9 +768,24 @@ func (s *Service) Uninstall(id string) error {
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)
}
@@ -547,6 +798,35 @@ func (s *Service) Uninstall(id string) error {
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.
+43 -9
View File
@@ -176,7 +176,7 @@ func (r *fakeRuntime) stopped(id string) bool {
return false
}
// fakeSecretRevealer is a minimal in-memory SecretRevealer for tests.
// fakeSecretRevealer is a minimal in-memory SecretVault for tests.
type fakeSecretRevealer struct {
mu sync.Mutex
values map[string]string
@@ -197,11 +197,45 @@ func (v *fakeSecretRevealer) Reveal(_ context.Context, ref string) (string, erro
defer v.mu.Unlock()
value, ok := v.values[ref]
if !ok {
return "", store.ErrNotFound
return "", fmt.Errorf("secret not found: %s", ref)
}
return value, nil
}
func (v *fakeSecretRevealer) Set(_ context.Context, id, _, value string) error {
v.mu.Lock()
defer v.mu.Unlock()
v.values[id] = value
return nil
}
func (v *fakeSecretRevealer) Delete(_ context.Context, id string) error {
v.mu.Lock()
defer v.mu.Unlock()
if _, ok := v.values[id]; !ok {
return fmt.Errorf("secret not found: %s", id)
}
delete(v.values, id)
return nil
}
func (v *fakeSecretRevealer) ListIDs(_ context.Context) ([]string, error) {
v.mu.Lock()
defer v.mu.Unlock()
ids := make([]string, 0, len(v.values))
for id := range v.values {
ids = append(ids, id)
}
return ids, nil
}
func (v *fakeSecretRevealer) get(id string) (string, bool) {
v.mu.Lock()
defer v.mu.Unlock()
value, ok := v.values[id]
return value, ok
}
// testPackage builds a valid, runtime-managed plugin tar.gz for the CURRENT
// host platform (so pkgtar.Install's platform check passes), with a
// capabilities.events subscription and (optionally) a UI bundle.
@@ -293,7 +327,7 @@ func TestServiceUpdateConfigPersists(t *testing.T) {
svc, fsStore, _ := newTestService(t)
installTestPlugin(t, svc, "kandev-plugin-slack")
if err := svc.UpdateConfig("kandev-plugin-slack", map[string]any{"default_channel": "#dev"}); err != nil {
if err := svc.UpdateConfig(context.Background(), "kandev-plugin-slack", map[string]any{"default_channel": "#dev"}); err != nil {
t.Fatalf("UpdateConfig() unexpected error: %v", err)
}
@@ -308,7 +342,7 @@ func TestServiceUpdateConfigPersists(t *testing.T) {
func TestServiceUpdateConfigMissingReturnsNotFound(t *testing.T) {
svc, _, _ := newTestService(t)
err := svc.UpdateConfig("missing", map[string]any{"a": "b"})
err := svc.UpdateConfig(context.Background(), "missing", map[string]any{"a": "b"})
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("UpdateConfig() error = %v, want store.ErrNotFound", err)
}
@@ -319,7 +353,7 @@ func TestServiceUninstallStopsRuntimeRemovesPackageAndRecord(t *testing.T) {
rec := installTestPlugin(t, svc, "kandev-plugin-slack")
installDir := filepath.Dir(rec.InstallPath) // .../plugins/kandev-plugin-slack
if err := svc.Uninstall("kandev-plugin-slack"); err != nil {
if err := svc.Uninstall(context.Background(), "kandev-plugin-slack"); err != nil {
t.Fatalf("Uninstall() unexpected error: %v", err)
}
@@ -351,7 +385,7 @@ func TestServiceUninstallDeletesPluginState(t *testing.T) {
t.Fatalf("seed plugin_state: %v", err)
}
if err := svc.Uninstall("kandev-plugin-slack"); err != nil {
if err := svc.Uninstall(context.Background(), "kandev-plugin-slack"); err != nil {
t.Fatalf("Uninstall() unexpected error: %v", err)
}
@@ -366,7 +400,7 @@ func TestServiceUninstallDeletesPluginState(t *testing.T) {
func TestServiceUninstallMissingReturnsNotFound(t *testing.T) {
svc, _, _ := newTestService(t)
if err := svc.Uninstall("missing"); !errors.Is(err, store.ErrNotFound) {
if err := svc.Uninstall(context.Background(), "missing"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("Uninstall() error = %v, want store.ErrNotFound", err)
}
}
@@ -636,7 +670,7 @@ func TestServiceUninstallNotifiesDelivererRefresh(t *testing.T) {
deliverer := &fakeDeliverer{}
svc.SetDeliverer(deliverer)
if err := svc.Uninstall("kandev-plugin-slack"); err != nil {
if err := svc.Uninstall(context.Background(), "kandev-plugin-slack"); err != nil {
t.Fatalf("Uninstall(): %v", err)
}
@@ -654,7 +688,7 @@ func TestServiceWithoutDelivererDoesNotPanic(t *testing.T) {
if err := svc.Disable("kandev-plugin-slack"); err != nil {
t.Fatalf("Disable(): %v", err)
}
if err := svc.Uninstall("kandev-plugin-slack"); err != nil {
if err := svc.Uninstall(context.Background(), "kandev-plugin-slack"); err != nil {
t.Fatalf("Uninstall(): %v", err)
}
}
@@ -272,7 +272,10 @@ func (s *FSStore) GetConfig(id string) (map[string]any, error) {
// atomic (tmp file + rename within the store dir, via writeFileAtomic), the
// same guarantee writeRecord gives Save, and is additionally serialized by
// s.mu so two concurrent SetConfig calls for the same id (e.g. two operator
// UpdateConfig PATCH requests racing) can never interleave.
// UpdateConfig PATCH requests racing) can never interleave. Mode 0600, like
// the record file: secret config fields are normally stored as encrypted-
// vault references (internal/plugins/config.go), but the no-vault fallback
// stores cleartext, so the file must not be world-readable either way.
func (s *FSStore) SetConfig(id string, config map[string]any) error {
if err := safePluginID(id); err != nil {
return err
@@ -287,7 +290,7 @@ func (s *FSStore) SetConfig(id string, config map[string]any) error {
if err != nil {
return fmt.Errorf("marshal plugin config: %w", err)
}
if err := writeFileAtomic(s.dir, s.configPath(id), data, 0o644); err != nil {
if err := writeFileAtomic(s.dir, s.configPath(id), data, 0o600); err != nil {
return fmt.Errorf("write plugin config: %w", err)
}
return nil
@@ -4,6 +4,7 @@ import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
@@ -240,6 +241,32 @@ func TestFSStore_SetConfigThenGetConfig_RoundTrips(t *testing.T) {
}
}
// The config file can carry cleartext secret values (config_schema fields
// marked secret, e.g. a PAT), so it must be owner-only like the record file
// — never world-readable.
func TestFSStore_SetConfig_WritesOwnerOnlyFile(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix permission bits not meaningful on windows")
}
dir := t.TempDir()
s := NewFSStore(dir)
if err := s.Save(testRecord("kandev-plugin-slack")); err != nil {
t.Fatalf("Save() unexpected error: %v", err)
}
if err := s.SetConfig("kandev-plugin-slack", map[string]any{"api_token": "ghp_secret"}); err != nil {
t.Fatalf("SetConfig() unexpected error: %v", err)
}
info, err := os.Stat(s.configPath("kandev-plugin-slack"))
if err != nil {
t.Fatalf("Stat() unexpected error: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("config file mode = %o, want 0600", perm)
}
}
func TestFSStore_GetConfig_NoConfigFileReturnsEmptyMap(t *testing.T) {
dir := t.TempDir()
s := NewFSStore(dir)
+11 -5
View File
@@ -101,12 +101,18 @@ type PluginRuntime interface {
StopAll()
}
// SecretRevealer is the minimal surface Service needs to resolve a secret
// reference to its cleartext value, for the Host.RevealSecret RPC.
// Satisfied by *internal/integrations/secretadapter.Adapter in production;
// tests use a fake.
type SecretRevealer interface {
// SecretVault is the surface Service needs on kandev's encrypted secret
// vault: Reveal for the Host.RevealSecret RPC and for resolving
// vault-backed config secrets, Set/Delete for the plugin-scoped
// GetSecret/SetSecret/DeleteSecret RPCs and for storing secret config
// fields, and ListIDs for uninstall cleanup of a plugin's vault namespace.
// Satisfied structurally by *internal/integrations/secretadapter.Adapter in
// production; tests use a fake.
type SecretVault interface {
Reveal(ctx context.Context, ref string) (string, error)
Set(ctx context.Context, id, name, value string) error
Delete(ctx context.Context, id string) error
ListIDs(ctx context.Context) ([]string, error)
}
// Deliverer is the minimal surface the event-delivery subsystem
+98 -1
View File
@@ -48,9 +48,36 @@ type Host interface {
// ListState returns every state entry for a scope/scopeID pair.
ListState(ctx context.Context, scope, scopeID string) ([]StateEntry, error)
// RevealSecret resolves a secret reference to its cleartext value.
// GetConfig returns the plugin's own operator-editable config: the
// values set in kandev's Settings > Plugins > <plugin> page against the
// manifest's config_schema. Returns an empty (non-nil) map when no
// config has been set yet. Ungated — a plugin can always read its own
// config, secret values included. Plugins should re-read config at
// startup: kandev restarts a running plugin when its config changes.
GetConfig(ctx context.Context) (map[string]any, error)
// RevealSecret resolves an operator-provided secret reference (e.g. a
// ref placed in config pointing at a shared kandev secret) to its
// cleartext value. For secrets the plugin itself owns, use
// GetSecret/SetSecret/DeleteSecret instead.
RevealSecret(ctx context.Context, ref string) (string, error)
// GetSecret reads a plugin-owned secret previously stored with
// SetSecret. found is false, err is nil when the key was never set.
// Requires the `secrets` capability.
GetSecret(ctx context.Context, key string) (value string, found bool, err error)
// SetSecret upserts a plugin-owned secret into kandev's encrypted
// vault. Keys are namespaced to this plugin server-side — a plugin can
// never read or write another plugin's (or kandev's) secrets through
// this API. Keys must match [a-zA-Z0-9][a-zA-Z0-9._-]{0,127}. Requires
// the `secrets` capability.
SetSecret(ctx context.Context, key, value string) error
// DeleteSecret removes a plugin-owned secret. Deleting a missing key
// is not an error. Requires the `secrets` capability.
DeleteSecret(ctx context.Context, key string) error
// EmitEvent publishes a plugin-originated event onto kandev's bus.
EmitEvent(ctx context.Context, name string, payload map[string]any) error
@@ -179,6 +206,21 @@ func (h *grpcHostClient) ListState(ctx context.Context, scope, scopeID string) (
return stateEntriesFromProto(resp.GetEntries())
}
func (h *grpcHostClient) GetConfig(ctx context.Context) (map[string]any, error) {
resp, err := h.client.GetConfig(ctx, &pluginv1.GetConfigRequest{})
if err != nil {
return nil, err
}
config, err := structToMap(resp.GetConfig())
if err != nil {
return nil, err
}
if config == nil {
config = map[string]any{}
}
return config, nil
}
func (h *grpcHostClient) RevealSecret(ctx context.Context, ref string) (string, error) {
resp, err := h.client.RevealSecret(ctx, &pluginv1.RevealSecretRequest{Ref: ref})
if err != nil {
@@ -187,6 +229,27 @@ func (h *grpcHostClient) RevealSecret(ctx context.Context, ref string) (string,
return resp.GetValue(), nil
}
func (h *grpcHostClient) GetSecret(ctx context.Context, key string) (string, bool, error) {
resp, err := h.client.GetSecret(ctx, &pluginv1.GetSecretRequest{Key: key})
if err != nil {
return "", false, err
}
if !resp.GetFound() {
return "", false, nil
}
return resp.GetValue(), true, nil
}
func (h *grpcHostClient) SetSecret(ctx context.Context, key, value string) error {
_, err := h.client.SetSecret(ctx, &pluginv1.SetSecretRequest{Key: key, Value: value})
return err
}
func (h *grpcHostClient) DeleteSecret(ctx context.Context, key string) error {
_, err := h.client.DeleteSecret(ctx, &pluginv1.DeleteSecretRequest{Key: key})
return err
}
func (h *grpcHostClient) EmitEvent(ctx context.Context, name string, payload map[string]any) error {
protoPayload, err := mapToStruct(payload)
if err != nil {
@@ -386,6 +449,18 @@ func (s *grpcHostServer) ListState(ctx context.Context, req *pluginv1.ListStateR
return &pluginv1.ListStateResponse{Entries: protoEntries}, nil
}
func (s *grpcHostServer) GetConfig(ctx context.Context, req *pluginv1.GetConfigRequest) (*pluginv1.GetConfigResponse, error) {
config, err := s.impl.GetConfig(ctx)
if err != nil {
return nil, err
}
protoConfig, err := mapToStruct(config)
if err != nil {
return nil, err
}
return &pluginv1.GetConfigResponse{Config: protoConfig}, nil
}
func (s *grpcHostServer) RevealSecret(ctx context.Context, req *pluginv1.RevealSecretRequest) (*pluginv1.RevealSecretResponse, error) {
value, err := s.impl.RevealSecret(ctx, req.GetRef())
if err != nil {
@@ -394,6 +469,28 @@ func (s *grpcHostServer) RevealSecret(ctx context.Context, req *pluginv1.RevealS
return &pluginv1.RevealSecretResponse{Value: value}, nil
}
func (s *grpcHostServer) GetSecret(ctx context.Context, req *pluginv1.GetSecretRequest) (*pluginv1.GetSecretResponse, error) {
value, found, err := s.impl.GetSecret(ctx, req.GetKey())
if err != nil {
return nil, err
}
return &pluginv1.GetSecretResponse{Found: found, Value: value}, nil
}
func (s *grpcHostServer) SetSecret(ctx context.Context, req *pluginv1.SetSecretRequest) (*pluginv1.SetSecretResponse, error) {
if err := s.impl.SetSecret(ctx, req.GetKey(), req.GetValue()); err != nil {
return nil, err
}
return &pluginv1.SetSecretResponse{}, nil
}
func (s *grpcHostServer) DeleteSecret(ctx context.Context, req *pluginv1.DeleteSecretRequest) (*pluginv1.DeleteSecretResponse, error) {
if err := s.impl.DeleteSecret(ctx, req.GetKey()); err != nil {
return nil, err
}
return &pluginv1.DeleteSecretResponse{}, nil
}
func (s *grpcHostServer) EmitEvent(ctx context.Context, req *pluginv1.EmitEventRequest) (*pluginv1.EmitEventResponse, error) {
payload, err := structToMap(req.GetPayload())
if err != nil {
@@ -46,6 +46,12 @@ func (h *dataRecordingHost) DeleteState(context.Context, string, string, string)
func (h *dataRecordingHost) ListState(context.Context, string, string) ([]StateEntry, error) {
return nil, nil
}
func (h *dataRecordingHost) GetConfig(context.Context) (map[string]any, error) { return nil, nil }
func (h *dataRecordingHost) GetSecret(context.Context, string) (string, bool, error) {
return "", false, nil
}
func (h *dataRecordingHost) SetSecret(context.Context, string, string) error { return nil }
func (h *dataRecordingHost) DeleteSecret(context.Context, string) error { return nil }
func (h *dataRecordingHost) RevealSecret(context.Context, string) (string, error) { return "", nil }
func (h *dataRecordingHost) EmitEvent(context.Context, string, map[string]any) error {
return nil
+67
View File
@@ -25,6 +25,8 @@ type recordingHost struct {
value map[string]any
}
listStateEntries []StateEntry
configValues map[string]any
ownedSecrets map[string]string
revealSecretFn func(ctx context.Context, ref string) (string, error)
emitEvent struct {
name string
@@ -51,10 +53,32 @@ func (h *recordingHost) ListState(context.Context, string, string) ([]StateEntry
return h.listStateEntries, nil
}
func (h *recordingHost) GetConfig(context.Context) (map[string]any, error) {
return h.configValues, nil
}
func (h *recordingHost) RevealSecret(ctx context.Context, ref string) (string, error) {
return h.revealSecretFn(ctx, ref)
}
func (h *recordingHost) GetSecret(_ context.Context, key string) (string, bool, error) {
value, ok := h.ownedSecrets[key]
return value, ok, nil
}
func (h *recordingHost) SetSecret(_ context.Context, key, value string) error {
if h.ownedSecrets == nil {
h.ownedSecrets = map[string]string{}
}
h.ownedSecrets[key] = value
return nil
}
func (h *recordingHost) DeleteSecret(_ context.Context, key string) error {
delete(h.ownedSecrets, key)
return nil
}
func (h *recordingHost) EmitEvent(_ context.Context, name string, payload map[string]any) error {
h.emitEvent.name, h.emitEvent.payload = name, payload
return nil
@@ -138,6 +162,28 @@ func TestHost_ListState(t *testing.T) {
require.Equal(t, impl.listStateEntries, entries)
}
func TestHost_GetConfig(t *testing.T) {
impl := &recordingHost{configValues: map[string]any{
"github_token": "ghp_real",
"org": "kdlbs",
"max_items": float64(25),
}}
host := dialHostOverBufconn(t, impl)
config, err := host.GetConfig(context.Background())
require.NoError(t, err)
require.Equal(t, impl.configValues, config)
}
func TestHost_GetConfig_EmptyIsNonNil(t *testing.T) {
host := dialHostOverBufconn(t, &recordingHost{})
config, err := host.GetConfig(context.Background())
require.NoError(t, err)
require.NotNil(t, config)
require.Empty(t, config)
}
func TestHost_RevealSecret(t *testing.T) {
impl := &recordingHost{revealSecretFn: func(_ context.Context, ref string) (string, error) {
if ref == "known" {
@@ -155,6 +201,27 @@ func TestHost_RevealSecret(t *testing.T) {
require.Error(t, err)
}
func TestHost_PluginSecrets_RoundTripOverWire(t *testing.T) {
impl := &recordingHost{}
host := dialHostOverBufconn(t, impl)
ctx := context.Background()
_, found, err := host.GetSecret(ctx, "pat")
require.NoError(t, err)
require.False(t, found)
require.NoError(t, host.SetSecret(ctx, "pat", "ghp_owned"))
value, found, err := host.GetSecret(ctx, "pat")
require.NoError(t, err)
require.True(t, found)
require.Equal(t, "ghp_owned", value)
require.NoError(t, host.DeleteSecret(ctx, "pat"))
_, found, err = host.GetSecret(ctx, "pat")
require.NoError(t, err)
require.False(t, found)
}
func TestHost_EmitEvent(t *testing.T) {
impl := &recordingHost{}
host := dialHostOverBufconn(t, impl)
@@ -49,6 +49,13 @@ func (f *fakeHost) DeleteState(context.Context, string, string, string) error {
func (f *fakeHost) ListState(context.Context, string, string) ([]StateEntry, error) {
return nil, nil
}
func (f *fakeHost) GetConfig(context.Context) (map[string]any, error) {
// Contract: empty non-nil map when no config has been set.
return map[string]any{}, nil
}
func (f *fakeHost) GetSecret(context.Context, string) (string, bool, error) { return "", false, nil }
func (f *fakeHost) SetSecret(context.Context, string, string) error { return nil }
func (f *fakeHost) DeleteSecret(context.Context, string) error { return nil }
func (f *fakeHost) RevealSecret(context.Context, string) (string, error) { return "", nil }
func (f *fakeHost) EmitEvent(context.Context, string, map[string]any) error { return nil }
File diff suppressed because it is too large Load Diff
@@ -44,6 +44,24 @@ service Host {
rpc RevealSecret(RevealSecretRequest) returns (RevealSecretResponse);
rpc EmitEvent(EmitEventRequest) returns (EmitEventResponse);
// Plugin-scoped secret primitives — capability `secrets`, like
// RevealSecret. Keys are namespaced server-side to the calling plugin
// (vault id "plugin:<id>:secret:<key>"), so a plugin can only ever read,
// write, or delete its OWN secrets; RevealSecret remains the way to
// resolve an operator-provided reference to a shared/global secret.
// Values are stored in kandev's encrypted secret vault (AES-256-GCM at
// rest), never on the plugin record or config file.
rpc GetSecret(GetSecretRequest) returns (GetSecretResponse);
rpc SetSecret(SetSecretRequest) returns (SetSecretResponse);
rpc DeleteSecret(DeleteSecretRequest) returns (DeleteSecretResponse);
// GetConfig returns the plugin's own operator-editable config (the values
// set in Settings > Plugins > <plugin> against the manifest's
// config_schema). Ungated: a plugin can always read its own config, secret
// values included — that is how e.g. an operator-configured PAT reaches
// the plugin.
rpc GetConfig(GetConfigRequest) returns (GetConfigResponse);
// Reads — capability api_read:<resource>
rpc ListTasks(ListTasksRequest) returns (ListTasksResponse);
rpc GetTask(GetTaskRequest) returns (GetTaskResponse);
@@ -144,6 +162,28 @@ message StateEntry {
string updated_at = 3;
}
message GetConfigRequest {}
message GetConfigResponse {
google.protobuf.Struct config = 1;
}
message GetSecretRequest {
string key = 1;
}
message GetSecretResponse {
bool found = 1;
string value = 2;
}
message SetSecretRequest {
string key = 1;
string value = 2;
}
message SetSecretResponse {}
message DeleteSecretRequest {
string key = 1;
}
message DeleteSecretResponse {}
message RevealSecretRequest {
string ref = 1;
}
@@ -207,6 +207,10 @@ const (
Host_ListState_FullMethodName = "/kandev.plugin.v1.Host/ListState"
Host_RevealSecret_FullMethodName = "/kandev.plugin.v1.Host/RevealSecret"
Host_EmitEvent_FullMethodName = "/kandev.plugin.v1.Host/EmitEvent"
Host_GetSecret_FullMethodName = "/kandev.plugin.v1.Host/GetSecret"
Host_SetSecret_FullMethodName = "/kandev.plugin.v1.Host/SetSecret"
Host_DeleteSecret_FullMethodName = "/kandev.plugin.v1.Host/DeleteSecret"
Host_GetConfig_FullMethodName = "/kandev.plugin.v1.Host/GetConfig"
Host_ListTasks_FullMethodName = "/kandev.plugin.v1.Host/ListTasks"
Host_GetTask_FullMethodName = "/kandev.plugin.v1.Host/GetTask"
Host_ListWorkspaces_FullMethodName = "/kandev.plugin.v1.Host/ListWorkspaces"
@@ -255,6 +259,22 @@ type HostClient interface {
ListState(ctx context.Context, in *ListStateRequest, opts ...grpc.CallOption) (*ListStateResponse, error)
RevealSecret(ctx context.Context, in *RevealSecretRequest, opts ...grpc.CallOption) (*RevealSecretResponse, error)
EmitEvent(ctx context.Context, in *EmitEventRequest, opts ...grpc.CallOption) (*EmitEventResponse, error)
// Plugin-scoped secret primitives — capability `secrets`, like
// RevealSecret. Keys are namespaced server-side to the calling plugin
// (vault id "plugin:<id>:secret:<key>"), so a plugin can only ever read,
// write, or delete its OWN secrets; RevealSecret remains the way to
// resolve an operator-provided reference to a shared/global secret.
// Values are stored in kandev's encrypted secret vault (AES-256-GCM at
// rest), never on the plugin record or config file.
GetSecret(ctx context.Context, in *GetSecretRequest, opts ...grpc.CallOption) (*GetSecretResponse, error)
SetSecret(ctx context.Context, in *SetSecretRequest, opts ...grpc.CallOption) (*SetSecretResponse, error)
DeleteSecret(ctx context.Context, in *DeleteSecretRequest, opts ...grpc.CallOption) (*DeleteSecretResponse, error)
// GetConfig returns the plugin's own operator-editable config (the values
// set in Settings > Plugins > <plugin> against the manifest's
// config_schema). Ungated: a plugin can always read its own config, secret
// values included — that is how e.g. an operator-configured PAT reaches
// the plugin.
GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error)
// Reads — capability api_read:<resource>
ListTasks(ctx context.Context, in *ListTasksRequest, opts ...grpc.CallOption) (*ListTasksResponse, error)
GetTask(ctx context.Context, in *GetTaskRequest, opts ...grpc.CallOption) (*GetTaskResponse, error)
@@ -347,6 +367,46 @@ func (c *hostClient) EmitEvent(ctx context.Context, in *EmitEventRequest, opts .
return out, nil
}
func (c *hostClient) GetSecret(ctx context.Context, in *GetSecretRequest, opts ...grpc.CallOption) (*GetSecretResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetSecretResponse)
err := c.cc.Invoke(ctx, Host_GetSecret_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *hostClient) SetSecret(ctx context.Context, in *SetSecretRequest, opts ...grpc.CallOption) (*SetSecretResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetSecretResponse)
err := c.cc.Invoke(ctx, Host_SetSecret_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *hostClient) DeleteSecret(ctx context.Context, in *DeleteSecretRequest, opts ...grpc.CallOption) (*DeleteSecretResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteSecretResponse)
err := c.cc.Invoke(ctx, Host_DeleteSecret_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *hostClient) GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetConfigResponse)
err := c.cc.Invoke(ctx, Host_GetConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *hostClient) ListTasks(ctx context.Context, in *ListTasksRequest, opts ...grpc.CallOption) (*ListTasksResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListTasksResponse)
@@ -501,6 +561,22 @@ type HostServer interface {
ListState(context.Context, *ListStateRequest) (*ListStateResponse, error)
RevealSecret(context.Context, *RevealSecretRequest) (*RevealSecretResponse, error)
EmitEvent(context.Context, *EmitEventRequest) (*EmitEventResponse, error)
// Plugin-scoped secret primitives — capability `secrets`, like
// RevealSecret. Keys are namespaced server-side to the calling plugin
// (vault id "plugin:<id>:secret:<key>"), so a plugin can only ever read,
// write, or delete its OWN secrets; RevealSecret remains the way to
// resolve an operator-provided reference to a shared/global secret.
// Values are stored in kandev's encrypted secret vault (AES-256-GCM at
// rest), never on the plugin record or config file.
GetSecret(context.Context, *GetSecretRequest) (*GetSecretResponse, error)
SetSecret(context.Context, *SetSecretRequest) (*SetSecretResponse, error)
DeleteSecret(context.Context, *DeleteSecretRequest) (*DeleteSecretResponse, error)
// GetConfig returns the plugin's own operator-editable config (the values
// set in Settings > Plugins > <plugin> against the manifest's
// config_schema). Ungated: a plugin can always read its own config, secret
// values included — that is how e.g. an operator-configured PAT reaches
// the plugin.
GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error)
// Reads — capability api_read:<resource>
ListTasks(context.Context, *ListTasksRequest) (*ListTasksResponse, error)
GetTask(context.Context, *GetTaskRequest) (*GetTaskResponse, error)
@@ -551,6 +627,18 @@ func (UnimplementedHostServer) RevealSecret(context.Context, *RevealSecretReques
func (UnimplementedHostServer) EmitEvent(context.Context, *EmitEventRequest) (*EmitEventResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method EmitEvent not implemented")
}
func (UnimplementedHostServer) GetSecret(context.Context, *GetSecretRequest) (*GetSecretResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetSecret not implemented")
}
func (UnimplementedHostServer) SetSecret(context.Context, *SetSecretRequest) (*SetSecretResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetSecret not implemented")
}
func (UnimplementedHostServer) DeleteSecret(context.Context, *DeleteSecretRequest) (*DeleteSecretResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteSecret not implemented")
}
func (UnimplementedHostServer) GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetConfig not implemented")
}
func (UnimplementedHostServer) ListTasks(context.Context, *ListTasksRequest) (*ListTasksResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListTasks not implemented")
}
@@ -716,6 +804,78 @@ func _Host_EmitEvent_Handler(srv interface{}, ctx context.Context, dec func(inte
return interceptor(ctx, in, info, handler)
}
func _Host_GetSecret_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetSecretRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HostServer).GetSecret(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Host_GetSecret_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HostServer).GetSecret(ctx, req.(*GetSecretRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Host_SetSecret_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetSecretRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HostServer).SetSecret(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Host_SetSecret_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HostServer).SetSecret(ctx, req.(*SetSecretRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Host_DeleteSecret_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteSecretRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HostServer).DeleteSecret(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Host_DeleteSecret_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HostServer).DeleteSecret(ctx, req.(*DeleteSecretRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Host_GetConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HostServer).GetConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Host_GetConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HostServer).GetConfig(ctx, req.(*GetConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Host_ListTasks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListTasksRequest)
if err := dec(in); err != nil {
@@ -963,6 +1123,22 @@ var Host_ServiceDesc = grpc.ServiceDesc{
MethodName: "EmitEvent",
Handler: _Host_EmitEvent_Handler,
},
{
MethodName: "GetSecret",
Handler: _Host_GetSecret_Handler,
},
{
MethodName: "SetSecret",
Handler: _Host_SetSecret_Handler,
},
{
MethodName: "DeleteSecret",
Handler: _Host_DeleteSecret_Handler,
},
{
MethodName: "GetConfig",
Handler: _Host_GetConfig_Handler,
},
{
MethodName: "ListTasks",
Handler: _Host_ListTasks_Handler,
@@ -0,0 +1,13 @@
"use client";
import { use } from "react";
import { PluginDetail } from "@/components/settings/plugins/plugin-detail";
import { useFeature } from "@/hooks/domains/features/use-feature";
export default function PluginDetailPage({ params }: { params: Promise<{ pluginId: string }> }) {
const { pluginId } = use(params);
const pluginsEnabled = useFeature("plugins");
if (!pluginsEnabled) return null;
return <PluginDetail pluginId={pluginId} />;
}
@@ -0,0 +1,143 @@
"use client";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Switch } from "@kandev/ui/switch";
import type { PluginConfigField } from "@/lib/plugins/config-schema";
/** Sentinel for the "Not set" select item — an actual enum option can never
* collide with it, and Radix rejects value="" items. */
const ENUM_UNSET_SENTINEL = "__kandev_enum_unset__";
type PluginConfigFormProps = {
fields: PluginConfigField[];
values: Record<string, string | boolean>;
disabled: boolean;
onChange: (name: string, value: string | boolean) => void;
};
/**
* Schema-driven settings form: one control per config_schema property.
* Secret fields render as password inputs pre-filled with the backend's
* mask; editing replaces the value, leaving it untouched keeps the stored
* secret. Purely controlled — load/save/dirty state lives in PluginDetail.
*/
export function PluginConfigForm({ fields, values, disabled, onChange }: PluginConfigFormProps) {
return (
<div className="space-y-5">
{fields.map((field) => (
<ConfigFieldRow
key={field.name}
field={field}
value={values[field.name] ?? ""}
disabled={disabled}
onChange={onChange}
/>
))}
</div>
);
}
type ConfigFieldRowProps = {
field: PluginConfigField;
value: string | boolean;
disabled: boolean;
onChange: (name: string, value: string | boolean) => void;
};
function ConfigFieldRow({ field, value, disabled, onChange }: ConfigFieldRowProps) {
const inputId = `plugin-config-${field.name}`;
return (
<div className="space-y-1.5" data-testid={`plugin-config-field-${field.name}`}>
<Label htmlFor={inputId} className="text-sm">
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</Label>
<ConfigFieldControl
field={field}
inputId={inputId}
value={value}
disabled={disabled}
onChange={onChange}
/>
{field.description && <p className="text-xs text-muted-foreground">{field.description}</p>}
</div>
);
}
type ConfigFieldControlProps = ConfigFieldRowProps & { inputId: string };
function ConfigFieldControl({
field,
inputId,
value,
disabled,
onChange,
}: ConfigFieldControlProps) {
if (field.type === "boolean") {
return (
<div>
<Switch
id={inputId}
checked={value === true}
disabled={disabled}
onCheckedChange={(checked) => onChange(field.name, checked)}
/>
</div>
);
}
if (field.type === "enum") {
// Radix Select forbids an item with value="", so an explicit "Not set"
// sentinel lets optional enums be cleared back to unset (serialization
// omits empty strings).
return (
<Select
value={typeof value === "string" ? value : ""}
disabled={disabled}
onValueChange={(next) => onChange(field.name, next === ENUM_UNSET_SENTINEL ? "" : next)}
>
<SelectTrigger id={inputId} className="max-w-md cursor-pointer">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{!field.required && (
<SelectItem
value={ENUM_UNSET_SENTINEL}
className="cursor-pointer text-muted-foreground"
>
Not set
</SelectItem>
)}
{(field.enumValues ?? []).map((option) => (
<SelectItem key={option} value={option} className="cursor-pointer">
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input
id={inputId}
type={inputType(field)}
// step="1" on integer fields nudges the browser to flag non-integral
// input in-place; serializeConfigValues still rejects it as a backstop.
step={!field.secret && field.type === "integer" ? "1" : undefined}
value={typeof value === "string" ? value : ""}
disabled={disabled}
autoComplete={field.secret ? "off" : undefined}
className="max-w-md"
onChange={(event) => onChange(field.name, event.target.value)}
/>
);
}
function inputType(field: PluginConfigField): string {
if (field.secret) return "password";
if (field.type === "number" || field.type === "integer") return "number";
return "text";
}
@@ -0,0 +1,241 @@
"use client";
import { IconArrowLeft } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Separator } from "@kandev/ui/separator";
import Link from "@/components/routing/app-link";
import { useRouter } from "@/lib/routing/client-router";
import { usePlugins } from "@/hooks/domains/plugins/use-plugins";
import { UnsavedChangesBadge, UnsavedSaveButton } from "@/components/settings/unsaved-indicator";
import { PluginConfigForm } from "./plugin-config-form";
import { PluginManifestCard } from "./plugin-manifest-card";
import { PluginStatusBadge } from "./plugin-status-badge";
import { UninstallPluginDialog } from "./uninstall-plugin-dialog";
import { usePluginActions } from "./use-plugin-actions";
import { usePluginConfigForm } from "./use-plugin-config-form";
import type { PluginRecord } from "@/lib/types/plugins";
const PLUGINS_SETTINGS_HREF = "/settings/plugins";
/**
* Per-plugin settings page (Settings > Plugins > <plugin>): manifest
* overview plus the schema-driven settings form declared by the plugin
* author via the manifest's config_schema (e.g. a GitHub plugin's PAT).
* Saving restarts a running plugin so it re-reads config via the Host
* GetConfig RPC.
*/
export function PluginDetail({ pluginId }: { pluginId: string }) {
const { items, loaded } = usePlugins();
const router = useRouter();
const actions = usePluginActions();
const plugin = items.find((p) => p.id === pluginId) ?? null;
const form = usePluginConfigForm(plugin);
if (!plugin) {
return loaded ? <PluginNotFound pluginId={pluginId} /> : null;
}
return (
<div className="space-y-6" data-testid={`plugin-detail-${plugin.id}`}>
<PluginDetailHeader
plugin={plugin}
showSave={form.fields.length > 0}
isDirty={form.isDirty}
saveStatus={form.saveStatus}
onSave={form.handleSave}
/>
<Separator />
<PluginSettingsCard plugin={plugin} form={form} busy={actions.busyId === plugin.id} />
<PluginManifestCard plugin={plugin} />
<PluginDangerZone plugin={plugin} actions={actions} />
<UninstallPluginDialog
target={actions.uninstallTarget}
busy={actions.uninstallBusy}
onClose={actions.closeUninstall}
onConfirm={async () => {
await actions.confirmUninstall();
router.push(PLUGINS_SETTINGS_HREF);
}}
/>
</div>
);
}
type PluginDetailHeaderProps = {
plugin: PluginRecord;
showSave: boolean;
isDirty: boolean;
saveStatus: "idle" | "loading" | "success" | "error";
onSave: () => void;
};
function PluginDetailHeader({
plugin,
showSave,
isDirty,
saveStatus,
onSave,
}: PluginDetailHeaderProps) {
return (
<div className="space-y-3">
<Link
href={PLUGINS_SETTINGS_HREF}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground cursor-pointer"
>
<IconArrowLeft className="h-4 w-4" />
Plugins
</Link>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-2xl font-bold truncate">{plugin.display_name}</h2>
<PluginStatusBadge status={plugin.status} />
{plugin.signed === false && (
<Badge
variant="outline"
className="border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[11px]"
>
unsigned
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground font-mono">
{plugin.id} · v{plugin.version}
</div>
{plugin.description && (
<p className="text-sm text-muted-foreground">{plugin.description}</p>
)}
</div>
{showSave && (
<div className="flex items-center gap-3 shrink-0">
{isDirty && <UnsavedChangesBadge />}
<UnsavedSaveButton
isDirty={isDirty}
isLoading={saveStatus === "loading"}
status={saveStatus}
onClick={onSave}
/>
</div>
)}
</div>
</div>
);
}
type PluginSettingsCardProps = {
plugin: PluginRecord;
form: ReturnType<typeof usePluginConfigForm>;
busy: boolean;
};
function PluginSettingsCard({ plugin, form, busy }: PluginSettingsCardProps) {
return (
<Card data-testid="plugin-settings-card">
<CardHeader>
<CardTitle className="text-base">Settings</CardTitle>
</CardHeader>
<CardContent>
<PluginSettingsBody plugin={plugin} form={form} busy={busy} />
</CardContent>
</Card>
);
}
function PluginSettingsBody({ plugin, form, busy }: PluginSettingsCardProps) {
if (form.fields.length === 0) {
return (
<p className="text-sm text-muted-foreground">
This plugin does not declare any settings (no <code>config_schema</code> in its manifest).
</p>
);
}
if (form.configError) {
return <p className="text-sm text-destructive">{form.configError}</p>;
}
if (form.configLoading) {
return <p className="text-sm text-muted-foreground">Loading settings...</p>;
}
return (
<div className="space-y-4">
<PluginConfigForm
fields={form.fields}
values={form.values}
disabled={busy || form.saveStatus === "loading"}
onChange={form.handleChange}
/>
{plugin.status === "active" && (
<p className="text-xs text-muted-foreground">
Saving restarts the plugin so the new settings take effect.
</p>
)}
</div>
);
}
type PluginDangerZoneProps = {
plugin: PluginRecord;
actions: ReturnType<typeof usePluginActions>;
};
function PluginDangerZone({ plugin, actions }: PluginDangerZoneProps) {
const busy = actions.busyId === plugin.id;
const canEnable = plugin.status === "disabled" || plugin.status === "registered";
const canDisable = plugin.status === "active" || plugin.status === "error";
return (
<div className="flex flex-wrap items-center gap-2">
{canEnable && (
<Button
variant="outline"
size="sm"
className="cursor-pointer"
disabled={busy}
onClick={() => actions.handleEnable(plugin)}
>
Enable
</Button>
)}
{canDisable && (
<Button
variant="outline"
size="sm"
className="cursor-pointer"
disabled={busy}
onClick={() => actions.handleDisable(plugin)}
>
Disable
</Button>
)}
<Button
variant="ghost"
size="sm"
className="cursor-pointer text-destructive hover:text-destructive"
disabled={busy}
onClick={() => actions.openUninstall(plugin)}
>
Uninstall
</Button>
</div>
);
}
function PluginNotFound({ pluginId }: { pluginId: string }) {
return (
<div className="space-y-4">
<Link
href={PLUGINS_SETTINGS_HREF}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground cursor-pointer"
>
<IconArrowLeft className="h-4 w-4" />
Plugins
</Link>
<div className="rounded-md border border-dashed p-6 text-sm text-muted-foreground">
No installed plugin with id <span className="font-mono">{pluginId}</span>.
</div>
</div>
);
}
@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import type { PluginRecord } from "@/lib/types/plugins";
/**
* Read-only view of the plugin's manifest: identity, capabilities, declared
* tools/webhooks, and a collapsible raw JSON dump for anything the summary
* rows don't surface.
*/
export function PluginManifestCard({ plugin }: { plugin: PluginRecord }) {
const [showRaw, setShowRaw] = useState(false);
return (
<Card data-testid="plugin-manifest-card">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Manifest</CardTitle>
<Button
variant="ghost"
size="sm"
className="cursor-pointer"
onClick={() => setShowRaw((v) => !v)}
>
{showRaw ? "Hide raw" : "View raw"}
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<ManifestRow label="ID" value={plugin.id} mono />
<ManifestRow label="Version" value={plugin.version} mono />
<ManifestRow label="API version" value={String(plugin.api_version)} />
<ManifestRow label="Author" value={plugin.author || "—"} />
<ManifestRow label="Signed" value={plugin.signed ? "yes" : "no"} />
<ManifestRow label="Installed" value={formatInstalledAt(plugin.installed_at)} />
</div>
<CapabilityBadges plugin={plugin} />
<DeclarationList
label="Tools"
items={(plugin.tools ?? []).map((t) => ({ key: t.name, text: t.display_name || t.name }))}
/>
<DeclarationList
label="Webhooks"
items={(plugin.webhooks ?? []).map((w) => ({ key: w.key, text: w.key }))}
/>
{showRaw && (
<pre
data-testid="plugin-manifest-raw"
className="rounded-md border border-border/70 bg-muted/40 p-3 text-xs overflow-x-auto"
>
{JSON.stringify(manifestOnly(plugin), null, 2)}
</pre>
)}
</CardContent>
</Card>
);
}
function ManifestRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-3 sm:justify-start">
<span className="text-muted-foreground w-28 shrink-0">{label}</span>
<span className={mono ? "font-mono text-xs truncate" : "truncate"}>{value}</span>
</div>
);
}
function CapabilityBadges({ plugin }: { plugin: PluginRecord }) {
const caps = plugin.capabilities ?? {};
const badges: string[] = [
...(caps.events ?? []).map((e) => `events:${e}`),
...(caps.api_read ?? []).map((r) => `read:${r}`),
...(caps.api_write ?? []).map((w) => `write:${w}`),
...(caps.state ? ["state"] : []),
...(caps.secrets ? ["secrets"] : []),
];
if (badges.length === 0) return null;
return (
<div className="space-y-1.5">
<div className="text-sm text-muted-foreground">Capabilities</div>
<div className="flex flex-wrap gap-1">
{badges.map((badge) => (
<Badge key={badge} variant="secondary" className="text-[11px] font-mono">
{badge}
</Badge>
))}
</div>
</div>
);
}
function DeclarationList({
label,
items,
}: {
label: string;
items: { key: string; text: string }[];
}) {
if (items.length === 0) return null;
return (
<div className="space-y-1.5">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="flex flex-wrap gap-1">
{items.map((item) => (
<Badge key={item.key} variant="outline" className="text-[11px]">
{item.text}
</Badge>
))}
</div>
</div>
);
}
/** The manifest-declared fields only — runtime bookkeeping (status, install
* path, restart counter) is shown elsewhere and just noise here. */
function manifestOnly(plugin: PluginRecord): Record<string, unknown> {
const { status: _s, install_path: _i, restart_count: _r, installed_at: _a, ...manifest } = plugin;
return manifest;
}
function formatInstalledAt(installedAt: string): string {
const date = new Date(installedAt);
return Number.isNaN(date.getTime()) ? installedAt : date.toLocaleString();
}
@@ -2,6 +2,7 @@
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import Link from "@/components/routing/app-link";
import { PluginStatusBadge } from "./plugin-status-badge";
import type { PluginRecord } from "@/lib/types/plugins";
@@ -30,9 +31,13 @@ export function PluginRow({ plugin, busy, onEnable, onDisable, onUninstall }: Pl
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground truncate">
<Link
href={`/settings/plugins/${encodeURIComponent(plugin.id)}`}
data-testid={`plugin-row-link-${plugin.id}`}
className="text-sm font-medium text-foreground truncate cursor-pointer hover:underline"
>
{plugin.display_name}
</span>
</Link>
<PluginStatusBadge status={plugin.status} />
{plugin.signed === false && (
<Badge
@@ -0,0 +1,154 @@
// @vitest-environment happy-dom
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const getPluginConfig = vi.fn();
const updatePluginConfig = vi.fn();
vi.mock("@/lib/api/domains/plugins-api", () => ({
getPluginConfig: (...args: unknown[]) => getPluginConfig(...args),
updatePluginConfig: (...args: unknown[]) => updatePluginConfig(...args),
}));
const toastError = vi.fn();
const toastSuccess = vi.fn();
const toastWarning = vi.fn();
vi.mock("sonner", () => ({
toast: {
error: (...args: unknown[]) => toastError(...args),
success: (...args: unknown[]) => toastSuccess(...args),
warning: (...args: unknown[]) => toastWarning(...args),
},
}));
import { SECRET_MASK } from "@/lib/plugins/config-schema";
import { usePluginConfigForm } from "./use-plugin-config-form";
import type { PluginRecord } from "@/lib/types/plugins";
function testPlugin(): PluginRecord {
return {
id: "kandev-plugin-github",
api_version: 1,
version: "1.0.0",
display_name: "GitHub",
description: "",
author: "kandev",
categories: [],
capabilities: {},
status: "active",
install_path: "/tmp/x",
signed: true,
installed_at: "2026-01-01T00:00:00Z",
restart_count: 0,
config_schema: {
type: "object",
required: ["github_token"],
properties: {
github_token: { type: "string", secret: true },
org: { type: "string" },
},
},
};
}
beforeEach(() => {
vi.clearAllMocks();
getPluginConfig.mockResolvedValue({});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("usePluginConfigForm", () => {
it("loads the stored config into initial values", async () => {
getPluginConfig.mockResolvedValue({ github_token: SECRET_MASK, org: "kdlbs" });
const { result } = renderHook(() => usePluginConfigForm(testPlugin()));
await waitFor(() => expect(result.current.configLoading).toBe(false));
expect(getPluginConfig).toHaveBeenCalledWith("kandev-plugin-github", { cache: "no-store" });
expect(result.current.values.github_token).toBe(SECRET_MASK);
expect(result.current.values.org).toBe("kdlbs");
expect(result.current.isDirty).toBe(false);
});
it("does not fetch for a null plugin or an empty schema", () => {
renderHook(() => usePluginConfigForm(null));
renderHook(() => usePluginConfigForm({ ...testPlugin(), config_schema: undefined }));
expect(getPluginConfig).not.toHaveBeenCalled();
});
it("tracks dirtiness across change and save", async () => {
getPluginConfig.mockResolvedValue({});
updatePluginConfig.mockResolvedValue({ updated: true });
const { result } = renderHook(() => usePluginConfigForm(testPlugin()));
await waitFor(() => expect(result.current.configLoading).toBe(false));
act(() => result.current.handleChange("github_token", "ghp_x"));
expect(result.current.isDirty).toBe(true);
getPluginConfig.mockResolvedValue({ github_token: SECRET_MASK });
await act(() => result.current.handleSave());
expect(result.current.isDirty).toBe(false);
expect(result.current.saveStatus).toBe("success");
});
it("rejects a save with missing required fields without calling the API", async () => {
const { result } = renderHook(() => usePluginConfigForm(testPlugin()));
await waitFor(() => expect(result.current.configLoading).toBe(false));
await act(() => result.current.handleSave());
expect(updatePluginConfig).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledWith(expect.stringContaining("Required"));
});
it("saves, refetches the masked config, and reports success", async () => {
updatePluginConfig.mockResolvedValue({ updated: true });
const { result } = renderHook(() => usePluginConfigForm(testPlugin()));
await waitFor(() => expect(result.current.configLoading).toBe(false));
act(() => result.current.handleChange("github_token", "ghp_cleartext"));
getPluginConfig.mockResolvedValue({ github_token: SECRET_MASK });
await act(() => result.current.handleSave());
expect(updatePluginConfig).toHaveBeenCalledWith("kandev-plugin-github", {
github_token: "ghp_cleartext",
});
expect(result.current.values.github_token).toBe(SECRET_MASK);
expect(result.current.saveStatus).toBe("success");
expect(toastSuccess).toHaveBeenCalled();
});
it("reports a PATCH failure as an error and keeps saveStatus=error", async () => {
updatePluginConfig.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => usePluginConfigForm(testPlugin()));
await waitFor(() => expect(result.current.configLoading).toBe(false));
act(() => result.current.handleChange("github_token", "ghp_x"));
await act(() => result.current.handleSave());
expect(result.current.saveStatus).toBe("error");
expect(toastError).toHaveBeenCalledWith("boom");
});
it("treats a refetch failure after a successful PATCH as saved — and masks typed secrets", async () => {
updatePluginConfig.mockResolvedValue({ updated: true });
const { result } = renderHook(() => usePluginConfigForm(testPlugin()));
await waitFor(() => expect(result.current.configLoading).toBe(false));
act(() => result.current.handleChange("github_token", "ghp_cleartext"));
getPluginConfig.mockRejectedValue(new Error("plugin restarting"));
await act(() => result.current.handleSave());
// The save succeeded: never report it as a failure...
expect(result.current.saveStatus).toBe("success");
expect(toastWarning).toHaveBeenCalled();
expect(toastError).not.toHaveBeenCalled();
// ...and the cleartext secret must not linger in the form.
expect(result.current.values.github_token).toBe(SECRET_MASK);
// ...and the masked form is the new baseline, so it does not read dirty.
expect(result.current.isDirty).toBe(false);
});
});
@@ -0,0 +1,139 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { getPluginConfig, updatePluginConfig } from "@/lib/api/domains/plugins-api";
import {
SECRET_MASK,
buildInitialValues,
missingRequiredFields,
parseConfigSchema,
serializeConfigValues,
} from "@/lib/plugins/config-schema";
import type { PluginRecord } from "@/lib/types/plugins";
type SaveStatus = "idle" | "loading" | "success" | "error";
type FormValues = Record<string, string | boolean>;
/**
* Load/edit/save state for one plugin's schema-driven settings form.
* Mirrors use-plugin-actions' local-hook pattern: fetch + toast wiring lives
* here, the components stay presentational. Saving PATCHes the full config
* (secret fields carrying the mask keep their stored value server-side) and
* then re-fetches the masked config so the form reflects what is stored.
*/
export function usePluginConfigForm(plugin: PluginRecord | null) {
const fields = useMemo(() => parseConfigSchema(plugin?.config_schema), [plugin?.config_schema]);
const [values, setValues] = useState<FormValues>({});
const [initialValues, setInitialValues] = useState<FormValues>({});
const [configLoading, setConfigLoading] = useState(false);
const [configError, setConfigError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const pluginId = plugin?.id ?? null;
const hasFields = fields.length > 0;
useEffect(() => {
if (!pluginId || !hasFields) return;
let cancelled = false;
setConfigLoading(true);
setConfigError(null);
getPluginConfig(pluginId, { cache: "no-store" })
.then((config) => {
if (cancelled) return;
const initial = buildInitialValues(fields, config);
setValues(initial);
setInitialValues(initial);
})
.catch((err) => {
if (!cancelled) {
setConfigError(err instanceof Error ? err.message : "Failed to load plugin settings");
}
})
.finally(() => {
if (!cancelled) setConfigLoading(false);
});
return () => {
cancelled = true;
};
// fields is derived solely from plugin.config_schema; pluginId is the
// real reload trigger.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginId, hasFields]);
const isDirty = useMemo(
() => fields.some((field) => values[field.name] !== initialValues[field.name]),
[fields, values, initialValues],
);
const handleChange = (name: string, value: string | boolean) => {
setValues((prev) => ({ ...prev, [name]: value }));
setSaveStatus("idle");
};
const handleSave = async () => {
if (!pluginId) return;
const missing = missingRequiredFields(fields, values);
if (missing.length > 0) {
toast.error(`Required: ${missing.join(", ")}`);
return;
}
setSaveStatus("loading");
try {
await updatePluginConfig(pluginId, serializeConfigValues(fields, values));
} catch (err) {
setSaveStatus("error");
toast.error(err instanceof Error ? err.message : "Failed to save plugin settings");
return;
}
// The config IS persisted from here on — a refetch failure (e.g. a
// transient hiccup while the plugin restarts) must not be reported as a
// save failure, and the typed cleartext secret must not stay on screen.
try {
const refreshed = await getPluginConfig(pluginId, { cache: "no-store" });
const initial = buildInitialValues(fields, refreshed);
setValues(initial);
setInitialValues(initial);
toast.success("Plugin settings saved");
} catch {
maskSecretValues();
toast.warning("Settings saved, but reloading them failed — refresh to confirm.");
}
setSaveStatus("success");
};
// On a post-save refetch failure, replace any typed secret input with the
// mask (so cleartext never lingers) and rebase initialValues onto the
// masked snapshot — the config IS saved, so the masked form is the new
// baseline and must not read as dirty (e.g. a previously-unset secret the
// user just entered). Computed from the current `values` and applied to
// both state setters directly, rather than as a side effect inside a
// functional updater (which would run twice under StrictMode).
const maskSecretValues = () => {
const masked = maskSecretsIn(values);
setValues(masked);
setInitialValues(masked);
};
const maskSecretsIn = (source: FormValues): FormValues => {
const masked = { ...source };
for (const field of fields) {
const current = masked[field.name];
if (field.secret && typeof current === "string" && current !== "") {
masked[field.name] = SECRET_MASK;
}
}
return masked;
};
return {
fields,
values,
configLoading,
configError,
saveStatus,
isDirty,
handleChange,
handleSave,
};
}
@@ -215,6 +215,94 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () =
await expect.poll(() => fs.existsSync(pluginDir), { timeout: 10_000 }).toBe(false);
});
test("settings page: schema-driven form, secret masking, and Host GetConfig delivery", async ({
testPage,
apiClient,
backend,
}) => {
test.setTimeout(90_000);
const pluginsDir = path.join(backend.tmpDir, ".kandev", "plugins");
const secretToken = "ghp_e2e_secret_token";
// --- Install via the upload UI, then click through to the detail page ---
await openInstallDialog(testPage);
await uploadPackage(testPage, PACKAGE_PATH);
const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`);
await expect(pluginRow).toBeVisible({ timeout: 15_000 });
await testPage.getByTestId(`plugin-row-link-${PLUGIN_ID}`).click();
await expect(testPage).toHaveURL(new RegExp(`/settings/plugins/${PLUGIN_ID}$`));
await expect(testPage.getByTestId(`plugin-detail-${PLUGIN_ID}`)).toBeVisible();
await expect(testPage.getByTestId("plugin-manifest-card")).toBeVisible();
// --- Fill the config_schema-driven form: secret token + plain string ---
const tokenInput = testPage.getByTestId("plugin-config-field-api_token").locator("input");
const greetingInput = testPage.getByTestId("plugin-config-field-greeting").locator("input");
await expect(tokenInput).toHaveAttribute("type", "password");
await tokenInput.fill(secretToken);
await greetingInput.fill("hello from e2e");
await testPage.getByRole("button", { name: "Save" }).click();
// --- After save the form re-fetches the MASKED config: the token shows
// as the placeholder, never the cleartext; the greeting round-trips. ---
await expect(tokenInput).toHaveValue("********", { timeout: 15_000 });
await expect(greetingInput).toHaveValue("hello from e2e");
// --- The config file never persists the cleartext: the secret field is
// a reference into kandev's encrypted vault. ---
const configPath = path.join(pluginsDir, `${PLUGIN_ID}.config.yml`);
await expect.poll(() => fs.existsSync(configPath), { timeout: 10_000 }).toBe(true);
const configFile = fs.readFileSync(configPath, "utf8");
expect(configFile).not.toContain(secretToken);
expect(configFile).toContain(`vault:plugin:${PLUGIN_ID}:config:api_token`);
// --- The operator API never returns the cleartext either. ---
const configRes = await apiClient.rawRequest("GET", `/api/plugins/${PLUGIN_ID}/config`);
const configBody = (await configRes.json()) as { config: Record<string, unknown> };
expect(configBody.config.api_token).toBe("********");
expect(configBody.config.greeting).toBe("hello from e2e");
// --- Saving restarted the plugin; it must be Active again. ---
await testPage.goto("/settings/plugins");
await expect(pluginRow.getByText("Active", { exact: true })).toBeVisible({ timeout: 15_000 });
// --- Prove the Host GetConfig gRPC path: the webhook makes the fixture
// binary call Host.GetConfig and snapshot the result to config.json in
// its data dir — cleartext secret included. ---
const webhookRes = await apiClient.rawRequest(
"POST",
`/api/plugins/${PLUGIN_ID}/webhooks/test-hook`,
{},
);
expect(webhookRes.status).toBe(200);
const snapshotPath = path.join(pluginsDir, PLUGIN_ID, "data", "config.json");
await expect
.poll(
() => {
if (!fs.existsSync(snapshotPath)) return null;
return (JSON.parse(fs.readFileSync(snapshotPath, "utf8")) as Record<string, unknown>)
.api_token;
},
{ timeout: 15_000, intervals: [250, 500, 1000] },
)
.toBe(secretToken);
// --- And the plugin-scoped secret primitives: the same webhook makes
// the fixture SetSecret("probe") then GetSecret it back through the
// vault, writing the round-tripped value to secret-probe.json. ---
const probePath = path.join(pluginsDir, PLUGIN_ID, "data", "secret-probe.json");
await expect
.poll(
() => {
if (!fs.existsSync(probePath)) return null;
return (JSON.parse(fs.readFileSync(probePath, "utf8")) as Record<string, unknown>).probe;
},
{ timeout: 15_000, intervals: [250, 500, 1000] },
)
.toBe("s3cret-roundtrip");
});
test("uploading a corrupted package surfaces install-plugin-error", async ({ testPage }) => {
const junkPath = path.join(os.tmpdir(), `kandev-e2e-corrupt-plugin-${Date.now()}.tar.gz`);
fs.writeFileSync(junkPath, "not a real gzip archive, just junk bytes\n".repeat(8));
@@ -8,6 +8,7 @@ import {
disablePlugin,
enablePlugin,
getPlugin,
getPluginConfig,
installPluginFromUrl,
installPluginUpload,
listPlugins,
@@ -332,3 +333,27 @@ describe("syncPlugins", () => {
await expect(syncPlugins()).rejects.toMatchObject({ status: 500, message: "sync failed" });
});
});
describe("getPluginConfig", () => {
it("fetches GET /api/plugins/:id/config and unwraps the config object", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ config: { default_channel: "#dev" } }));
const result = await getPluginConfig(PLUGIN_ID);
const [url] = fetchSpy.mock.calls.at(-1) ?? [];
expect(String(url)).toBe(`${PLUGIN_URL}/config`);
expect(result).toEqual({ default_channel: "#dev" });
});
it("returns an empty object when the backend omits config", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({}));
expect(await getPluginConfig(PLUGIN_ID)).toEqual({});
});
it("URL-encodes the plugin id", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ config: {} }));
await getPluginConfig("my plugin");
const [url] = fetchSpy.mock.calls.at(-1) ?? [];
expect(String(url)).toContain("my%20plugin");
});
});
+14 -1
View File
@@ -87,8 +87,21 @@ async function throwInstallError(response: Response): Promise<never> {
throw new ApiError(message, response.status, body);
}
// getPluginConfig fetches a plugin's stored operator config
// (GET /api/plugins/:id/config). Secret values (per the manifest's
// config_schema) arrive masked — the backend never returns them in
// cleartext on this surface.
export async function getPluginConfig(id: string, options?: ApiRequestOptions) {
const res = await fetchJson<{ config?: Record<string, unknown> }>(
`${BASE}/${encodeURIComponent(id)}/config`,
options,
);
return res.config ?? {};
}
// updatePluginConfig replaces a plugin's operator-editable config
// (PATCH /api/plugins/:id).
// (PATCH /api/plugins/:id). Secret fields submitted as the mask placeholder
// keep their stored value server-side.
export async function updatePluginConfig(
id: string,
config: Record<string, unknown>,
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import {
SECRET_MASK,
buildInitialValues,
missingRequiredFields,
parseConfigSchema,
serializeConfigValues,
} from "./config-schema";
const githubSchema = {
type: "object",
required: ["github_token"],
properties: {
github_token: {
type: "string",
title: "Personal access token",
description: "Fine-grained PAT with repo scope.",
secret: true,
},
webhook_key: { type: "string", format: "password" },
org: { type: "string" },
max_items: { type: "integer", default: 25 },
channel: { type: "string", enum: ["dev", "ops"] },
verbose: { type: "boolean", default: true },
},
} satisfies Record<string, unknown>;
describe("parseConfigSchema", () => {
it("maps properties to fields with titles, requiredness, and secrecy", () => {
const fields = parseConfigSchema(githubSchema);
const byName = Object.fromEntries(fields.map((f) => [f.name, f]));
expect(fields).toHaveLength(6);
expect(byName.github_token).toMatchObject({
type: "string",
label: "Personal access token",
description: "Fine-grained PAT with repo scope.",
required: true,
secret: true,
});
expect(byName.webhook_key.secret).toBe(true);
expect(byName.org).toMatchObject({ label: "org", required: false, secret: false });
expect(byName.max_items).toMatchObject({ type: "integer", defaultValue: 25 });
expect(byName.channel).toMatchObject({ type: "enum", enumValues: ["dev", "ops"] });
expect(byName.verbose.type).toBe("boolean");
});
it("returns [] for missing or unusable schemas", () => {
expect(parseConfigSchema(undefined)).toEqual([]);
expect(parseConfigSchema({})).toEqual([]);
expect(parseConfigSchema({ properties: "nope" })).toEqual([]);
});
});
describe("buildInitialValues", () => {
it("prefers stored config, then defaults, then empties", () => {
const fields = parseConfigSchema(githubSchema);
const values = buildInitialValues(fields, { github_token: SECRET_MASK, org: "kdlbs" });
expect(values.github_token).toBe(SECRET_MASK);
expect(values.org).toBe("kdlbs");
expect(values.max_items).toBe("25");
expect(values.verbose).toBe(true);
expect(values.channel).toBe("");
});
});
describe("serializeConfigValues", () => {
it("parses numerics, keeps booleans, and drops empty strings", () => {
const fields = parseConfigSchema(githubSchema);
const config = serializeConfigValues(fields, {
github_token: SECRET_MASK,
webhook_key: "",
org: "kdlbs",
max_items: "10",
channel: "",
verbose: false,
});
expect(config).toEqual({
github_token: SECRET_MASK,
org: "kdlbs",
max_items: 10,
verbose: false,
});
});
it("omits unparseable numeric input", () => {
const fields = parseConfigSchema(githubSchema);
const config = serializeConfigValues(fields, { max_items: "abc", verbose: true });
expect(config).not.toHaveProperty("max_items");
});
it("rejects (omits) non-integral input for integer fields instead of truncating", () => {
const fields = parseConfigSchema(githubSchema);
// parseInt would silently turn "1.5" into 1 — the field must be omitted
// so the operator is told rather than getting a corrupted value.
const config = serializeConfigValues(fields, { max_items: "1.5", verbose: true });
expect(config).not.toHaveProperty("max_items");
expect(
serializeConfigValues(fields, { max_items: "Infinity", verbose: true }),
).not.toHaveProperty("max_items");
});
it("submits numeric enum selections with their original type", () => {
const fields = parseConfigSchema({
properties: { level: { type: "integer", enum: [1, 2, 3] } },
});
const config = serializeConfigValues(fields, { level: "2" });
expect(config.level).toBe(2);
});
});
describe("missingRequiredFields", () => {
it("flags blank required fields and accepts the secret mask as set", () => {
const fields = parseConfigSchema(githubSchema);
expect(missingRequiredFields(fields, { github_token: "" })).toEqual(["Personal access token"]);
expect(missingRequiredFields(fields, { github_token: SECRET_MASK })).toEqual([]);
expect(missingRequiredFields(fields, { github_token: "ghp_x" })).toEqual([]);
});
});
+185
View File
@@ -0,0 +1,185 @@
/**
* Turns a plugin manifest's `config_schema` (a JSON-Schema-like object the
* plugin author declares — see apps/backend/internal/plugins/config.go) into
* renderable form fields for the plugin settings page, and converts form
* values back into the config payload for PATCH /api/plugins/:id.
*
* Secret fields (`secret: true` or `format: "password"`, e.g. a GitHub PAT)
* come back from GET /api/plugins/:id/config as SECRET_MASK; submitting the
* mask unchanged tells the backend to keep the stored value.
*/
/** Mirror of the backend's configSecretMask (internal/plugins/config.go). */
export const SECRET_MASK = "********";
export type PluginConfigFieldType = "string" | "boolean" | "number" | "integer" | "enum";
export interface PluginConfigField {
name: string;
type: PluginConfigFieldType;
label: string;
description?: string;
required: boolean;
secret: boolean;
/** Display strings for the select control, parallel to enumRawValues. */
enumValues?: string[];
/** The schema's original typed enum values (numbers/booleans survive the
* round trip — the backend validates against these, not their string
* forms). */
enumRawValues?: unknown[];
defaultValue?: unknown;
}
type SchemaObject = Record<string, unknown>;
function asObject(value: unknown): SchemaObject | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as SchemaObject)
: null;
}
function fieldType(prop: SchemaObject): PluginConfigFieldType {
const enumValues = prop.enum;
if (Array.isArray(enumValues) && enumValues.length > 0) return "enum";
const type = prop.type;
if (type === "boolean" || type === "number" || type === "integer") return type;
return "string";
}
function isSecretProp(prop: SchemaObject): boolean {
return prop.secret === true || prop.format === "password";
}
function requiredNames(schema: SchemaObject): Set<string> {
const required = schema.required;
if (!Array.isArray(required)) return new Set();
return new Set(required.filter((name): name is string => typeof name === "string"));
}
/**
* Parses a config_schema into ordered form fields. Only declared
* `properties` become fields; richer schema constructs are ignored (the
* schema is authoring metadata, not a full JSON-Schema contract). Returns []
* for a missing or unusable schema — the settings page then shows the
* "no settings" state.
*/
export function parseConfigSchema(
schema: Record<string, unknown> | undefined,
): PluginConfigField[] {
const schemaObj = asObject(schema);
if (!schemaObj) return [];
const properties = asObject(schemaObj.properties);
if (!properties) return [];
const required = requiredNames(schemaObj);
const fields: PluginConfigField[] = [];
for (const [name, raw] of Object.entries(properties)) {
const prop = asObject(raw);
if (!prop) continue;
fields.push({
name,
type: fieldType(prop),
label: typeof prop.title === "string" && prop.title !== "" ? prop.title : name,
description: typeof prop.description === "string" ? prop.description : undefined,
required: required.has(name),
secret: isSecretProp(prop),
enumValues: Array.isArray(prop.enum) ? prop.enum.map((v) => String(v)) : undefined,
enumRawValues: Array.isArray(prop.enum) ? prop.enum : undefined,
defaultValue: prop.default,
});
}
return fields;
}
/**
* Builds the form's initial values from the (masked) stored config: stored
* value wins, then the schema default, then a type-appropriate empty value.
* Everything non-boolean is kept as a string for the input elements.
*/
export function buildInitialValues(
fields: PluginConfigField[],
config: Record<string, unknown>,
): Record<string, string | boolean> {
const values: Record<string, string | boolean> = {};
for (const field of fields) {
const stored = config[field.name] ?? field.defaultValue;
if (field.type === "boolean") {
values[field.name] = typeof stored === "boolean" ? stored : false;
} else {
values[field.name] = stored === undefined || stored === null ? "" : String(stored);
}
}
return values;
}
function coerceNumeric(field: PluginConfigField, raw: string): number | undefined {
// Never Number.parseInt here: it silently truncates "1.5" to 1, persisting
// a value the operator did not type. Reject non-integral input instead so
// the field is omitted and the required-field/backend validation surfaces
// the problem.
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return undefined;
if (field.type === "integer" && !Number.isInteger(parsed)) return undefined;
return parsed;
}
/** Maps a select control's string back to the schema's original typed enum
* value, so numeric/boolean enums are submitted with their real types. */
function coerceEnum(field: PluginConfigField, raw: string): unknown {
const index = (field.enumValues ?? []).indexOf(raw);
if (index >= 0 && field.enumRawValues && index < field.enumRawValues.length) {
return field.enumRawValues[index];
}
return raw;
}
/**
* Converts form values back into the config object to PATCH. Empty string
* inputs are omitted (an unset optional field stays unset rather than
* persisting ""), booleans are always included, and numeric fields are
* parsed — an unparseable numeric input is omitted so the backend's schema
* validation reports the missing required field instead of a bogus value.
*/
export function serializeConfigValues(
fields: PluginConfigField[],
values: Record<string, string | boolean>,
): Record<string, unknown> {
const config: Record<string, unknown> = {};
for (const field of fields) {
const value = values[field.name];
if (field.type === "boolean") {
config[field.name] = value === true;
continue;
}
if (typeof value !== "string" || value === "") continue;
if (field.type === "number" || field.type === "integer") {
const parsed = coerceNumeric(field, value);
if (parsed !== undefined) config[field.name] = parsed;
continue;
}
if (field.type === "enum") {
config[field.name] = coerceEnum(field, value);
continue;
}
config[field.name] = value;
}
return config;
}
/**
* Client-side pre-validation matching the backend's required-field check, so
* the form can flag missing values before a round-trip. A secret field
* showing the mask counts as set (the backend keeps the stored value).
*/
export function missingRequiredFields(
fields: PluginConfigField[],
values: Record<string, string | boolean>,
): string[] {
return fields
.filter((field) => field.required && field.type !== "boolean")
.filter((field) => {
const value = values[field.name];
return typeof value !== "string" || value.trim() === "";
})
.map((field) => field.label);
}
+13
View File
@@ -19,6 +19,7 @@ import IntegrationsLinearPage from "@/app/settings/integrations/linear/page";
import IntegrationsSentryPage from "@/app/settings/integrations/sentry/page";
import IntegrationsSlackPage from "@/app/settings/integrations/slack/page";
import PluginsSettingsPage from "@/app/settings/plugins/page";
import PluginDetailPage from "@/app/settings/plugins/[pluginId]/page";
import UtilityAgentsSettingsPage from "@/app/settings/utility-agents/page";
import AutomationsPage from "@/app/settings/workspace/[id]/automations/page";
import AutomationEditorPage from "@/app/settings/workspace/[id]/automations/[automationId]/page";
@@ -263,6 +264,18 @@ function renderDynamicSettingsRoute(pathname: string) {
const workspaceRoute = renderWorkspaceSettingsRoute(pathname);
if (workspaceRoute) return workspaceRoute;
const pluginId = matchSingle(pathname, /^\/settings\/plugins\/([^/]+)$/);
if (pluginId) {
// A plugin-authored settings route registered at exactly this path
// (registry.registerSettingsRoute) wins over the first-party detail
// page, so a plugin can fully replace its own settings surface.
return (
renderPluginSettingsRoute(pathname) ?? (
<PluginDetailPage params={Promise.resolve({ pluginId })} />
)
);
}
const agentProfile = matchDouble(pathname, /^\/settings\/agents\/([^/]+)\/profiles\/([^/]+)$/);
if (agentProfile) {
return <AgentProfileRoute />;
+36 -1
View File
@@ -54,6 +54,27 @@ service Host {
rpc RevealSecret(RevealSecretRequest) returns (RevealSecretResponse);
rpc EmitEvent(EmitEventRequest) returns (EmitEventResponse);
// The plugin's own operator-editable config (Settings > Plugins > <plugin>,
// driven by the manifest's config_schema). Ungated; secret values arrive
// in cleartext — this RPC is how an operator-configured credential (e.g. a
// PAT) reaches the plugin. At rest, secret config fields live in kandev's
// encrypted vault (the config file holds only a vault reference); the Host
// resolves them before responding. kandev restarts a running plugin on
// config change, so reading config at startup is sufficient.
rpc GetConfig(GetConfigRequest) returns (GetConfigResponse);
// Plugin-scoped secret primitives — capability `secrets`. Keys are
// namespaced server-side to the calling plugin (vault id
// "plugin:<id>:secret:<key>", key must match
// [a-zA-Z0-9][a-zA-Z0-9._-]{0,127}), so a plugin can only ever touch its
// OWN entries; RevealSecret remains the way to resolve an
// operator-provided reference to a shared/global secret. Values live in
// kandev's encrypted vault (AES-256-GCM at rest) and the whole
// "plugin:<id>:" namespace is deleted on uninstall.
rpc GetSecret(GetSecretRequest) returns (GetSecretResponse);
rpc SetSecret(SetSecretRequest) returns (SetSecretResponse);
rpc DeleteSecret(DeleteSecretRequest) returns (DeleteSecretResponse);
// Host data API (ADR 0043, §3a below) — reads, capability api_read:<resource>.
rpc ListTasks(ListTasksRequest) returns (ListTasksResponse);
rpc GetTask(GetTaskRequest) returns (Task);
@@ -112,6 +133,16 @@ message ListStateRequest { string scope = 1; string scope_id = 2; }
message ListStateResponse { repeated StateEntry entries = 1; }
message StateEntry { string key = 1; google.protobuf.Struct value = 2; string updated_at = 3; }
message GetConfigRequest {}
message GetConfigResponse { google.protobuf.Struct config = 1; }
message GetSecretRequest { string key = 1; }
message GetSecretResponse { bool found = 1; string value = 2; }
message SetSecretRequest { string key = 1; string value = 2; }
message SetSecretResponse {}
message DeleteSecretRequest { string key = 1; }
message DeleteSecretResponse {}
message RevealSecretRequest { string ref = 1; }
message RevealSecretResponse { string value = 1; }
@@ -222,7 +253,11 @@ type Plugin interface {
}
type Host interface { // injected before Serve returns
GetState/SetState/DeleteState/ListState(...)
RevealSecret(ctx, ref string) (string, error)
GetConfig(ctx) (map[string]any, error) // own operator config, cleartext
RevealSecret(ctx, ref string) (string, error) // operator-provided shared-secret ref
GetSecret(ctx, key) (value string, found bool, err error) // plugin-owned, vault-backed
SetSecret(ctx, key, value string) error
DeleteSecret(ctx, key string) error
EmitEvent(ctx, name string, payload map[string]any) error
// Host data API (ADR 0043, §3a) — each accessor is capability-gated by
+4 -1
View File
@@ -213,7 +213,10 @@ POST /api/plugins/install # Install a plugin: JSON {"url": "..."} or
POST /api/plugins/sync # Reconcile the registry with the plugins directory on disk
GET /api/plugins # List installed plugins
GET /api/plugins/{id} # Get plugin detail
PATCH /api/plugins/{id} # Update plugin config
GET /api/plugins/{id}/config # Stored operator config; secret values masked
# (secret fields live in the encrypted vault; the
# config file persists only a vault reference)
PATCH /api/plugins/{id} # Update plugin config (masked secrets keep stored values; restarts a running plugin)
DELETE /api/plugins/{id} # Uninstall plugin (stops subprocess, removes package + state)
POST /api/plugins/{id}/enable
POST /api/plugins/{id}/disable