From fae2303db3c1bdf1132f2dbe37dfd557038a2733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Fri, 17 Jul 2026 22:13:58 +0100 Subject: [PATCH] feat(plugins): per-plugin settings pages driven by manifest config_schema (#1761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * fix(plugins): write plugin config file owner-only (0600) Operator config can now carry cleartext secret values (config_schema fields marked secret), so .config.yml must not be world-readable — match the 0600 mode the record file already uses. Co-Authored-By: Claude Fable 5 * 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::secret: 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 — .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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Fable 5 --- .../fixture-package/manifest.yaml | 14 + apps/backend/cmd/plugin-fixture/main_test.go | 6 +- apps/backend/cmd/plugin-fixture/plugin.go | 60 +- .../secretadapter/secretadapter.go | 16 + .../secretadapter/secretadapter_test.go | 31 +- apps/backend/internal/plugins/config.go | 343 +++++++ .../internal/plugins/config_secrets_test.go | 632 ++++++++++++ apps/backend/internal/plugins/config_test.go | 411 ++++++++ apps/backend/internal/plugins/handlers.go | 21 +- .../backend/internal/plugins/handlers_test.go | 4 + apps/backend/internal/plugins/host.go | 123 ++- apps/backend/internal/plugins/provider.go | 2 +- .../internal/plugins/runtime/manager_test.go | 4 + apps/backend/internal/plugins/service.go | 312 +++++- apps/backend/internal/plugins/service_test.go | 52 +- .../internal/plugins/store/fs_store.go | 7 +- .../internal/plugins/store/fs_store_test.go | 27 + apps/backend/internal/plugins/types.go | 16 +- apps/backend/pkg/pluginsdk/host.go | 99 +- apps/backend/pkg/pluginsdk/host_data_test.go | 6 + apps/backend/pkg/pluginsdk/host_test.go | 67 ++ apps/backend/pkg/pluginsdk/plugin_test.go | 7 + .../proto/kandev/plugin/v1/plugin.pb.go | 918 +++++++++++++----- .../proto/kandev/plugin/v1/plugin.proto | 40 + .../proto/kandev/plugin/v1/plugin_grpc.pb.go | 176 ++++ .../app/settings/plugins/[pluginId]/page.tsx | 13 + .../settings/plugins/plugin-config-form.tsx | 143 +++ .../settings/plugins/plugin-detail.tsx | 241 +++++ .../settings/plugins/plugin-manifest-card.tsx | 128 +++ .../settings/plugins/plugin-row.tsx | 9 +- .../plugins/use-plugin-config-form.test.ts | 154 +++ .../plugins/use-plugin-config-form.ts | 139 +++ apps/web/e2e/tests/plugins/plugins.spec.ts | 88 ++ apps/web/lib/api/domains/plugins-api.test.ts | 25 + apps/web/lib/api/domains/plugins-api.ts | 15 +- apps/web/lib/plugins/config-schema.test.ts | 122 +++ apps/web/lib/plugins/config-schema.ts | 185 ++++ apps/web/src/settings-routes.tsx | 13 + docs/plans/plugins/GRPC-CONTRACT.md | 37 +- docs/specs/plugins/spec.md | 5 +- 40 files changed, 4393 insertions(+), 318 deletions(-) create mode 100644 apps/backend/internal/plugins/config.go create mode 100644 apps/backend/internal/plugins/config_secrets_test.go create mode 100644 apps/backend/internal/plugins/config_test.go create mode 100644 apps/web/app/settings/plugins/[pluginId]/page.tsx create mode 100644 apps/web/components/settings/plugins/plugin-config-form.tsx create mode 100644 apps/web/components/settings/plugins/plugin-detail.tsx create mode 100644 apps/web/components/settings/plugins/plugin-manifest-card.tsx create mode 100644 apps/web/components/settings/plugins/use-plugin-config-form.test.ts create mode 100644 apps/web/components/settings/plugins/use-plugin-config-form.ts create mode 100644 apps/web/lib/plugins/config-schema.test.ts create mode 100644 apps/web/lib/plugins/config-schema.ts diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml index 3b08387a2f..d452fa2dcc 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml +++ b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml @@ -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" diff --git a/apps/backend/cmd/plugin-fixture/main_test.go b/apps/backend/cmd/plugin-fixture/main_test.go index c8dc1ec6a9..024480ce5e 100644 --- a/apps/backend/cmd/plugin-fixture/main_test.go +++ b/apps/backend/cmd/plugin-fixture/main_test.go @@ -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 } diff --git a/apps/backend/cmd/plugin-fixture/plugin.go b/apps/backend/cmd/plugin-fixture/plugin.go index 3000b67345..dcdeae985a 100644 --- a/apps/backend/cmd/plugin-fixture/plugin.go +++ b/apps/backend/cmd/plugin-fixture/plugin.go @@ -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 { diff --git a/apps/backend/internal/integrations/secretadapter/secretadapter.go b/apps/backend/internal/integrations/secretadapter/secretadapter.go index 5f2a26650c..bfdc3a63b4 100644 --- a/apps/backend/internal/integrations/secretadapter/secretadapter.go +++ b/apps/backend/internal/integrations/secretadapter/secretadapter.go @@ -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::..." 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. diff --git a/apps/backend/internal/integrations/secretadapter/secretadapter_test.go b/apps/backend/internal/integrations/secretadapter/secretadapter_test.go index b6a6e5d49e..6408e6ce5b 100644 --- a/apps/backend/internal/integrations/secretadapter/secretadapter_test.go +++ b/apps/backend/internal/integrations/secretadapter/secretadapter_test.go @@ -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) + } +} diff --git a/apps/backend/internal/plugins/config.go b/apps/backend/internal/plugins/config.go new file mode 100644 index 0000000000..bcc10ba6c9 --- /dev/null +++ b/apps/backend/internal/plugins/config.go @@ -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 > , 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::...". 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 .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 + } +} diff --git a/apps/backend/internal/plugins/config_secrets_test.go b/apps/backend/internal/plugins/config_secrets_test.go new file mode 100644 index 0000000000..295aaf2f20 --- /dev/null +++ b/apps/backend/internal/plugins/config_secrets_test.go @@ -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) + } +} diff --git a/apps/backend/internal/plugins/config_test.go b/apps/backend/internal/plugins/config_test.go new file mode 100644 index 0000000000..c536618d27 --- /dev/null +++ b/apps/backend/internal/plugins/config_test.go @@ -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()) + } +} diff --git a/apps/backend/internal/plugins/handlers.go b/apps/backend/internal/plugins/handlers.go index 6e3d99dcc6..823118d25a 100644 --- a/apps/backend/internal/plugins/handlers.go +++ b/apps/backend/internal/plugins/handlers.go @@ -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 } diff --git a/apps/backend/internal/plugins/handlers_test.go b/apps/backend/internal/plugins/handlers_test.go index 93facd8f4f..22b66f18d2 100644 --- a/apps/backend/internal/plugins/handlers_test.go +++ b/apps/backend/internal/plugins/handlers_test.go @@ -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 diff --git a/apps/backend/internal/plugins/host.go b/apps/backend/internal/plugins/host.go index 63eeb981e8..7706cfe578 100644 --- a/apps/backend/internal/plugins/host.go +++ b/apps/backend/internal/plugins/host.go @@ -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::secret:") — 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") diff --git a/apps/backend/internal/plugins/provider.go b/apps/backend/internal/plugins/provider.go index 63b0c745b1..7ad8469b7c 100644 --- a/apps/backend/internal/plugins/provider.go +++ b/apps/backend/internal/plugins/provider.go @@ -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) diff --git a/apps/backend/internal/plugins/runtime/manager_test.go b/apps/backend/internal/plugins/runtime/manager_test.go index a56eb41b90..678e1f31ac 100644 --- a/apps/backend/internal/plugins/runtime/manager_test.go +++ b/apps/backend/internal/plugins/runtime/manager_test.go @@ -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 } diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go index 2fea88d8ba..e7f8d9b997 100644 --- a/apps/backend/internal/plugins/service.go +++ b/apps/backend/internal/plugins/service.go @@ -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 .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::..." — 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. diff --git a/apps/backend/internal/plugins/service_test.go b/apps/backend/internal/plugins/service_test.go index fd741ab461..b9ddfc990a 100644 --- a/apps/backend/internal/plugins/service_test.go +++ b/apps/backend/internal/plugins/service_test.go @@ -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) } } diff --git a/apps/backend/internal/plugins/store/fs_store.go b/apps/backend/internal/plugins/store/fs_store.go index 6b059c244c..a0b0611d58 100644 --- a/apps/backend/internal/plugins/store/fs_store.go +++ b/apps/backend/internal/plugins/store/fs_store.go @@ -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 diff --git a/apps/backend/internal/plugins/store/fs_store_test.go b/apps/backend/internal/plugins/store/fs_store_test.go index 9efe88517b..abdd18d710 100644 --- a/apps/backend/internal/plugins/store/fs_store_test.go +++ b/apps/backend/internal/plugins/store/fs_store_test.go @@ -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) diff --git a/apps/backend/internal/plugins/types.go b/apps/backend/internal/plugins/types.go index de44719678..58568afbf3 100644 --- a/apps/backend/internal/plugins/types.go +++ b/apps/backend/internal/plugins/types.go @@ -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 diff --git a/apps/backend/pkg/pluginsdk/host.go b/apps/backend/pkg/pluginsdk/host.go index dd97bd26c7..3723f316cd 100644 --- a/apps/backend/pkg/pluginsdk/host.go +++ b/apps/backend/pkg/pluginsdk/host.go @@ -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 > 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 { diff --git a/apps/backend/pkg/pluginsdk/host_data_test.go b/apps/backend/pkg/pluginsdk/host_data_test.go index 41d8c5db96..cda4024f75 100644 --- a/apps/backend/pkg/pluginsdk/host_data_test.go +++ b/apps/backend/pkg/pluginsdk/host_data_test.go @@ -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 diff --git a/apps/backend/pkg/pluginsdk/host_test.go b/apps/backend/pkg/pluginsdk/host_test.go index 176500bfb4..33128e7cd7 100644 --- a/apps/backend/pkg/pluginsdk/host_test.go +++ b/apps/backend/pkg/pluginsdk/host_test.go @@ -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) diff --git a/apps/backend/pkg/pluginsdk/plugin_test.go b/apps/backend/pkg/pluginsdk/plugin_test.go index d97832eb4f..2dff3f68f9 100644 --- a/apps/backend/pkg/pluginsdk/plugin_test.go +++ b/apps/backend/pkg/pluginsdk/plugin_test.go @@ -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 } diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go index 3b62cc7634..b6466b5737 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go @@ -926,6 +926,350 @@ func (x *StateEntry) GetUpdatedAt() string { return "" } +type GetConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetConfigRequest) Reset() { + *x = GetConfigRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetConfigRequest) ProtoMessage() {} + +func (x *GetConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetConfigRequest.ProtoReflect.Descriptor instead. +func (*GetConfigRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{16} +} + +type GetConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Config *structpb.Struct `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetConfigResponse) Reset() { + *x = GetConfigResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetConfigResponse) ProtoMessage() {} + +func (x *GetConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetConfigResponse.ProtoReflect.Descriptor instead. +func (*GetConfigResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{17} +} + +func (x *GetConfigResponse) GetConfig() *structpb.Struct { + if x != nil { + return x.Config + } + return nil +} + +type GetSecretRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSecretRequest) Reset() { + *x = GetSecretRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSecretRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSecretRequest) ProtoMessage() {} + +func (x *GetSecretRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSecretRequest.ProtoReflect.Descriptor instead. +func (*GetSecretRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{18} +} + +func (x *GetSecretRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +type GetSecretResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Found bool `protobuf:"varint,1,opt,name=found,proto3" json:"found,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSecretResponse) Reset() { + *x = GetSecretResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSecretResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSecretResponse) ProtoMessage() {} + +func (x *GetSecretResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSecretResponse.ProtoReflect.Descriptor instead. +func (*GetSecretResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{19} +} + +func (x *GetSecretResponse) GetFound() bool { + if x != nil { + return x.Found + } + return false +} + +func (x *GetSecretResponse) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type SetSecretRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetSecretRequest) Reset() { + *x = SetSecretRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetSecretRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetSecretRequest) ProtoMessage() {} + +func (x *SetSecretRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetSecretRequest.ProtoReflect.Descriptor instead. +func (*SetSecretRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{20} +} + +func (x *SetSecretRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *SetSecretRequest) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type SetSecretResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetSecretResponse) Reset() { + *x = SetSecretResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetSecretResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetSecretResponse) ProtoMessage() {} + +func (x *SetSecretResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetSecretResponse.ProtoReflect.Descriptor instead. +func (*SetSecretResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{21} +} + +type DeleteSecretRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSecretRequest) Reset() { + *x = DeleteSecretRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSecretRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSecretRequest) ProtoMessage() {} + +func (x *DeleteSecretRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSecretRequest.ProtoReflect.Descriptor instead. +func (*DeleteSecretRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{22} +} + +func (x *DeleteSecretRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +type DeleteSecretResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSecretResponse) Reset() { + *x = DeleteSecretResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSecretResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSecretResponse) ProtoMessage() {} + +func (x *DeleteSecretResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSecretResponse.ProtoReflect.Descriptor instead. +func (*DeleteSecretResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{23} +} + type RevealSecretRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Ref string `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` @@ -935,7 +1279,7 @@ type RevealSecretRequest struct { func (x *RevealSecretRequest) Reset() { *x = RevealSecretRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[16] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -947,7 +1291,7 @@ func (x *RevealSecretRequest) String() string { func (*RevealSecretRequest) ProtoMessage() {} func (x *RevealSecretRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[16] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -960,7 +1304,7 @@ func (x *RevealSecretRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevealSecretRequest.ProtoReflect.Descriptor instead. func (*RevealSecretRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{16} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{24} } func (x *RevealSecretRequest) GetRef() string { @@ -979,7 +1323,7 @@ type RevealSecretResponse struct { func (x *RevealSecretResponse) Reset() { *x = RevealSecretResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[17] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -991,7 +1335,7 @@ func (x *RevealSecretResponse) String() string { func (*RevealSecretResponse) ProtoMessage() {} func (x *RevealSecretResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[17] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1004,7 +1348,7 @@ func (x *RevealSecretResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevealSecretResponse.ProtoReflect.Descriptor instead. func (*RevealSecretResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{17} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{25} } func (x *RevealSecretResponse) GetValue() string { @@ -1024,7 +1368,7 @@ type EmitEventRequest struct { func (x *EmitEventRequest) Reset() { *x = EmitEventRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[18] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1036,7 +1380,7 @@ func (x *EmitEventRequest) String() string { func (*EmitEventRequest) ProtoMessage() {} func (x *EmitEventRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[18] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1049,7 +1393,7 @@ func (x *EmitEventRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EmitEventRequest.ProtoReflect.Descriptor instead. func (*EmitEventRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{18} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{26} } func (x *EmitEventRequest) GetEventName() string { @@ -1074,7 +1418,7 @@ type EmitEventResponse struct { func (x *EmitEventResponse) Reset() { *x = EmitEventResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[19] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1086,7 +1430,7 @@ func (x *EmitEventResponse) String() string { func (*EmitEventResponse) ProtoMessage() {} func (x *EmitEventResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[19] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1099,7 +1443,7 @@ func (x *EmitEventResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EmitEventResponse.ProtoReflect.Descriptor instead. func (*EmitEventResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{19} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{27} } // ── Host data API (ADR 0043) ───────────────────────────────────────────────── @@ -1115,7 +1459,7 @@ type Page struct { func (x *Page) Reset() { *x = Page{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[20] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1127,7 +1471,7 @@ func (x *Page) String() string { func (*Page) ProtoMessage() {} func (x *Page) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[20] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1140,7 +1484,7 @@ func (x *Page) ProtoReflect() protoreflect.Message { // Deprecated: Use Page.ProtoReflect.Descriptor instead. func (*Page) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{20} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{28} } func (x *Page) GetLimit() int32 { @@ -1167,7 +1511,7 @@ type PageInfo struct { func (x *PageInfo) Reset() { *x = PageInfo{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[21] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1179,7 +1523,7 @@ func (x *PageInfo) String() string { func (*PageInfo) ProtoMessage() {} func (x *PageInfo) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[21] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1192,7 +1536,7 @@ func (x *PageInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use PageInfo.ProtoReflect.Descriptor instead. func (*PageInfo) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{21} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{29} } func (x *PageInfo) GetNextCursor() string { @@ -1235,7 +1579,7 @@ type Task struct { func (x *Task) Reset() { *x = Task{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[22] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1247,7 +1591,7 @@ func (x *Task) String() string { func (*Task) ProtoMessage() {} func (x *Task) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[22] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1260,7 +1604,7 @@ func (x *Task) ProtoReflect() protoreflect.Message { // Deprecated: Use Task.ProtoReflect.Descriptor instead. func (*Task) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{22} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{30} } func (x *Task) GetId() string { @@ -1394,7 +1738,7 @@ type TaskRepository struct { func (x *TaskRepository) Reset() { *x = TaskRepository{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[23] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1406,7 +1750,7 @@ func (x *TaskRepository) String() string { func (*TaskRepository) ProtoMessage() {} func (x *TaskRepository) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[23] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1419,7 +1763,7 @@ func (x *TaskRepository) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskRepository.ProtoReflect.Descriptor instead. func (*TaskRepository) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{23} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{31} } func (x *TaskRepository) GetId() string { @@ -1463,7 +1807,7 @@ type TaskFilter struct { func (x *TaskFilter) Reset() { *x = TaskFilter{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[24] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1475,7 +1819,7 @@ func (x *TaskFilter) String() string { func (*TaskFilter) ProtoMessage() {} func (x *TaskFilter) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[24] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1488,7 +1832,7 @@ func (x *TaskFilter) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskFilter.ProtoReflect.Descriptor instead. func (*TaskFilter) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{24} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{32} } func (x *TaskFilter) GetWorkspaceIds() []string { @@ -1536,7 +1880,7 @@ type ListTasksRequest struct { func (x *ListTasksRequest) Reset() { *x = ListTasksRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[25] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1548,7 +1892,7 @@ func (x *ListTasksRequest) String() string { func (*ListTasksRequest) ProtoMessage() {} func (x *ListTasksRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[25] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1561,7 +1905,7 @@ func (x *ListTasksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListTasksRequest.ProtoReflect.Descriptor instead. func (*ListTasksRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{25} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{33} } func (x *ListTasksRequest) GetFilter() *TaskFilter { @@ -1588,7 +1932,7 @@ type ListTasksResponse struct { func (x *ListTasksResponse) Reset() { *x = ListTasksResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[26] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1600,7 +1944,7 @@ func (x *ListTasksResponse) String() string { func (*ListTasksResponse) ProtoMessage() {} func (x *ListTasksResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[26] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1613,7 +1957,7 @@ func (x *ListTasksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListTasksResponse.ProtoReflect.Descriptor instead. func (*ListTasksResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{26} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{34} } func (x *ListTasksResponse) GetTasks() []*Task { @@ -1639,7 +1983,7 @@ type GetTaskRequest struct { func (x *GetTaskRequest) Reset() { *x = GetTaskRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[27] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1651,7 +1995,7 @@ func (x *GetTaskRequest) String() string { func (*GetTaskRequest) ProtoMessage() {} func (x *GetTaskRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[27] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1664,7 +2008,7 @@ func (x *GetTaskRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetTaskRequest.ProtoReflect.Descriptor instead. func (*GetTaskRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{27} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{35} } func (x *GetTaskRequest) GetId() string { @@ -1683,7 +2027,7 @@ type GetTaskResponse struct { func (x *GetTaskResponse) Reset() { *x = GetTaskResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[28] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1695,7 +2039,7 @@ func (x *GetTaskResponse) String() string { func (*GetTaskResponse) ProtoMessage() {} func (x *GetTaskResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[28] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1708,7 +2052,7 @@ func (x *GetTaskResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetTaskResponse.ProtoReflect.Descriptor instead. func (*GetTaskResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{28} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{36} } func (x *GetTaskResponse) GetTask() *Task { @@ -1735,7 +2079,7 @@ type Workspace struct { func (x *Workspace) Reset() { *x = Workspace{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[29] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1747,7 +2091,7 @@ func (x *Workspace) String() string { func (*Workspace) ProtoMessage() {} func (x *Workspace) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[29] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1760,7 +2104,7 @@ func (x *Workspace) ProtoReflect() protoreflect.Message { // Deprecated: Use Workspace.ProtoReflect.Descriptor instead. func (*Workspace) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{29} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{37} } func (x *Workspace) GetId() string { @@ -1828,7 +2172,7 @@ type ListWorkspacesRequest struct { func (x *ListWorkspacesRequest) Reset() { *x = ListWorkspacesRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[30] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1840,7 +2184,7 @@ func (x *ListWorkspacesRequest) String() string { func (*ListWorkspacesRequest) ProtoMessage() {} func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[30] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1853,7 +2197,7 @@ func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesRequest.ProtoReflect.Descriptor instead. func (*ListWorkspacesRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{30} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{38} } func (x *ListWorkspacesRequest) GetPage() *Page { @@ -1873,7 +2217,7 @@ type ListWorkspacesResponse struct { func (x *ListWorkspacesResponse) Reset() { *x = ListWorkspacesResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[31] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1885,7 +2229,7 @@ func (x *ListWorkspacesResponse) String() string { func (*ListWorkspacesResponse) ProtoMessage() {} func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[31] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1898,7 +2242,7 @@ func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkspacesResponse.ProtoReflect.Descriptor instead. func (*ListWorkspacesResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{31} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{39} } func (x *ListWorkspacesResponse) GetWorkspaces() []*Workspace { @@ -1930,7 +2274,7 @@ type Workflow struct { func (x *Workflow) Reset() { *x = Workflow{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[32] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1942,7 +2286,7 @@ func (x *Workflow) String() string { func (*Workflow) ProtoMessage() {} func (x *Workflow) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[32] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1955,7 +2299,7 @@ func (x *Workflow) ProtoReflect() protoreflect.Message { // Deprecated: Use Workflow.ProtoReflect.Descriptor instead. func (*Workflow) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{32} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{40} } func (x *Workflow) GetId() string { @@ -2017,7 +2361,7 @@ type ListWorkflowsRequest struct { func (x *ListWorkflowsRequest) Reset() { *x = ListWorkflowsRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[33] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2029,7 +2373,7 @@ func (x *ListWorkflowsRequest) String() string { func (*ListWorkflowsRequest) ProtoMessage() {} func (x *ListWorkflowsRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[33] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2042,7 +2386,7 @@ func (x *ListWorkflowsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkflowsRequest.ProtoReflect.Descriptor instead. func (*ListWorkflowsRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{33} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{41} } func (x *ListWorkflowsRequest) GetWorkspaceId() string { @@ -2069,7 +2413,7 @@ type ListWorkflowsResponse struct { func (x *ListWorkflowsResponse) Reset() { *x = ListWorkflowsResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[34] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2081,7 +2425,7 @@ func (x *ListWorkflowsResponse) String() string { func (*ListWorkflowsResponse) ProtoMessage() {} func (x *ListWorkflowsResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[34] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2094,7 +2438,7 @@ func (x *ListWorkflowsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkflowsResponse.ProtoReflect.Descriptor instead. func (*ListWorkflowsResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{34} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{42} } func (x *ListWorkflowsResponse) GetWorkflows() []*Workflow { @@ -2124,7 +2468,7 @@ type WorkflowStep struct { func (x *WorkflowStep) Reset() { *x = WorkflowStep{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[35] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2136,7 +2480,7 @@ func (x *WorkflowStep) String() string { func (*WorkflowStep) ProtoMessage() {} func (x *WorkflowStep) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[35] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2149,7 +2493,7 @@ func (x *WorkflowStep) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkflowStep.ProtoReflect.Descriptor instead. func (*WorkflowStep) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{35} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{43} } func (x *WorkflowStep) GetId() string { @@ -2196,7 +2540,7 @@ type ListWorkflowStepsRequest struct { func (x *ListWorkflowStepsRequest) Reset() { *x = ListWorkflowStepsRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[36] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2208,7 +2552,7 @@ func (x *ListWorkflowStepsRequest) String() string { func (*ListWorkflowStepsRequest) ProtoMessage() {} func (x *ListWorkflowStepsRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[36] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2221,7 +2565,7 @@ func (x *ListWorkflowStepsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkflowStepsRequest.ProtoReflect.Descriptor instead. func (*ListWorkflowStepsRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{36} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{44} } func (x *ListWorkflowStepsRequest) GetWorkflowId() string { @@ -2240,7 +2584,7 @@ type ListWorkflowStepsResponse struct { func (x *ListWorkflowStepsResponse) Reset() { *x = ListWorkflowStepsResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[37] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2252,7 +2596,7 @@ func (x *ListWorkflowStepsResponse) String() string { func (*ListWorkflowStepsResponse) ProtoMessage() {} func (x *ListWorkflowStepsResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[37] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2265,7 +2609,7 @@ func (x *ListWorkflowStepsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkflowStepsResponse.ProtoReflect.Descriptor instead. func (*ListWorkflowStepsResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{37} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{45} } func (x *ListWorkflowStepsResponse) GetSteps() []*WorkflowStep { @@ -2290,7 +2634,7 @@ type AgentProfile struct { func (x *AgentProfile) Reset() { *x = AgentProfile{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[38] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2302,7 +2646,7 @@ func (x *AgentProfile) String() string { func (*AgentProfile) ProtoMessage() {} func (x *AgentProfile) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[38] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2315,7 +2659,7 @@ func (x *AgentProfile) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentProfile.ProtoReflect.Descriptor instead. func (*AgentProfile) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{38} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{46} } func (x *AgentProfile) GetId() string { @@ -2369,7 +2713,7 @@ type ListAgentProfilesRequest struct { func (x *ListAgentProfilesRequest) Reset() { *x = ListAgentProfilesRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[39] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2381,7 +2725,7 @@ func (x *ListAgentProfilesRequest) String() string { func (*ListAgentProfilesRequest) ProtoMessage() {} func (x *ListAgentProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[39] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2394,7 +2738,7 @@ func (x *ListAgentProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListAgentProfilesRequest.ProtoReflect.Descriptor instead. func (*ListAgentProfilesRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{39} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{47} } func (x *ListAgentProfilesRequest) GetPage() *Page { @@ -2414,7 +2758,7 @@ type ListAgentProfilesResponse struct { func (x *ListAgentProfilesResponse) Reset() { *x = ListAgentProfilesResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[40] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2426,7 +2770,7 @@ func (x *ListAgentProfilesResponse) String() string { func (*ListAgentProfilesResponse) ProtoMessage() {} func (x *ListAgentProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[40] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2439,7 +2783,7 @@ func (x *ListAgentProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListAgentProfilesResponse.ProtoReflect.Descriptor instead. func (*ListAgentProfilesResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{40} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{48} } func (x *ListAgentProfilesResponse) GetProfiles() []*AgentProfile { @@ -2468,7 +2812,7 @@ type Repository struct { func (x *Repository) Reset() { *x = Repository{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[41] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2480,7 +2824,7 @@ func (x *Repository) String() string { func (*Repository) ProtoMessage() {} func (x *Repository) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[41] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2493,7 +2837,7 @@ func (x *Repository) ProtoReflect() protoreflect.Message { // Deprecated: Use Repository.ProtoReflect.Descriptor instead. func (*Repository) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{41} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{49} } func (x *Repository) GetId() string { @@ -2534,7 +2878,7 @@ type ListRepositoriesRequest struct { func (x *ListRepositoriesRequest) Reset() { *x = ListRepositoriesRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[42] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2546,7 +2890,7 @@ func (x *ListRepositoriesRequest) String() string { func (*ListRepositoriesRequest) ProtoMessage() {} func (x *ListRepositoriesRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[42] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2559,7 +2903,7 @@ func (x *ListRepositoriesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRepositoriesRequest.ProtoReflect.Descriptor instead. func (*ListRepositoriesRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{42} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{50} } func (x *ListRepositoriesRequest) GetWorkspaceId() string { @@ -2586,7 +2930,7 @@ type ListRepositoriesResponse struct { func (x *ListRepositoriesResponse) Reset() { *x = ListRepositoriesResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[43] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2598,7 +2942,7 @@ func (x *ListRepositoriesResponse) String() string { func (*ListRepositoriesResponse) ProtoMessage() {} func (x *ListRepositoriesResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[43] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2611,7 +2955,7 @@ func (x *ListRepositoriesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRepositoriesResponse.ProtoReflect.Descriptor instead. func (*ListRepositoriesResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{43} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{51} } func (x *ListRepositoriesResponse) GetRepositories() []*Repository { @@ -2656,7 +3000,7 @@ type Session struct { func (x *Session) Reset() { *x = Session{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[44] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2668,7 +3012,7 @@ func (x *Session) String() string { func (*Session) ProtoMessage() {} func (x *Session) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[44] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2681,7 +3025,7 @@ func (x *Session) ProtoReflect() protoreflect.Message { // Deprecated: Use Session.ProtoReflect.Descriptor instead. func (*Session) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{44} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{52} } func (x *Session) GetId() string { @@ -2765,7 +3109,7 @@ type SessionFilter struct { func (x *SessionFilter) Reset() { *x = SessionFilter{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[45] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2777,7 +3121,7 @@ func (x *SessionFilter) String() string { func (*SessionFilter) ProtoMessage() {} func (x *SessionFilter) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[45] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2790,7 +3134,7 @@ func (x *SessionFilter) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionFilter.ProtoReflect.Descriptor instead. func (*SessionFilter) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{45} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{53} } func (x *SessionFilter) GetTaskIds() []string { @@ -2824,7 +3168,7 @@ type ListSessionsRequest struct { func (x *ListSessionsRequest) Reset() { *x = ListSessionsRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[46] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2836,7 +3180,7 @@ func (x *ListSessionsRequest) String() string { func (*ListSessionsRequest) ProtoMessage() {} func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[46] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2849,7 +3193,7 @@ func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsRequest.ProtoReflect.Descriptor instead. func (*ListSessionsRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{46} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{54} } func (x *ListSessionsRequest) GetFilter() *SessionFilter { @@ -2876,7 +3220,7 @@ type ListSessionsResponse struct { func (x *ListSessionsResponse) Reset() { *x = ListSessionsResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[47] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2888,7 +3232,7 @@ func (x *ListSessionsResponse) String() string { func (*ListSessionsResponse) ProtoMessage() {} func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[47] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2901,7 +3245,7 @@ func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsResponse.ProtoReflect.Descriptor instead. func (*ListSessionsResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{47} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{55} } func (x *ListSessionsResponse) GetSessions() []*Session { @@ -2934,7 +3278,7 @@ type SessionCodeStats struct { func (x *SessionCodeStats) Reset() { *x = SessionCodeStats{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[48] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2946,7 +3290,7 @@ func (x *SessionCodeStats) String() string { func (*SessionCodeStats) ProtoMessage() {} func (x *SessionCodeStats) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[48] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2959,7 +3303,7 @@ func (x *SessionCodeStats) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionCodeStats.ProtoReflect.Descriptor instead. func (*SessionCodeStats) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{48} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{56} } func (x *SessionCodeStats) GetSessionId() string { @@ -3007,7 +3351,7 @@ type ListSessionCodeStatsRequest struct { func (x *ListSessionCodeStatsRequest) Reset() { *x = ListSessionCodeStatsRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[49] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3019,7 +3363,7 @@ func (x *ListSessionCodeStatsRequest) String() string { func (*ListSessionCodeStatsRequest) ProtoMessage() {} func (x *ListSessionCodeStatsRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[49] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3032,7 +3376,7 @@ func (x *ListSessionCodeStatsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionCodeStatsRequest.ProtoReflect.Descriptor instead. func (*ListSessionCodeStatsRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{49} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{57} } func (x *ListSessionCodeStatsRequest) GetFilter() *SessionFilter { @@ -3059,7 +3403,7 @@ type ListSessionCodeStatsResponse struct { func (x *ListSessionCodeStatsResponse) Reset() { *x = ListSessionCodeStatsResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[50] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3071,7 +3415,7 @@ func (x *ListSessionCodeStatsResponse) String() string { func (*ListSessionCodeStatsResponse) ProtoMessage() {} func (x *ListSessionCodeStatsResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[50] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3084,7 +3428,7 @@ func (x *ListSessionCodeStatsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionCodeStatsResponse.ProtoReflect.Descriptor instead. func (*ListSessionCodeStatsResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{50} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{58} } func (x *ListSessionCodeStatsResponse) GetStats() []*SessionCodeStats { @@ -3117,7 +3461,7 @@ type CreateTaskRequest struct { func (x *CreateTaskRequest) Reset() { *x = CreateTaskRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[51] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3129,7 +3473,7 @@ func (x *CreateTaskRequest) String() string { func (*CreateTaskRequest) ProtoMessage() {} func (x *CreateTaskRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[51] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3142,7 +3486,7 @@ func (x *CreateTaskRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateTaskRequest.ProtoReflect.Descriptor instead. func (*CreateTaskRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{51} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{59} } func (x *CreateTaskRequest) GetWorkspaceId() string { @@ -3203,7 +3547,7 @@ type CreateTaskResponse struct { func (x *CreateTaskResponse) Reset() { *x = CreateTaskResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[52] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3215,7 +3559,7 @@ func (x *CreateTaskResponse) String() string { func (*CreateTaskResponse) ProtoMessage() {} func (x *CreateTaskResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[52] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3228,7 +3572,7 @@ func (x *CreateTaskResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateTaskResponse.ProtoReflect.Descriptor instead. func (*CreateTaskResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{52} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{60} } func (x *CreateTaskResponse) GetTask() *Task { @@ -3251,7 +3595,7 @@ type UpdateTaskRequest struct { func (x *UpdateTaskRequest) Reset() { *x = UpdateTaskRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[53] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3263,7 +3607,7 @@ func (x *UpdateTaskRequest) String() string { func (*UpdateTaskRequest) ProtoMessage() {} func (x *UpdateTaskRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[53] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3276,7 +3620,7 @@ func (x *UpdateTaskRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateTaskRequest.ProtoReflect.Descriptor instead. func (*UpdateTaskRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{53} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{61} } func (x *UpdateTaskRequest) GetId() string { @@ -3323,7 +3667,7 @@ type UpdateTaskResponse struct { func (x *UpdateTaskResponse) Reset() { *x = UpdateTaskResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[54] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3335,7 +3679,7 @@ func (x *UpdateTaskResponse) String() string { func (*UpdateTaskResponse) ProtoMessage() {} func (x *UpdateTaskResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[54] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3348,7 +3692,7 @@ func (x *UpdateTaskResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateTaskResponse.ProtoReflect.Descriptor instead. func (*UpdateTaskResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{54} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{62} } func (x *UpdateTaskResponse) GetTask() *Task { @@ -3371,7 +3715,7 @@ type Comment struct { func (x *Comment) Reset() { *x = Comment{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[55] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3383,7 +3727,7 @@ func (x *Comment) String() string { func (*Comment) ProtoMessage() {} func (x *Comment) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[55] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3396,7 +3740,7 @@ func (x *Comment) ProtoReflect() protoreflect.Message { // Deprecated: Use Comment.ProtoReflect.Descriptor instead. func (*Comment) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{55} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{63} } func (x *Comment) GetId() string { @@ -3444,7 +3788,7 @@ type CreateCommentRequest struct { func (x *CreateCommentRequest) Reset() { *x = CreateCommentRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[56] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3456,7 +3800,7 @@ func (x *CreateCommentRequest) String() string { func (*CreateCommentRequest) ProtoMessage() {} func (x *CreateCommentRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[56] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3469,7 +3813,7 @@ func (x *CreateCommentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateCommentRequest.ProtoReflect.Descriptor instead. func (*CreateCommentRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{56} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{64} } func (x *CreateCommentRequest) GetTaskId() string { @@ -3495,7 +3839,7 @@ type CreateCommentResponse struct { func (x *CreateCommentResponse) Reset() { *x = CreateCommentResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[57] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3507,7 +3851,7 @@ func (x *CreateCommentResponse) String() string { func (*CreateCommentResponse) ProtoMessage() {} func (x *CreateCommentResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[57] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3520,7 +3864,7 @@ func (x *CreateCommentResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateCommentResponse.ProtoReflect.Descriptor instead. func (*CreateCommentResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{57} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{65} } func (x *CreateCommentResponse) GetComment() *Comment { @@ -3605,7 +3949,22 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12-\n" + "\x05value\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x05value\x12\x1d\n" + "\n" + - "updated_at\x18\x03 \x01(\tR\tupdatedAt\"'\n" + + "updated_at\x18\x03 \x01(\tR\tupdatedAt\"\x12\n" + + "\x10GetConfigRequest\"D\n" + + "\x11GetConfigResponse\x12/\n" + + "\x06config\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06config\"$\n" + + "\x10GetSecretRequest\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\"?\n" + + "\x11GetSecretResponse\x12\x14\n" + + "\x05found\x18\x01 \x01(\bR\x05found\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value\":\n" + + "\x10SetSecretRequest\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value\"\x13\n" + + "\x11SetSecretResponse\"'\n" + + "\x13DeleteSecretRequest\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\"\x16\n" + + "\x14DeleteSecretResponse\"'\n" + "\x13RevealSecretRequest\x12\x10\n" + "\x03ref\x18\x01 \x01(\tR\x03ref\",\n" + "\x14RevealSecretResponse\x12\x14\n" + @@ -3834,7 +4193,7 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\fDeliverEvent\x12\x17.kandev.plugin.v1.Event\x1a\x1a.kandev.plugin.v1.EventAck\x12K\n" + "\n" + "InvokeTool\x12\x1d.kandev.plugin.v1.ToolRequest\x1a\x1e.kandev.plugin.v1.ToolResponse\x12T\n" + - "\rHandleWebhook\x12 .kandev.plugin.v1.WebhookRequest\x1a!.kandev.plugin.v1.WebhookResponse2\xb1\r\n" + + "\rHandleWebhook\x12 .kandev.plugin.v1.WebhookRequest\x1a!.kandev.plugin.v1.WebhookResponse2\x92\x10\n" + "\x04Host\x12Q\n" + "\bGetState\x12!.kandev.plugin.v1.GetStateRequest\x1a\".kandev.plugin.v1.GetStateResponse\x12Q\n" + "\bSetState\x12!.kandev.plugin.v1.SetStateRequest\x1a\".kandev.plugin.v1.SetStateResponse\x12Z\n" + @@ -3842,6 +4201,10 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\tListState\x12\".kandev.plugin.v1.ListStateRequest\x1a#.kandev.plugin.v1.ListStateResponse\x12]\n" + "\fRevealSecret\x12%.kandev.plugin.v1.RevealSecretRequest\x1a&.kandev.plugin.v1.RevealSecretResponse\x12T\n" + "\tEmitEvent\x12\".kandev.plugin.v1.EmitEventRequest\x1a#.kandev.plugin.v1.EmitEventResponse\x12T\n" + + "\tGetSecret\x12\".kandev.plugin.v1.GetSecretRequest\x1a#.kandev.plugin.v1.GetSecretResponse\x12T\n" + + "\tSetSecret\x12\".kandev.plugin.v1.SetSecretRequest\x1a#.kandev.plugin.v1.SetSecretResponse\x12]\n" + + "\fDeleteSecret\x12%.kandev.plugin.v1.DeleteSecretRequest\x1a&.kandev.plugin.v1.DeleteSecretResponse\x12T\n" + + "\tGetConfig\x12\".kandev.plugin.v1.GetConfigRequest\x1a#.kandev.plugin.v1.GetConfigResponse\x12T\n" + "\tListTasks\x12\".kandev.plugin.v1.ListTasksRequest\x1a#.kandev.plugin.v1.ListTasksResponse\x12N\n" + "\aGetTask\x12 .kandev.plugin.v1.GetTaskRequest\x1a!.kandev.plugin.v1.GetTaskResponse\x12c\n" + "\x0eListWorkspaces\x12'.kandev.plugin.v1.ListWorkspacesRequest\x1a(.kandev.plugin.v1.ListWorkspacesResponse\x12`\n" + @@ -3869,7 +4232,7 @@ func file_kandev_plugin_v1_plugin_proto_rawDescGZIP() []byte { return file_kandev_plugin_v1_plugin_proto_rawDescData } -var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 60) +var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 68) var file_kandev_plugin_v1_plugin_proto_goTypes = []any{ (*Event)(nil), // 0: kandev.plugin.v1.Event (*EventAck)(nil), // 1: kandev.plugin.v1.EventAck @@ -3887,142 +4250,159 @@ var file_kandev_plugin_v1_plugin_proto_goTypes = []any{ (*ListStateRequest)(nil), // 13: kandev.plugin.v1.ListStateRequest (*ListStateResponse)(nil), // 14: kandev.plugin.v1.ListStateResponse (*StateEntry)(nil), // 15: kandev.plugin.v1.StateEntry - (*RevealSecretRequest)(nil), // 16: kandev.plugin.v1.RevealSecretRequest - (*RevealSecretResponse)(nil), // 17: kandev.plugin.v1.RevealSecretResponse - (*EmitEventRequest)(nil), // 18: kandev.plugin.v1.EmitEventRequest - (*EmitEventResponse)(nil), // 19: kandev.plugin.v1.EmitEventResponse - (*Page)(nil), // 20: kandev.plugin.v1.Page - (*PageInfo)(nil), // 21: kandev.plugin.v1.PageInfo - (*Task)(nil), // 22: kandev.plugin.v1.Task - (*TaskRepository)(nil), // 23: kandev.plugin.v1.TaskRepository - (*TaskFilter)(nil), // 24: kandev.plugin.v1.TaskFilter - (*ListTasksRequest)(nil), // 25: kandev.plugin.v1.ListTasksRequest - (*ListTasksResponse)(nil), // 26: kandev.plugin.v1.ListTasksResponse - (*GetTaskRequest)(nil), // 27: kandev.plugin.v1.GetTaskRequest - (*GetTaskResponse)(nil), // 28: kandev.plugin.v1.GetTaskResponse - (*Workspace)(nil), // 29: kandev.plugin.v1.Workspace - (*ListWorkspacesRequest)(nil), // 30: kandev.plugin.v1.ListWorkspacesRequest - (*ListWorkspacesResponse)(nil), // 31: kandev.plugin.v1.ListWorkspacesResponse - (*Workflow)(nil), // 32: kandev.plugin.v1.Workflow - (*ListWorkflowsRequest)(nil), // 33: kandev.plugin.v1.ListWorkflowsRequest - (*ListWorkflowsResponse)(nil), // 34: kandev.plugin.v1.ListWorkflowsResponse - (*WorkflowStep)(nil), // 35: kandev.plugin.v1.WorkflowStep - (*ListWorkflowStepsRequest)(nil), // 36: kandev.plugin.v1.ListWorkflowStepsRequest - (*ListWorkflowStepsResponse)(nil), // 37: kandev.plugin.v1.ListWorkflowStepsResponse - (*AgentProfile)(nil), // 38: kandev.plugin.v1.AgentProfile - (*ListAgentProfilesRequest)(nil), // 39: kandev.plugin.v1.ListAgentProfilesRequest - (*ListAgentProfilesResponse)(nil), // 40: kandev.plugin.v1.ListAgentProfilesResponse - (*Repository)(nil), // 41: kandev.plugin.v1.Repository - (*ListRepositoriesRequest)(nil), // 42: kandev.plugin.v1.ListRepositoriesRequest - (*ListRepositoriesResponse)(nil), // 43: kandev.plugin.v1.ListRepositoriesResponse - (*Session)(nil), // 44: kandev.plugin.v1.Session - (*SessionFilter)(nil), // 45: kandev.plugin.v1.SessionFilter - (*ListSessionsRequest)(nil), // 46: kandev.plugin.v1.ListSessionsRequest - (*ListSessionsResponse)(nil), // 47: kandev.plugin.v1.ListSessionsResponse - (*SessionCodeStats)(nil), // 48: kandev.plugin.v1.SessionCodeStats - (*ListSessionCodeStatsRequest)(nil), // 49: kandev.plugin.v1.ListSessionCodeStatsRequest - (*ListSessionCodeStatsResponse)(nil), // 50: kandev.plugin.v1.ListSessionCodeStatsResponse - (*CreateTaskRequest)(nil), // 51: kandev.plugin.v1.CreateTaskRequest - (*CreateTaskResponse)(nil), // 52: kandev.plugin.v1.CreateTaskResponse - (*UpdateTaskRequest)(nil), // 53: kandev.plugin.v1.UpdateTaskRequest - (*UpdateTaskResponse)(nil), // 54: kandev.plugin.v1.UpdateTaskResponse - (*Comment)(nil), // 55: kandev.plugin.v1.Comment - (*CreateCommentRequest)(nil), // 56: kandev.plugin.v1.CreateCommentRequest - (*CreateCommentResponse)(nil), // 57: kandev.plugin.v1.CreateCommentResponse - nil, // 58: kandev.plugin.v1.WebhookRequest.HeadersEntry - nil, // 59: kandev.plugin.v1.WebhookResponse.HeadersEntry - (*structpb.Struct)(nil), // 60: google.protobuf.Struct + (*GetConfigRequest)(nil), // 16: kandev.plugin.v1.GetConfigRequest + (*GetConfigResponse)(nil), // 17: kandev.plugin.v1.GetConfigResponse + (*GetSecretRequest)(nil), // 18: kandev.plugin.v1.GetSecretRequest + (*GetSecretResponse)(nil), // 19: kandev.plugin.v1.GetSecretResponse + (*SetSecretRequest)(nil), // 20: kandev.plugin.v1.SetSecretRequest + (*SetSecretResponse)(nil), // 21: kandev.plugin.v1.SetSecretResponse + (*DeleteSecretRequest)(nil), // 22: kandev.plugin.v1.DeleteSecretRequest + (*DeleteSecretResponse)(nil), // 23: kandev.plugin.v1.DeleteSecretResponse + (*RevealSecretRequest)(nil), // 24: kandev.plugin.v1.RevealSecretRequest + (*RevealSecretResponse)(nil), // 25: kandev.plugin.v1.RevealSecretResponse + (*EmitEventRequest)(nil), // 26: kandev.plugin.v1.EmitEventRequest + (*EmitEventResponse)(nil), // 27: kandev.plugin.v1.EmitEventResponse + (*Page)(nil), // 28: kandev.plugin.v1.Page + (*PageInfo)(nil), // 29: kandev.plugin.v1.PageInfo + (*Task)(nil), // 30: kandev.plugin.v1.Task + (*TaskRepository)(nil), // 31: kandev.plugin.v1.TaskRepository + (*TaskFilter)(nil), // 32: kandev.plugin.v1.TaskFilter + (*ListTasksRequest)(nil), // 33: kandev.plugin.v1.ListTasksRequest + (*ListTasksResponse)(nil), // 34: kandev.plugin.v1.ListTasksResponse + (*GetTaskRequest)(nil), // 35: kandev.plugin.v1.GetTaskRequest + (*GetTaskResponse)(nil), // 36: kandev.plugin.v1.GetTaskResponse + (*Workspace)(nil), // 37: kandev.plugin.v1.Workspace + (*ListWorkspacesRequest)(nil), // 38: kandev.plugin.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 39: kandev.plugin.v1.ListWorkspacesResponse + (*Workflow)(nil), // 40: kandev.plugin.v1.Workflow + (*ListWorkflowsRequest)(nil), // 41: kandev.plugin.v1.ListWorkflowsRequest + (*ListWorkflowsResponse)(nil), // 42: kandev.plugin.v1.ListWorkflowsResponse + (*WorkflowStep)(nil), // 43: kandev.plugin.v1.WorkflowStep + (*ListWorkflowStepsRequest)(nil), // 44: kandev.plugin.v1.ListWorkflowStepsRequest + (*ListWorkflowStepsResponse)(nil), // 45: kandev.plugin.v1.ListWorkflowStepsResponse + (*AgentProfile)(nil), // 46: kandev.plugin.v1.AgentProfile + (*ListAgentProfilesRequest)(nil), // 47: kandev.plugin.v1.ListAgentProfilesRequest + (*ListAgentProfilesResponse)(nil), // 48: kandev.plugin.v1.ListAgentProfilesResponse + (*Repository)(nil), // 49: kandev.plugin.v1.Repository + (*ListRepositoriesRequest)(nil), // 50: kandev.plugin.v1.ListRepositoriesRequest + (*ListRepositoriesResponse)(nil), // 51: kandev.plugin.v1.ListRepositoriesResponse + (*Session)(nil), // 52: kandev.plugin.v1.Session + (*SessionFilter)(nil), // 53: kandev.plugin.v1.SessionFilter + (*ListSessionsRequest)(nil), // 54: kandev.plugin.v1.ListSessionsRequest + (*ListSessionsResponse)(nil), // 55: kandev.plugin.v1.ListSessionsResponse + (*SessionCodeStats)(nil), // 56: kandev.plugin.v1.SessionCodeStats + (*ListSessionCodeStatsRequest)(nil), // 57: kandev.plugin.v1.ListSessionCodeStatsRequest + (*ListSessionCodeStatsResponse)(nil), // 58: kandev.plugin.v1.ListSessionCodeStatsResponse + (*CreateTaskRequest)(nil), // 59: kandev.plugin.v1.CreateTaskRequest + (*CreateTaskResponse)(nil), // 60: kandev.plugin.v1.CreateTaskResponse + (*UpdateTaskRequest)(nil), // 61: kandev.plugin.v1.UpdateTaskRequest + (*UpdateTaskResponse)(nil), // 62: kandev.plugin.v1.UpdateTaskResponse + (*Comment)(nil), // 63: kandev.plugin.v1.Comment + (*CreateCommentRequest)(nil), // 64: kandev.plugin.v1.CreateCommentRequest + (*CreateCommentResponse)(nil), // 65: kandev.plugin.v1.CreateCommentResponse + nil, // 66: kandev.plugin.v1.WebhookRequest.HeadersEntry + nil, // 67: kandev.plugin.v1.WebhookResponse.HeadersEntry + (*structpb.Struct)(nil), // 68: google.protobuf.Struct } var file_kandev_plugin_v1_plugin_proto_depIdxs = []int32{ - 60, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct - 60, // 1: kandev.plugin.v1.ToolRequest.input:type_name -> google.protobuf.Struct + 68, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct + 68, // 1: kandev.plugin.v1.ToolRequest.input:type_name -> google.protobuf.Struct 3, // 2: kandev.plugin.v1.ToolRequest.context:type_name -> kandev.plugin.v1.ToolContext - 60, // 3: kandev.plugin.v1.ToolResponse.output:type_name -> google.protobuf.Struct - 58, // 4: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry - 59, // 5: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry - 60, // 6: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct - 60, // 7: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct + 68, // 3: kandev.plugin.v1.ToolResponse.output:type_name -> google.protobuf.Struct + 66, // 4: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry + 67, // 5: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry + 68, // 6: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct + 68, // 7: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct 15, // 8: kandev.plugin.v1.ListStateResponse.entries:type_name -> kandev.plugin.v1.StateEntry - 60, // 9: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct - 60, // 10: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct - 23, // 11: kandev.plugin.v1.Task.repositories:type_name -> kandev.plugin.v1.TaskRepository - 60, // 12: kandev.plugin.v1.Task.metadata:type_name -> google.protobuf.Struct - 24, // 13: kandev.plugin.v1.ListTasksRequest.filter:type_name -> kandev.plugin.v1.TaskFilter - 20, // 14: kandev.plugin.v1.ListTasksRequest.page:type_name -> kandev.plugin.v1.Page - 22, // 15: kandev.plugin.v1.ListTasksResponse.tasks:type_name -> kandev.plugin.v1.Task - 21, // 16: kandev.plugin.v1.ListTasksResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 22, // 17: kandev.plugin.v1.GetTaskResponse.task:type_name -> kandev.plugin.v1.Task - 20, // 18: kandev.plugin.v1.ListWorkspacesRequest.page:type_name -> kandev.plugin.v1.Page - 29, // 19: kandev.plugin.v1.ListWorkspacesResponse.workspaces:type_name -> kandev.plugin.v1.Workspace - 21, // 20: kandev.plugin.v1.ListWorkspacesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 20, // 21: kandev.plugin.v1.ListWorkflowsRequest.page:type_name -> kandev.plugin.v1.Page - 32, // 22: kandev.plugin.v1.ListWorkflowsResponse.workflows:type_name -> kandev.plugin.v1.Workflow - 21, // 23: kandev.plugin.v1.ListWorkflowsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 35, // 24: kandev.plugin.v1.ListWorkflowStepsResponse.steps:type_name -> kandev.plugin.v1.WorkflowStep - 20, // 25: kandev.plugin.v1.ListAgentProfilesRequest.page:type_name -> kandev.plugin.v1.Page - 38, // 26: kandev.plugin.v1.ListAgentProfilesResponse.profiles:type_name -> kandev.plugin.v1.AgentProfile - 21, // 27: kandev.plugin.v1.ListAgentProfilesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 20, // 28: kandev.plugin.v1.ListRepositoriesRequest.page:type_name -> kandev.plugin.v1.Page - 41, // 29: kandev.plugin.v1.ListRepositoriesResponse.repositories:type_name -> kandev.plugin.v1.Repository - 21, // 30: kandev.plugin.v1.ListRepositoriesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 45, // 31: kandev.plugin.v1.ListSessionsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter - 20, // 32: kandev.plugin.v1.ListSessionsRequest.page:type_name -> kandev.plugin.v1.Page - 44, // 33: kandev.plugin.v1.ListSessionsResponse.sessions:type_name -> kandev.plugin.v1.Session - 21, // 34: kandev.plugin.v1.ListSessionsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 45, // 35: kandev.plugin.v1.ListSessionCodeStatsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter - 20, // 36: kandev.plugin.v1.ListSessionCodeStatsRequest.page:type_name -> kandev.plugin.v1.Page - 48, // 37: kandev.plugin.v1.ListSessionCodeStatsResponse.stats:type_name -> kandev.plugin.v1.SessionCodeStats - 21, // 38: kandev.plugin.v1.ListSessionCodeStatsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo - 22, // 39: kandev.plugin.v1.CreateTaskResponse.task:type_name -> kandev.plugin.v1.Task - 22, // 40: kandev.plugin.v1.UpdateTaskResponse.task:type_name -> kandev.plugin.v1.Task - 55, // 41: kandev.plugin.v1.CreateCommentResponse.comment:type_name -> kandev.plugin.v1.Comment - 0, // 42: kandev.plugin.v1.Plugin.DeliverEvent:input_type -> kandev.plugin.v1.Event - 2, // 43: kandev.plugin.v1.Plugin.InvokeTool:input_type -> kandev.plugin.v1.ToolRequest - 5, // 44: kandev.plugin.v1.Plugin.HandleWebhook:input_type -> kandev.plugin.v1.WebhookRequest - 7, // 45: kandev.plugin.v1.Host.GetState:input_type -> kandev.plugin.v1.GetStateRequest - 9, // 46: kandev.plugin.v1.Host.SetState:input_type -> kandev.plugin.v1.SetStateRequest - 11, // 47: kandev.plugin.v1.Host.DeleteState:input_type -> kandev.plugin.v1.DeleteStateRequest - 13, // 48: kandev.plugin.v1.Host.ListState:input_type -> kandev.plugin.v1.ListStateRequest - 16, // 49: kandev.plugin.v1.Host.RevealSecret:input_type -> kandev.plugin.v1.RevealSecretRequest - 18, // 50: kandev.plugin.v1.Host.EmitEvent:input_type -> kandev.plugin.v1.EmitEventRequest - 25, // 51: kandev.plugin.v1.Host.ListTasks:input_type -> kandev.plugin.v1.ListTasksRequest - 27, // 52: kandev.plugin.v1.Host.GetTask:input_type -> kandev.plugin.v1.GetTaskRequest - 30, // 53: kandev.plugin.v1.Host.ListWorkspaces:input_type -> kandev.plugin.v1.ListWorkspacesRequest - 33, // 54: kandev.plugin.v1.Host.ListWorkflows:input_type -> kandev.plugin.v1.ListWorkflowsRequest - 36, // 55: kandev.plugin.v1.Host.ListWorkflowSteps:input_type -> kandev.plugin.v1.ListWorkflowStepsRequest - 39, // 56: kandev.plugin.v1.Host.ListAgentProfiles:input_type -> kandev.plugin.v1.ListAgentProfilesRequest - 42, // 57: kandev.plugin.v1.Host.ListRepositories:input_type -> kandev.plugin.v1.ListRepositoriesRequest - 46, // 58: kandev.plugin.v1.Host.ListSessions:input_type -> kandev.plugin.v1.ListSessionsRequest - 49, // 59: kandev.plugin.v1.Host.ListSessionCodeStats:input_type -> kandev.plugin.v1.ListSessionCodeStatsRequest - 51, // 60: kandev.plugin.v1.Host.CreateTask:input_type -> kandev.plugin.v1.CreateTaskRequest - 53, // 61: kandev.plugin.v1.Host.UpdateTask:input_type -> kandev.plugin.v1.UpdateTaskRequest - 56, // 62: kandev.plugin.v1.Host.CreateComment:input_type -> kandev.plugin.v1.CreateCommentRequest - 1, // 63: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck - 4, // 64: kandev.plugin.v1.Plugin.InvokeTool:output_type -> kandev.plugin.v1.ToolResponse - 6, // 65: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse - 8, // 66: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse - 10, // 67: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse - 12, // 68: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse - 14, // 69: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse - 17, // 70: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse - 19, // 71: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse - 26, // 72: kandev.plugin.v1.Host.ListTasks:output_type -> kandev.plugin.v1.ListTasksResponse - 28, // 73: kandev.plugin.v1.Host.GetTask:output_type -> kandev.plugin.v1.GetTaskResponse - 31, // 74: kandev.plugin.v1.Host.ListWorkspaces:output_type -> kandev.plugin.v1.ListWorkspacesResponse - 34, // 75: kandev.plugin.v1.Host.ListWorkflows:output_type -> kandev.plugin.v1.ListWorkflowsResponse - 37, // 76: kandev.plugin.v1.Host.ListWorkflowSteps:output_type -> kandev.plugin.v1.ListWorkflowStepsResponse - 40, // 77: kandev.plugin.v1.Host.ListAgentProfiles:output_type -> kandev.plugin.v1.ListAgentProfilesResponse - 43, // 78: kandev.plugin.v1.Host.ListRepositories:output_type -> kandev.plugin.v1.ListRepositoriesResponse - 47, // 79: kandev.plugin.v1.Host.ListSessions:output_type -> kandev.plugin.v1.ListSessionsResponse - 50, // 80: kandev.plugin.v1.Host.ListSessionCodeStats:output_type -> kandev.plugin.v1.ListSessionCodeStatsResponse - 52, // 81: kandev.plugin.v1.Host.CreateTask:output_type -> kandev.plugin.v1.CreateTaskResponse - 54, // 82: kandev.plugin.v1.Host.UpdateTask:output_type -> kandev.plugin.v1.UpdateTaskResponse - 57, // 83: kandev.plugin.v1.Host.CreateComment:output_type -> kandev.plugin.v1.CreateCommentResponse - 63, // [63:84] is the sub-list for method output_type - 42, // [42:63] is the sub-list for method input_type - 42, // [42:42] is the sub-list for extension type_name - 42, // [42:42] is the sub-list for extension extendee - 0, // [0:42] is the sub-list for field type_name + 68, // 9: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct + 68, // 10: kandev.plugin.v1.GetConfigResponse.config:type_name -> google.protobuf.Struct + 68, // 11: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct + 31, // 12: kandev.plugin.v1.Task.repositories:type_name -> kandev.plugin.v1.TaskRepository + 68, // 13: kandev.plugin.v1.Task.metadata:type_name -> google.protobuf.Struct + 32, // 14: kandev.plugin.v1.ListTasksRequest.filter:type_name -> kandev.plugin.v1.TaskFilter + 28, // 15: kandev.plugin.v1.ListTasksRequest.page:type_name -> kandev.plugin.v1.Page + 30, // 16: kandev.plugin.v1.ListTasksResponse.tasks:type_name -> kandev.plugin.v1.Task + 29, // 17: kandev.plugin.v1.ListTasksResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 30, // 18: kandev.plugin.v1.GetTaskResponse.task:type_name -> kandev.plugin.v1.Task + 28, // 19: kandev.plugin.v1.ListWorkspacesRequest.page:type_name -> kandev.plugin.v1.Page + 37, // 20: kandev.plugin.v1.ListWorkspacesResponse.workspaces:type_name -> kandev.plugin.v1.Workspace + 29, // 21: kandev.plugin.v1.ListWorkspacesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 28, // 22: kandev.plugin.v1.ListWorkflowsRequest.page:type_name -> kandev.plugin.v1.Page + 40, // 23: kandev.plugin.v1.ListWorkflowsResponse.workflows:type_name -> kandev.plugin.v1.Workflow + 29, // 24: kandev.plugin.v1.ListWorkflowsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 43, // 25: kandev.plugin.v1.ListWorkflowStepsResponse.steps:type_name -> kandev.plugin.v1.WorkflowStep + 28, // 26: kandev.plugin.v1.ListAgentProfilesRequest.page:type_name -> kandev.plugin.v1.Page + 46, // 27: kandev.plugin.v1.ListAgentProfilesResponse.profiles:type_name -> kandev.plugin.v1.AgentProfile + 29, // 28: kandev.plugin.v1.ListAgentProfilesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 28, // 29: kandev.plugin.v1.ListRepositoriesRequest.page:type_name -> kandev.plugin.v1.Page + 49, // 30: kandev.plugin.v1.ListRepositoriesResponse.repositories:type_name -> kandev.plugin.v1.Repository + 29, // 31: kandev.plugin.v1.ListRepositoriesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 53, // 32: kandev.plugin.v1.ListSessionsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter + 28, // 33: kandev.plugin.v1.ListSessionsRequest.page:type_name -> kandev.plugin.v1.Page + 52, // 34: kandev.plugin.v1.ListSessionsResponse.sessions:type_name -> kandev.plugin.v1.Session + 29, // 35: kandev.plugin.v1.ListSessionsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 53, // 36: kandev.plugin.v1.ListSessionCodeStatsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter + 28, // 37: kandev.plugin.v1.ListSessionCodeStatsRequest.page:type_name -> kandev.plugin.v1.Page + 56, // 38: kandev.plugin.v1.ListSessionCodeStatsResponse.stats:type_name -> kandev.plugin.v1.SessionCodeStats + 29, // 39: kandev.plugin.v1.ListSessionCodeStatsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 30, // 40: kandev.plugin.v1.CreateTaskResponse.task:type_name -> kandev.plugin.v1.Task + 30, // 41: kandev.plugin.v1.UpdateTaskResponse.task:type_name -> kandev.plugin.v1.Task + 63, // 42: kandev.plugin.v1.CreateCommentResponse.comment:type_name -> kandev.plugin.v1.Comment + 0, // 43: kandev.plugin.v1.Plugin.DeliverEvent:input_type -> kandev.plugin.v1.Event + 2, // 44: kandev.plugin.v1.Plugin.InvokeTool:input_type -> kandev.plugin.v1.ToolRequest + 5, // 45: kandev.plugin.v1.Plugin.HandleWebhook:input_type -> kandev.plugin.v1.WebhookRequest + 7, // 46: kandev.plugin.v1.Host.GetState:input_type -> kandev.plugin.v1.GetStateRequest + 9, // 47: kandev.plugin.v1.Host.SetState:input_type -> kandev.plugin.v1.SetStateRequest + 11, // 48: kandev.plugin.v1.Host.DeleteState:input_type -> kandev.plugin.v1.DeleteStateRequest + 13, // 49: kandev.plugin.v1.Host.ListState:input_type -> kandev.plugin.v1.ListStateRequest + 24, // 50: kandev.plugin.v1.Host.RevealSecret:input_type -> kandev.plugin.v1.RevealSecretRequest + 26, // 51: kandev.plugin.v1.Host.EmitEvent:input_type -> kandev.plugin.v1.EmitEventRequest + 18, // 52: kandev.plugin.v1.Host.GetSecret:input_type -> kandev.plugin.v1.GetSecretRequest + 20, // 53: kandev.plugin.v1.Host.SetSecret:input_type -> kandev.plugin.v1.SetSecretRequest + 22, // 54: kandev.plugin.v1.Host.DeleteSecret:input_type -> kandev.plugin.v1.DeleteSecretRequest + 16, // 55: kandev.plugin.v1.Host.GetConfig:input_type -> kandev.plugin.v1.GetConfigRequest + 33, // 56: kandev.plugin.v1.Host.ListTasks:input_type -> kandev.plugin.v1.ListTasksRequest + 35, // 57: kandev.plugin.v1.Host.GetTask:input_type -> kandev.plugin.v1.GetTaskRequest + 38, // 58: kandev.plugin.v1.Host.ListWorkspaces:input_type -> kandev.plugin.v1.ListWorkspacesRequest + 41, // 59: kandev.plugin.v1.Host.ListWorkflows:input_type -> kandev.plugin.v1.ListWorkflowsRequest + 44, // 60: kandev.plugin.v1.Host.ListWorkflowSteps:input_type -> kandev.plugin.v1.ListWorkflowStepsRequest + 47, // 61: kandev.plugin.v1.Host.ListAgentProfiles:input_type -> kandev.plugin.v1.ListAgentProfilesRequest + 50, // 62: kandev.plugin.v1.Host.ListRepositories:input_type -> kandev.plugin.v1.ListRepositoriesRequest + 54, // 63: kandev.plugin.v1.Host.ListSessions:input_type -> kandev.plugin.v1.ListSessionsRequest + 57, // 64: kandev.plugin.v1.Host.ListSessionCodeStats:input_type -> kandev.plugin.v1.ListSessionCodeStatsRequest + 59, // 65: kandev.plugin.v1.Host.CreateTask:input_type -> kandev.plugin.v1.CreateTaskRequest + 61, // 66: kandev.plugin.v1.Host.UpdateTask:input_type -> kandev.plugin.v1.UpdateTaskRequest + 64, // 67: kandev.plugin.v1.Host.CreateComment:input_type -> kandev.plugin.v1.CreateCommentRequest + 1, // 68: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck + 4, // 69: kandev.plugin.v1.Plugin.InvokeTool:output_type -> kandev.plugin.v1.ToolResponse + 6, // 70: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse + 8, // 71: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse + 10, // 72: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse + 12, // 73: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse + 14, // 74: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse + 25, // 75: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse + 27, // 76: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse + 19, // 77: kandev.plugin.v1.Host.GetSecret:output_type -> kandev.plugin.v1.GetSecretResponse + 21, // 78: kandev.plugin.v1.Host.SetSecret:output_type -> kandev.plugin.v1.SetSecretResponse + 23, // 79: kandev.plugin.v1.Host.DeleteSecret:output_type -> kandev.plugin.v1.DeleteSecretResponse + 17, // 80: kandev.plugin.v1.Host.GetConfig:output_type -> kandev.plugin.v1.GetConfigResponse + 34, // 81: kandev.plugin.v1.Host.ListTasks:output_type -> kandev.plugin.v1.ListTasksResponse + 36, // 82: kandev.plugin.v1.Host.GetTask:output_type -> kandev.plugin.v1.GetTaskResponse + 39, // 83: kandev.plugin.v1.Host.ListWorkspaces:output_type -> kandev.plugin.v1.ListWorkspacesResponse + 42, // 84: kandev.plugin.v1.Host.ListWorkflows:output_type -> kandev.plugin.v1.ListWorkflowsResponse + 45, // 85: kandev.plugin.v1.Host.ListWorkflowSteps:output_type -> kandev.plugin.v1.ListWorkflowStepsResponse + 48, // 86: kandev.plugin.v1.Host.ListAgentProfiles:output_type -> kandev.plugin.v1.ListAgentProfilesResponse + 51, // 87: kandev.plugin.v1.Host.ListRepositories:output_type -> kandev.plugin.v1.ListRepositoriesResponse + 55, // 88: kandev.plugin.v1.Host.ListSessions:output_type -> kandev.plugin.v1.ListSessionsResponse + 58, // 89: kandev.plugin.v1.Host.ListSessionCodeStats:output_type -> kandev.plugin.v1.ListSessionCodeStatsResponse + 60, // 90: kandev.plugin.v1.Host.CreateTask:output_type -> kandev.plugin.v1.CreateTaskResponse + 62, // 91: kandev.plugin.v1.Host.UpdateTask:output_type -> kandev.plugin.v1.UpdateTaskResponse + 65, // 92: kandev.plugin.v1.Host.CreateComment:output_type -> kandev.plugin.v1.CreateCommentResponse + 68, // [68:93] is the sub-list for method output_type + 43, // [43:68] is the sub-list for method input_type + 43, // [43:43] is the sub-list for extension type_name + 43, // [43:43] is the sub-list for extension extendee + 0, // [0:43] is the sub-list for field type_name } func init() { file_kandev_plugin_v1_plugin_proto_init() } @@ -4030,21 +4410,21 @@ func file_kandev_plugin_v1_plugin_proto_init() { if File_kandev_plugin_v1_plugin_proto != nil { return } - file_kandev_plugin_v1_plugin_proto_msgTypes[22].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[24].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[29].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[30].OneofWrappers = []any{} file_kandev_plugin_v1_plugin_proto_msgTypes[32].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[41].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[44].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[51].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[53].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[37].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[40].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[49].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[52].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[59].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[61].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kandev_plugin_v1_plugin_proto_rawDesc), len(file_kandev_plugin_v1_plugin_proto_rawDesc)), NumEnums: 0, - NumMessages: 60, + NumMessages: 68, NumExtensions: 0, NumServices: 2, }, diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.proto b/apps/backend/proto/kandev/plugin/v1/plugin.proto index 1361b16518..dd9c205bd7 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.proto +++ b/apps/backend/proto/kandev/plugin/v1/plugin.proto @@ -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::secret:"), 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 > 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: 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; } diff --git a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go index 020d710b49..1cc2721164 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go @@ -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::secret:"), 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 > 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: 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::secret:"), 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 > 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: 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, diff --git a/apps/web/app/settings/plugins/[pluginId]/page.tsx b/apps/web/app/settings/plugins/[pluginId]/page.tsx new file mode 100644 index 0000000000..4484c95b65 --- /dev/null +++ b/apps/web/app/settings/plugins/[pluginId]/page.tsx @@ -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 ; +} diff --git a/apps/web/components/settings/plugins/plugin-config-form.tsx b/apps/web/components/settings/plugins/plugin-config-form.tsx new file mode 100644 index 0000000000..4992f393dc --- /dev/null +++ b/apps/web/components/settings/plugins/plugin-config-form.tsx @@ -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; + 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 ( +
+ {fields.map((field) => ( + + ))} +
+ ); +} + +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 ( +
+ + + {field.description &&

{field.description}

} +
+ ); +} + +type ConfigFieldControlProps = ConfigFieldRowProps & { inputId: string }; + +function ConfigFieldControl({ + field, + inputId, + value, + disabled, + onChange, +}: ConfigFieldControlProps) { + if (field.type === "boolean") { + return ( +
+ onChange(field.name, checked)} + /> +
+ ); + } + + 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 ( + + ); + } + + return ( + 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"; +} diff --git a/apps/web/components/settings/plugins/plugin-detail.tsx b/apps/web/components/settings/plugins/plugin-detail.tsx new file mode 100644 index 0000000000..b8431a96e1 --- /dev/null +++ b/apps/web/components/settings/plugins/plugin-detail.tsx @@ -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 > ): 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 ? : null; + } + + return ( +
+ 0} + isDirty={form.isDirty} + saveStatus={form.saveStatus} + onSave={form.handleSave} + /> + + + + + + + { + await actions.confirmUninstall(); + router.push(PLUGINS_SETTINGS_HREF); + }} + /> +
+ ); +} + +type PluginDetailHeaderProps = { + plugin: PluginRecord; + showSave: boolean; + isDirty: boolean; + saveStatus: "idle" | "loading" | "success" | "error"; + onSave: () => void; +}; + +function PluginDetailHeader({ + plugin, + showSave, + isDirty, + saveStatus, + onSave, +}: PluginDetailHeaderProps) { + return ( +
+ + + Plugins + +
+
+
+

{plugin.display_name}

+ + {plugin.signed === false && ( + + unsigned + + )} +
+
+ {plugin.id} · v{plugin.version} +
+ {plugin.description && ( +

{plugin.description}

+ )} +
+ {showSave && ( +
+ {isDirty && } + +
+ )} +
+
+ ); +} + +type PluginSettingsCardProps = { + plugin: PluginRecord; + form: ReturnType; + busy: boolean; +}; + +function PluginSettingsCard({ plugin, form, busy }: PluginSettingsCardProps) { + return ( + + + Settings + + + + + + ); +} + +function PluginSettingsBody({ plugin, form, busy }: PluginSettingsCardProps) { + if (form.fields.length === 0) { + return ( +

+ This plugin does not declare any settings (no config_schema in its manifest). +

+ ); + } + if (form.configError) { + return

{form.configError}

; + } + if (form.configLoading) { + return

Loading settings...

; + } + return ( +
+ + {plugin.status === "active" && ( +

+ Saving restarts the plugin so the new settings take effect. +

+ )} +
+ ); +} + +type PluginDangerZoneProps = { + plugin: PluginRecord; + actions: ReturnType; +}; + +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 ( +
+ {canEnable && ( + + )} + {canDisable && ( + + )} + +
+ ); +} + +function PluginNotFound({ pluginId }: { pluginId: string }) { + return ( +
+ + + Plugins + +
+ No installed plugin with id {pluginId}. +
+
+ ); +} diff --git a/apps/web/components/settings/plugins/plugin-manifest-card.tsx b/apps/web/components/settings/plugins/plugin-manifest-card.tsx new file mode 100644 index 0000000000..0738da33af --- /dev/null +++ b/apps/web/components/settings/plugins/plugin-manifest-card.tsx @@ -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 ( + + + Manifest + + + +
+ + + + + + +
+ + + ({ key: t.name, text: t.display_name || t.name }))} + /> + ({ key: w.key, text: w.key }))} + /> + + {showRaw && ( +
+            {JSON.stringify(manifestOnly(plugin), null, 2)}
+          
+ )} +
+
+ ); +} + +function ManifestRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +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 ( +
+
Capabilities
+
+ {badges.map((badge) => ( + + {badge} + + ))} +
+
+ ); +} + +function DeclarationList({ + label, + items, +}: { + label: string; + items: { key: string; text: string }[]; +}) { + if (items.length === 0) return null; + return ( +
+
{label}
+
+ {items.map((item) => ( + + {item.text} + + ))} +
+
+ ); +} + +/** The manifest-declared fields only — runtime bookkeeping (status, install + * path, restart counter) is shown elsewhere and just noise here. */ +function manifestOnly(plugin: PluginRecord): Record { + 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(); +} diff --git a/apps/web/components/settings/plugins/plugin-row.tsx b/apps/web/components/settings/plugins/plugin-row.tsx index 5a05062a90..c9948928bf 100644 --- a/apps/web/components/settings/plugins/plugin-row.tsx +++ b/apps/web/components/settings/plugins/plugin-row.tsx @@ -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
- + {plugin.display_name} - + {plugin.signed === false && ( ({ + 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); + }); +}); diff --git a/apps/web/components/settings/plugins/use-plugin-config-form.ts b/apps/web/components/settings/plugins/use-plugin-config-form.ts new file mode 100644 index 0000000000..aad401e27a --- /dev/null +++ b/apps/web/components/settings/plugins/use-plugin-config-form.ts @@ -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; + +/** + * 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({}); + const [initialValues, setInitialValues] = useState({}); + const [configLoading, setConfigLoading] = useState(false); + const [configError, setConfigError] = useState(null); + const [saveStatus, setSaveStatus] = useState("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, + }; +} diff --git a/apps/web/e2e/tests/plugins/plugins.spec.ts b/apps/web/e2e/tests/plugins/plugins.spec.ts index 3bb694cb53..d88e172200 100644 --- a/apps/web/e2e/tests/plugins/plugins.spec.ts +++ b/apps/web/e2e/tests/plugins/plugins.spec.ts @@ -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 }; + 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) + .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).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)); diff --git a/apps/web/lib/api/domains/plugins-api.test.ts b/apps/web/lib/api/domains/plugins-api.test.ts index acb97f3781..15fb0e4501 100644 --- a/apps/web/lib/api/domains/plugins-api.test.ts +++ b/apps/web/lib/api/domains/plugins-api.test.ts @@ -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"); + }); +}); diff --git a/apps/web/lib/api/domains/plugins-api.ts b/apps/web/lib/api/domains/plugins-api.ts index c63ac843ff..d66e9498d9 100644 --- a/apps/web/lib/api/domains/plugins-api.ts +++ b/apps/web/lib/api/domains/plugins-api.ts @@ -87,8 +87,21 @@ async function throwInstallError(response: Response): Promise { 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 }>( + `${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, diff --git a/apps/web/lib/plugins/config-schema.test.ts b/apps/web/lib/plugins/config-schema.test.ts new file mode 100644 index 0000000000..9d92afe706 --- /dev/null +++ b/apps/web/lib/plugins/config-schema.test.ts @@ -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; + +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([]); + }); +}); diff --git a/apps/web/lib/plugins/config-schema.ts b/apps/web/lib/plugins/config-schema.ts new file mode 100644 index 0000000000..ff188bb2e4 --- /dev/null +++ b/apps/web/lib/plugins/config-schema.ts @@ -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; + +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 { + 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 | 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, +): Record { + const values: Record = {}; + 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, +): Record { + const config: Record = {}; + 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[] { + 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); +} diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index 140f0462d8..7331e2424e 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -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) ?? ( + + ) + ); + } + const agentProfile = matchDouble(pathname, /^\/settings\/agents\/([^/]+)\/profiles\/([^/]+)$/); if (agentProfile) { return ; diff --git a/docs/plans/plugins/GRPC-CONTRACT.md b/docs/plans/plugins/GRPC-CONTRACT.md index 653a462f96..d71cb5560c 100644 --- a/docs/plans/plugins/GRPC-CONTRACT.md +++ b/docs/plans/plugins/GRPC-CONTRACT.md @@ -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 > , + // 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::secret:", 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::" 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:. 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 diff --git a/docs/specs/plugins/spec.md b/docs/specs/plugins/spec.md index 38fde8421a..7e977cfedd 100644 --- a/docs/specs/plugins/spec.md +++ b/docs/specs/plugins/spec.md @@ -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