fae2303db3
* feat(plugins): per-plugin settings pages driven by manifest config_schema Settings > Plugins rows now open /settings/plugins/:id — a detail page showing the manifest plus a settings form generated from the plugin author's config_schema. Secret fields (secret: true / format: password) are masked on the operator API and kept on masked re-submits; a running plugin restarts on save and reads its config (cleartext secrets included) through the new Host GetConfig gRPC RPC. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): write plugin config file owner-only (0600) Operator config can now carry cleartext secret values (config_schema fields marked secret), so <id>.config.yml must not be world-readable — match the 0600 mode the record file already uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plugins): vault-backed plugin secrets — Get/Set/DeleteSecret RPCs and encrypted config secrets Plugins get vault-backed secret primitives on the Host API (GetSecret/SetSecret/DeleteSecret, capability-gated, namespaced to plugin:<id>㊙️<key> so a plugin can only touch its own entries; purged on uninstall). Secret config_schema fields now live in kandev's AES-256-GCM vault too — <id>.config.yml persists only a vault reference, and the Host GetConfig RPC resolves it back to cleartext for the plugin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(plugins): serve PR screenshots from the media/pr-1761-screenshots ref instead of the repo The PR body now points at raw URLs pinned to an unmerged media ref, so the binaries never land in main (PRs are squash-merged; only the branch tip tree merges). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): address review feedback on settings validation and save flow - integer form fields reject non-integral input instead of parseInt-truncating - enum selections keep their schema-declared types (numeric enums round-trip) - backend enum validation normalizes int-vs-float64 before comparison - maskSecrets masks any non-zero secret value regardless of runtime type - save flow splits PATCH from refetch: a refetch failure no longer reports a successful save as an error, and typed secrets are masked on screen - optional enum fields gain a 'Not set' option so they can be cleared - resolveConfigSecrets copies before mutating; restart-failure SetStatus errors are logged; SDK test fake returns a non-nil empty config map - add missing tests: usePluginConfigForm hook, getPluginConfig client, integer/enum regression cases Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): harden secret config invariants from review - reject non-string values on declared secret fields at validation time, closing the path where a numeric/boolean secret bypassed the vault and persisted cleartext in the config file - delete removed secret fields' vault entries only AFTER the config commit succeeds, so a failed SetConfig never leaves the still-current config referencing a deleted vault entry - uninstall fails closed: the plugin's vault namespace is purged before any destructive step, and a cleanup failure aborts the uninstall (retryable) instead of letting a reinstall inherit stale secrets Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): failure-atomic config secrets, quiesced uninstall purge, ctx propagation - UpdateConfig is now failure-atomic for secret overwrites: storeConfigSecrets snapshots each vault entry before writing and returns a rollback that restores prior values (or deletes newly-created ones). A vault.Set failure mid-loop or a failed SetConfig commit rolls back, so a request reported as failed never changes the value a still-current config ref resolves to. - Uninstall stops the plugin process BEFORE purging its vault namespace, so the plugin can no longer race the cleanup with a SetSecret between ListIDs and the deletes; the purge stays fail-closed and retryable. - Thread the request context through Uninstall -> deletePluginSecrets instead of context.Background(), per the pass-context-through-chains convention. - Consolidate the 'secret not found:' prefix check into isSecretNotFound (host.go + service rollback share one definition). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): detach rollback context, fail closed without a vault, reconcile uninstall status - vault rollback closures now run on context.WithoutCancel(ctx): a request cancelled mid-save (browser closed) can no longer abort the rollback and leave the vault holding new values while the unchanged config still points at them (Greptile P1). - storeConfigSecrets fails closed (errSecretVaultRequired) when a plugin declares secret config fields but no vault is wired, instead of silently persisting the secret in cleartext. - vaultRestoreFunc distinguishes not-found from a genuine Reveal error: a backend read failure aborts the update before writing rather than risk a rollback that deletes a real secret; rollback now returns errors, joined into UpdateConfig's failure so an inconsistent vault is surfaced, not just logged. - Uninstall reconciles lifecycle status when a secret-purge failure aborts after the process was stopped: a previously-running plugin transitions to error and notifies observers instead of being reported active with no process running. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): detach removed-secret cleanup ctx and clear stale save dirty flag - cleanupRemovedConfigSecrets now runs on context.WithoutCancel(ctx), matching the rollback path, so a client disconnect right after the config commit can no longer cancel the delete and orphan now-unreferenced vault entries. - tests: assert GetConfig succeeds before treating an empty map as proof that nothing persisted (a swallowed read error would be a false pass). - web: on a post-save refetch failure, rebase initialValues onto the masked snapshot so the saved form no longer reads as dirty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): fail tests on discarded GetConfig errors, drop setState-in-updater - config_test: capture and fatal on GetConfig errors at the four remaining positive-assertion sites (a nil from a failed read would panic on the next map access instead of giving a clear failure), matching the earlier fix. - web hook: compute the masked post-save snapshot from the current values and apply it to setValues + setInitialValues directly, instead of calling setInitialValues inside a setValues functional updater (a React anti-pattern that runs twice under StrictMode). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): skip validation of a secret field's own vault ref - validateConfigSchema takes the plugin id and skips a secret field whose value is that field's config vault ref (an internal storage marker, already validated when first set). Without this, a field declaring both secret: true and enum: [...] 400s on every save that keeps the mask, because the enum check saw the vault ref instead of a user value. Freshly entered secret values are still validated; a foreign plugin's ref is not skipped. Covered by unit + service-level regression tests. - surface mid-loop vault rollback failures in storeConfigSecrets via a warning instead of discarding them (double-fault observability). - web: integer inputs get step=1 so browsers flag non-integral input in place (serializeConfigValues remains the backstop). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(plugins): split config secret tests into config_secrets_test.go config_test.go had grown past the 800-line backend file-length limit (revive), failing CI lint. Move the vault-backed config and plugin-scoped secret tests (plus their fake-vault/store helpers) into a sibling file; no test logic changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
210 lines
6.7 KiB
Go
210 lines
6.7 KiB
Go
// Package main implements fixturePlugin, the pluginsdk.Plugin backing the
|
|
// plugin-fixture binary (see the package doc comment in main.go).
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"github.com/kandev/kandev/pkg/pluginsdk"
|
|
)
|
|
|
|
const (
|
|
deliveriesFileName = "deliveries.jsonl"
|
|
webhooksFileName = "webhooks.jsonl"
|
|
configSnapshotFileName = "config.json"
|
|
secretProbeFileName = "secret-probe.json"
|
|
|
|
toolNameEcho = "echo"
|
|
)
|
|
|
|
// deliveryRecord is one recorded OnEvent delivery, appended as a JSON line
|
|
// to deliveries.jsonl. e2e tests poll this file as evidence that an event
|
|
// reached the plugin over the real gRPC transport.
|
|
type deliveryRecord struct {
|
|
EventType string `json:"event_type"`
|
|
EventID string `json:"event_id"`
|
|
}
|
|
|
|
// webhookRecord is one recorded HandleWebhook delivery, appended as a JSON
|
|
// line to webhooks.jsonl.
|
|
type webhookRecord struct {
|
|
WebhookKey string `json:"webhook_key"`
|
|
Method string `json:"method"`
|
|
}
|
|
|
|
// fixturePlugin implements pluginsdk.Plugin (via UnimplementedPlugin) for
|
|
// Go integration tests and Playwright e2e: it records every delivery to
|
|
// disk under dataDir so tests can poll for evidence without needing their
|
|
// own gRPC client.
|
|
type fixturePlugin struct {
|
|
pluginsdk.UnimplementedPlugin
|
|
|
|
dataDir string
|
|
|
|
mu sync.Mutex
|
|
sawFirstEvent bool
|
|
}
|
|
|
|
var _ pluginsdk.Plugin = (*fixturePlugin)(nil)
|
|
|
|
// newFixturePlugin builds a fixturePlugin whose data directory is resolved
|
|
// from KANDEV_PLUGIN_DATA_DIR (falling back to the current working
|
|
// directory), per §2 of docs/plans/plugins/GRPC-CONTRACT.md.
|
|
func newFixturePlugin() *fixturePlugin {
|
|
return &fixturePlugin{dataDir: resolveDataDir()}
|
|
}
|
|
|
|
// resolveDataDir returns KANDEV_PLUGIN_DATA_DIR if set, otherwise the
|
|
// current working directory.
|
|
func resolveDataDir() string {
|
|
if dir := os.Getenv("KANDEV_PLUGIN_DATA_DIR"); dir != "" {
|
|
return dir
|
|
}
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return "."
|
|
}
|
|
return wd
|
|
}
|
|
|
|
// OnEvent appends a deliveries.jsonl line recording the event, then — only
|
|
// for the first event this process instance has seen — best-effort
|
|
// exercises the Host.SetState round trip (errors are ignored; this is
|
|
// coverage, not a critical path).
|
|
func (p *fixturePlugin) OnEvent(ctx context.Context, e *pluginsdk.Event) error {
|
|
rec := deliveryRecord{EventType: e.EventType, EventID: e.EventID}
|
|
if err := appendJSONLine(filepath.Join(p.dataDir, deliveriesFileName), rec); err != nil {
|
|
return err
|
|
}
|
|
|
|
if p.markFirstEvent() {
|
|
p.recordLastEventBestEffort(ctx, e)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// markFirstEvent returns true exactly once (on the first call), false on
|
|
// every subsequent call.
|
|
func (p *fixturePlugin) markFirstEvent() bool {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.sawFirstEvent {
|
|
return false
|
|
}
|
|
p.sawFirstEvent = true
|
|
return true
|
|
}
|
|
|
|
// recordLastEventBestEffort calls Host.SetState("instance", "",
|
|
// "last_event", ...) if a Host has been injected. Errors (including "no
|
|
// Host yet") are silently ignored — this exists purely to exercise the
|
|
// Host round trip for e2e coverage, not to guarantee delivery.
|
|
func (p *fixturePlugin) recordLastEventBestEffort(ctx context.Context, e *pluginsdk.Event) {
|
|
host := p.Host()
|
|
if host == nil {
|
|
return
|
|
}
|
|
_ = host.SetState(ctx, "instance", "", "last_event", map[string]any{
|
|
"event_type": e.EventType,
|
|
"event_id": e.EventID,
|
|
})
|
|
}
|
|
|
|
// InvokeTool implements the fixture's only declared tool ("echo"): it
|
|
// returns the request's input unchanged as the response output.
|
|
func (p *fixturePlugin) InvokeTool(_ context.Context, req *pluginsdk.ToolRequest) (*pluginsdk.ToolResponse, error) {
|
|
if req.ToolName != toolNameEcho {
|
|
return &pluginsdk.ToolResponse{Error: fmt.Sprintf("unknown tool %q", req.ToolName)}, nil
|
|
}
|
|
return &pluginsdk.ToolResponse{Output: req.Input}, nil
|
|
}
|
|
|
|
// 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 {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return fmt.Errorf("plugin-fixture: creating data dir for %s: %w", path, err)
|
|
}
|
|
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Errorf("plugin-fixture: marshaling record: %w", err)
|
|
}
|
|
data = append(bytes.TrimRight(data, "\n"), '\n')
|
|
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return fmt.Errorf("plugin-fixture: opening %s: %w", path, err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
if _, err := f.Write(data); err != nil {
|
|
return fmt.Errorf("plugin-fixture: writing %s: %w", path, err)
|
|
}
|
|
return f.Close()
|
|
}
|