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:
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user