diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 1f8926e544..8bcc8baadd 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -77,6 +77,12 @@ jobs: - name: Build backend and web run: make build-backend build-web + # Fixture plugin package consumed by the plugin E2E specs. Built here + # (the build job has the Go toolchain) and shipped to the shards as its + # own artifact; global-setup fails fast if it is missing. + - name: Build E2E fixture plugin package + run: make -C apps/backend e2e-plugin-package + # Linux/amd64 helper binaries (agentctl + mock-agent) bind-mounted into # containers by the Docker E2E project. Cheap to build (~1s each) so we # always do it; the regular E2E job ignores them. @@ -90,6 +96,13 @@ jobs: path: apps/backend/bin/ retention-days: 1 + - name: Upload E2E fixture plugin package + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: e2e-plugin-package + path: apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz + retention-days: 1 + # Tar the Vite dist directory so shards can reuse the same static web build. - name: Package web build run: tar -cf apps/web/web-dist.tar -C apps/web dist @@ -165,6 +178,12 @@ jobs: - name: Make backend binaries executable run: chmod +x apps/backend/bin/* + - name: Download E2E fixture plugin package + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: e2e-plugin-package + path: apps/backend/.build/ + - name: Download web build uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 with: @@ -328,6 +347,12 @@ jobs: - name: Make backend binaries executable run: chmod +x apps/backend/bin/* + - name: Download E2E fixture plugin package + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: e2e-plugin-package + path: apps/backend/.build/ + - name: Download web build uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 with: diff --git a/.gitignore b/.gitignore index 099728498a..31852c85ef 100644 --- a/.gitignore +++ b/.gitignore @@ -85,11 +85,20 @@ tmp/ apps/backend/bin/ +# `make -C apps/backend e2e-plugin-package` output (packaged fixture plugin +# tar.gz + build staging dir) — e2e tests build this on demand, not committed. +apps/backend/.build/ + +# Local protoc-gen-* tool binaries installed by `make -C apps/backend proto` +apps/backend/.tools/ + # Go binaries (built locally) apps/backend/kandev apps/backend/agentctl apps/backend/mock-agent apps/backend/acpdbg +apps/backend/plugin-fixture +apps/backend/plugin-pack # acpdbg JSONL output (raw ACP wire frames from debug runs) apps/backend/acp-debug/ diff --git a/Makefile b/Makefile index 72eb662ce7..a8e6d0341e 100644 --- a/Makefile +++ b/Makefile @@ -331,6 +331,15 @@ build-backend-quiet: @printf " $(DIM)Backend$(RESET)\n" @$(MAKE) -s -C $(BACKEND_DIR) build >/dev/null 2>&1 +## Package the plugin-fixture SDK plugin used by tests/plugins/plugins.spec.ts. +## e2e/global-setup.ts checks the resulting tar.gz exists (like it does for +## the kandev/mock-agent binaries) but does not build it itself — this target +## is the "make it exist" step, wired into test-e2e* below. +.PHONY: build-e2e-plugin-package +build-e2e-plugin-package: + @printf "$(CYAN)Packaging e2e fixture plugin...$(RESET)\n" + @$(MAKE) -C $(BACKEND_DIR) e2e-plugin-package + .PHONY: build-web build-web: @printf "$(CYAN)Building web app...$(RESET)\n" @@ -430,17 +439,17 @@ test-scripts: @node --test scripts/validate-public-docs.test.mjs .PHONY: test-e2e -test-e2e: build-backend build-web +test-e2e: build-backend build-web build-e2e-plugin-package @printf "$(CYAN)Running E2E tests (headless, parallel)...$(RESET)\n" @cd $(APPS_DIR) && $(PNPM) --filter @kandev/web e2e .PHONY: test-e2e-headed -test-e2e-headed: build-backend build-web +test-e2e-headed: build-backend build-web build-e2e-plugin-package @printf "$(CYAN)Running E2E tests (headed)...$(RESET)\n" @cd $(APPS_DIR) && $(PNPM) --filter @kandev/web e2e:headed .PHONY: test-e2e-ui -test-e2e-ui: build-backend build-web +test-e2e-ui: build-backend build-web build-e2e-plugin-package @printf "$(CYAN)Opening Playwright UI mode...$(RESET)\n" @cd $(APPS_DIR) && $(PNPM) --filter @kandev/web e2e:ui diff --git a/apps/backend/.golangci.yml b/apps/backend/.golangci.yml index a9733e165c..f0687283c7 100644 --- a/apps/backend/.golangci.yml +++ b/apps/backend/.golangci.yml @@ -57,6 +57,13 @@ linters: - dupl - goconst - maintidx + paths: + # protoc-gen-go / protoc-gen-go-grpc output — regenerated by `make + # proto`, never hand-edited, and not worth policing with hand-written + # code limits. golangci-lint's default generated-file detection + # already skips most linters here, but this is an explicit belt-and- + # braces exclusion since this dir is committed, not gitignored. + - proto/.*\.pb\.go$ issues: new: true diff --git a/apps/backend/Makefile b/apps/backend/Makefile index b96966eaf3..eea6b6b9f2 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -75,7 +75,7 @@ BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || powershell -c LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) -.PHONY: all build build-all build-agentctl build-agentctl-remote build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint fmt vet help +.PHONY: all build build-all build-agentctl build-agentctl-remote build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob e2e-plugin-package clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint fmt vet help ## Default target all: build @@ -166,6 +166,31 @@ build-mock-agent-linux: GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/mock-agent-linux-amd64 ./cmd/mock-agent +## Package the plugin-fixture SDK plugin (cmd/plugin-fixture) as a kandev +## plugin tar.gz for backend integration tests and Playwright e2e: stages +## fixture-package/ (manifest.yaml + ui/) into a temp dir, builds the +## plugin-fixture binary for the HOST platform into /server/, then +## packs it with cmd/plugin-pack (-platform-only: host binary only, smaller +## package) into .build/kandev-plugin-e2e-1.0.0.tar.gz. e2e tests upload +## this file to install the plugin. +FIXTURE_PACKAGE_DIR := cmd/plugin-fixture/fixture-package +FIXTURE_STAGE_DIR := .build/fixture-stage +FIXTURE_PACKAGE_OUT := .build/kandev-plugin-e2e-1.0.0.tar.gz +HOST_GOOS := $(shell $(GO) env GOOS) +HOST_GOARCH := $(shell $(GO) env GOARCH) + +.PHONY: e2e-plugin-package +e2e-plugin-package: + @echo "Packaging plugin-fixture for e2e ($(HOST_GOOS)-$(HOST_GOARCH))..." + @$(RMDIR) $(FIXTURE_STAGE_DIR) + @$(MKDIR) $(FIXTURE_STAGE_DIR)/server + cp $(FIXTURE_PACKAGE_DIR)/manifest.yaml $(FIXTURE_STAGE_DIR)/manifest.yaml + cp -r $(FIXTURE_PACKAGE_DIR)/ui $(FIXTURE_STAGE_DIR)/ui + $(GO) build $(GOFLAGS) -o $(FIXTURE_STAGE_DIR)/server/plugin-$(HOST_GOOS)-$(HOST_GOARCH)$(EXE) ./cmd/plugin-fixture + $(GO) run ./cmd/plugin-pack -dir $(FIXTURE_STAGE_DIR) -out $(FIXTURE_PACKAGE_OUT) -platform-only + @$(RMDIR) $(FIXTURE_STAGE_DIR) + @echo "Wrote $(FIXTURE_PACKAGE_OUT)" + ## Clean build artifacts clean: @echo "Cleaning..." @@ -234,6 +259,25 @@ test-coverage: $(CGO_PREFIX) $(GO) test -tags fts5 -v -coverprofile=coverage.out ./... $(GO) tool cover -html=coverage.out -o coverage.html +## Regenerate Go stubs from proto/kandev/plugin/v1/plugin.proto (§3 of +## docs/plans/plugins/GRPC-CONTRACT.md). Installs protoc-gen-go and +## protoc-gen-go-grpc into a local .tools/bin (not committed) and drives them +## via `go run` buf — no system protoc required. Requires network access for +## the first run (tool + buf module downloads); output is committed so +## `go build` never needs this target. +PROTO_TOOLS_BIN := $(CURDIR)/.tools/bin +BUF_VERSION := v1.47.2 +PROTOC_GEN_GO_VERSION := v1.36.11 +PROTOC_GEN_GO_GRPC_VERSION := v1.5.1 + +.PHONY: proto +proto: + @echo "Generating proto stubs..." + @$(MKDIR) $(PROTO_TOOLS_BIN) + GOBIN=$(PROTO_TOOLS_BIN) $(GO) install google.golang.org/protobuf/cmd/protoc-gen-go@$(PROTOC_GEN_GO_VERSION) + GOBIN=$(PROTO_TOOLS_BIN) $(GO) install google.golang.org/grpc/cmd/protoc-gen-go-grpc@$(PROTOC_GEN_GO_GRPC_VERSION) + cd proto && PATH="$(PROTO_TOOLS_BIN):$$PATH" $(GO) run github.com/bufbuild/buf/cmd/buf@$(BUF_VERSION) generate + ## Run linter (requires golangci-lint) lint: @echo "Running linter..." @@ -288,6 +332,7 @@ help: @echo " test Run tests (full suite — fails on Windows pending fixture cleanup)" @echo " test-windows Run only the Windows-clean subset (matches CI windows-latest job)" @echo " test-e2e Run E2E adapter tests (real agents, costs money)" + @echo " e2e-plugin-package Package the plugin-fixture SDK plugin into .build/kandev-plugin-e2e-1.0.0.tar.gz" @echo " test-lifecycle-goleak Stress-run lifecycle goleak suite (override LIFECYCLE_GOLEAK_COUNT=N)" @echo " test-coverage Run tests with coverage report" @echo " lint Run golangci-lint" diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml new file mode 100644 index 0000000000..3b08387a2f --- /dev/null +++ b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml @@ -0,0 +1,39 @@ +# Manifest for the plugin-fixture SDK plugin (docs/plans/plugins/GRPC-CONTRACT.md +# §6). Used by Go integration tests and Playwright e2e tests — see +# apps/backend/cmd/plugin-fixture and the `e2e-plugin-package` Makefile +# target, which stages this directory, builds the plugin-fixture binary for +# the host platform into server/, and packs the whole tree with +# cmd/plugin-pack into apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz. +id: "kandev-plugin-e2e" +api_version: 1 +version: "1.0.0" +display_name: "Kandev E2E Fixture Plugin" +description: "SDK-based plugin backend used by backend integration tests and Playwright e2e tests" +author: "kandev" +categories: ["tools"] + +runtime: + type: binary + executables: + linux-amd64: "server/plugin-linux-amd64" + linux-arm64: "server/plugin-linux-arm64" + darwin-amd64: "server/plugin-darwin-amd64" + darwin-arm64: "server/plugin-darwin-arm64" + windows-amd64: "server/plugin-windows-amd64.exe" + +capabilities: + events: ["task.created"] + state: true + +tools: + - name: "echo" + display_name: "Echo" + description: "Returns its input unchanged, for e2e tool-invocation coverage" + +webhooks: + - key: "test-hook" + description: "Records inbound webhooks for e2e polling" + method: "POST" + +ui: + bundle: "/ui/bundle.js" diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js new file mode 100644 index 0000000000..24f41f63ca --- /dev/null +++ b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js @@ -0,0 +1,74 @@ +/** + * Native JS UI bundle for the kandev-plugin-e2e fixture plugin + * (docs/plans/plugins/PLUGIN-API.md). A self-contained ES module — no + * imports, no bundled React — that calls `window.registerKandevPlugin(id, + * plugin)` at evaluation time and, on `initialize(registry, host)`, + * registers a nav item, a top-level route, a `task-sidebar` slot component, + * and a `task.created` WS handler. Uses only host.React/host.jsx. + * + * The task-created counter lives in module scope (not component state) with + * a tiny listener set, so it survives across route navigations (the page + * component unmounts/remounts as the user navigates away and back). + */ +(function () { + var moduleCount = 0; + var listeners = new Set(); + + function emit() { + listeners.forEach(function (fn) { + fn(moduleCount); + }); + } + + function incrementCount() { + moduleCount += 1; + emit(); + } + + function useCounter(React) { + var state = React.useState(moduleCount); + var count = state[0]; + var setCount = state[1]; + React.useEffect(function () { + setCount(moduleCount); + listeners.add(setCount); + return function () { + listeners.delete(setCount); + }; + }, []); + return count; + } + + window.registerKandevPlugin("kandev-plugin-e2e", { + initialize: function (registry, host) { + var React = host.React; + var jsx = host.jsx; + + function PluginPage() { + var count = useCounter(React); + return jsx( + "div", + { id: "hello-plugin-page-root" }, + jsx("h1", { id: "hello-plugin-page" }, "Hello E2E"), + jsx("span", { id: "hello-task-counter" }, String(count)), + ); + } + + function SidebarSlot() { + return jsx("div", { id: "hello-sidebar" }, "Hello E2E sidebar"); + } + + registry.registerNavItem({ + id: "e2e-hello", + label: "Hello E2E", + path: "/plugins/e2e-hello", + section: "main", + }); + registry.registerRoute("/plugins/e2e-hello", PluginPage); + registry.registerComponent("task-sidebar", SidebarSlot); + registry.registerWsHandler("task.created", function () { + incrementCount(); + }); + }, + }); +})(); diff --git a/apps/backend/cmd/plugin-fixture/fixture_package_test.go b/apps/backend/cmd/plugin-fixture/fixture_package_test.go new file mode 100644 index 0000000000..d431151381 --- /dev/null +++ b/apps/backend/cmd/plugin-fixture/fixture_package_test.go @@ -0,0 +1,55 @@ +// Guards fixture-package/manifest.yaml against drift: it must stay a valid, +// runtime-managed manifest that declares the id, tool, webhook, and UI +// bundle path the e2e suite and `make e2e-plugin-package` depend on. See +// docs/plans/plugins/GRPC-CONTRACT.md §6. +package main + +import ( + _ "embed" + "testing" + + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/stretchr/testify/require" +) + +//go:embed fixture-package/manifest.yaml +var fixtureManifestYAML []byte + +func TestFixtureManifest_ParsesAndValidates(t *testing.T) { + m, err := manifest.Parse(fixtureManifestYAML) + require.NoError(t, err) + require.NoError(t, m.Validate()) + + require.Equal(t, "kandev-plugin-e2e", m.ID) + require.Equal(t, 1, m.APIVersion) + require.Equal(t, "1.0.0", m.Version) + require.True(t, m.IsManaged()) + require.Equal(t, "/ui/bundle.js", m.UI.Bundle) + require.True(t, m.HasEvent("task.created")) + require.True(t, m.Capabilities.State) + + require.Len(t, m.Tools, 1) + require.Equal(t, "echo", m.Tools[0].Name) + + require.Len(t, m.Webhooks, 1) + require.Equal(t, "test-hook", m.Webhooks[0].Key) + require.Equal(t, "POST", m.Webhooks[0].Method) +} + +func TestFixtureManifest_DeclaresHostPlatformExecutable(t *testing.T) { + m, err := manifest.Parse(fixtureManifestYAML) + require.NoError(t, err) + + // The Makefile's `e2e-plugin-package` target only ever builds/packs for + // the host platform, but the committed manifest lists every platform + // the fixture might run on in CI (linux/darwin/windows, amd64/arm64). + for platformKey, execPath := range map[string]string{ + "linux-amd64": "server/plugin-linux-amd64", + "linux-arm64": "server/plugin-linux-arm64", + "darwin-amd64": "server/plugin-darwin-amd64", + "darwin-arm64": "server/plugin-darwin-arm64", + "windows-amd64": "server/plugin-windows-amd64.exe", + } { + require.Equal(t, execPath, m.Runtime.Executables[platformKey], "platform %s", platformKey) + } +} diff --git a/apps/backend/cmd/plugin-fixture/main.go b/apps/backend/cmd/plugin-fixture/main.go new file mode 100644 index 0000000000..db8b9af236 --- /dev/null +++ b/apps/backend/cmd/plugin-fixture/main.go @@ -0,0 +1,17 @@ +// Command plugin-fixture is an SDK-based kandev plugin backend used by Go +// integration tests and Playwright e2e tests: it proves the gRPC plugin +// contract (docs/plans/plugins/GRPC-CONTRACT.md) end to end without +// depending on internal/plugins, since it only imports the public +// pkg/pluginsdk surface — exactly what a third-party plugin author would. +// +// Its companion package directory, fixture-package/, holds the manifest and +// UI bundle a test packages this binary with (see +// docs/plans/plugins/GRPC-CONTRACT.md §6 and `make e2e-plugin-package` in +// apps/backend/Makefile). +package main + +import "github.com/kandev/kandev/pkg/pluginsdk" + +func main() { + pluginsdk.Serve(newFixturePlugin()) +} diff --git a/apps/backend/cmd/plugin-fixture/main_test.go b/apps/backend/cmd/plugin-fixture/main_test.go new file mode 100644 index 0000000000..be0961d24f --- /dev/null +++ b/apps/backend/cmd/plugin-fixture/main_test.go @@ -0,0 +1,187 @@ +// Command plugin-fixture tests. Exercises fixturePlugin's Plugin methods +// (OnEvent, InvokeTool, HandleWebhook) via direct calls — no go-plugin +// spawn needed, since fixturePlugin has no dependency on the gRPC +// transport itself (pluginsdk.Serve owns that wiring and is covered by +// pkg/pluginsdk's own tests). +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/kandev/kandev/pkg/pluginsdk" + "github.com/stretchr/testify/require" +) + +var errBoom = errors.New("boom") + +// readJSONLines reads path and decodes each non-empty line as T. +func readJSONLines[T any](t *testing.T, path string) []T { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + + var out []T + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := scanner.Bytes() + if len(bytes.TrimSpace(line)) == 0 { + continue + } + var v T + require.NoError(t, json.Unmarshal(line, &v)) + out = append(out, v) + } + require.NoError(t, scanner.Err()) + return out +} + +func TestOnEvent_AppendsJSONLineToDataDir(t *testing.T) { + dir := t.TempDir() + p := &fixturePlugin{dataDir: dir} + + err := p.OnEvent(context.Background(), &pluginsdk.Event{ + EventID: "evt_1", + EventType: "task.created", + Payload: map[string]any{"task_id": "t1"}, + }) + require.NoError(t, err) + + recs := readJSONLines[deliveryRecord](t, filepath.Join(dir, "deliveries.jsonl")) + require.Len(t, recs, 1) + require.Equal(t, "task.created", recs[0].EventType) + require.Equal(t, "evt_1", recs[0].EventID) +} + +func TestOnEvent_AppendsMultipleDeliveriesInOrder(t *testing.T) { + dir := t.TempDir() + p := &fixturePlugin{dataDir: dir} + + require.NoError(t, p.OnEvent(context.Background(), &pluginsdk.Event{EventID: "e1", EventType: "task.created"})) + require.NoError(t, p.OnEvent(context.Background(), &pluginsdk.Event{EventID: "e2", EventType: "task.updated"})) + + recs := readJSONLines[deliveryRecord](t, filepath.Join(dir, "deliveries.jsonl")) + require.Len(t, recs, 2) + require.Equal(t, "e1", recs[0].EventID) + require.Equal(t, "e2", recs[1].EventID) +} + +func TestOnEvent_CreatesDataDirIfMissing(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "data") + p := &fixturePlugin{dataDir: dir} + + err := p.OnEvent(context.Background(), &pluginsdk.Event{EventID: "e1", EventType: "task.created"}) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(dir, "deliveries.jsonl")) + require.NoError(t, err) +} + +func TestOnEvent_FirstEventCallsHostSetState(t *testing.T) { + dir := t.TempDir() + p := &fixturePlugin{dataDir: dir} + host := &fakeHost{} + p.SetHost(host) + + require.NoError(t, p.OnEvent(context.Background(), &pluginsdk.Event{EventID: "e1", EventType: "task.created"})) + require.NoError(t, p.OnEvent(context.Background(), &pluginsdk.Event{EventID: "e2", EventType: "task.updated"})) + + require.Len(t, host.setStateCalls, 1, "SetState should only be exercised on the first event") + call := host.setStateCalls[0] + require.Equal(t, "instance", call.scope) + require.Equal(t, "", call.scopeID) + require.Equal(t, "last_event", call.key) + require.Equal(t, "e1", call.value["event_id"]) +} + +func TestOnEvent_IgnoresHostSetStateError(t *testing.T) { + dir := t.TempDir() + p := &fixturePlugin{dataDir: dir} + host := &fakeHost{setStateErr: errBoom} + p.SetHost(host) + + err := p.OnEvent(context.Background(), &pluginsdk.Event{EventID: "e1", EventType: "task.created"}) + require.NoError(t, err, "Host.SetState failures must be best-effort and not fail OnEvent") +} + +func TestInvokeTool_EchoReturnsInput(t *testing.T) { + p := &fixturePlugin{dataDir: t.TempDir()} + + input := map[string]any{"channel": "#dev", "text": "hi"} + resp, err := p.InvokeTool(context.Background(), &pluginsdk.ToolRequest{ + ToolName: "echo", + Input: input, + }) + require.NoError(t, err) + require.Equal(t, input, resp.Output) +} + +func TestHandleWebhook_AppendsJSONLineAndRespondsOK(t *testing.T) { + dir := t.TempDir() + p := &fixturePlugin{dataDir: dir} + + resp, err := p.HandleWebhook(context.Background(), &pluginsdk.WebhookRequest{ + WebhookKey: "test-hook", + Method: "POST", + Body: []byte(`{"ping":true}`), + }) + require.NoError(t, err) + require.Equal(t, int32(200), resp.Status) + require.Equal(t, "ok", string(resp.Body)) + + recs := readJSONLines[webhookRecord](t, filepath.Join(dir, "webhooks.jsonl")) + require.Len(t, recs, 1) + require.Equal(t, "test-hook", recs[0].WebhookKey) +} + +func TestResolveDataDir_UsesEnvWhenSet(t *testing.T) { + t.Setenv("KANDEV_PLUGIN_DATA_DIR", "/tmp/kandev-plugin-e2e-data") + require.Equal(t, "/tmp/kandev-plugin-e2e-data", resolveDataDir()) +} + +func TestResolveDataDir_FallsBackToCwdWhenUnset(t *testing.T) { + t.Setenv("KANDEV_PLUGIN_DATA_DIR", "") + wd, err := os.Getwd() + require.NoError(t, err) + require.Equal(t, wd, resolveDataDir()) +} + +// setStateCall records one fakeHost.SetState invocation. +type setStateCall struct { + scope, scopeID, key string + value map[string]any +} + +// fakeHost is a minimal pluginsdk.Host test double that only records +// SetState calls; the fixture plugin never exercises the other methods. +type fakeHost struct { + setStateCalls []setStateCall + setStateErr error +} + +func (h *fakeHost) GetState(context.Context, string, string, string) (map[string]any, bool, error) { + return nil, false, nil +} + +func (h *fakeHost) SetState(_ context.Context, scope, scopeID, key string, value map[string]any) error { + h.setStateCalls = append(h.setStateCalls, setStateCall{scope: scope, scopeID: scopeID, key: key, value: value}) + return h.setStateErr +} + +func (h *fakeHost) DeleteState(context.Context, string, string, string) error { return nil } + +func (h *fakeHost) ListState(context.Context, string, string) ([]pluginsdk.StateEntry, error) { + return nil, 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 } + +var _ pluginsdk.Host = (*fakeHost)(nil) diff --git a/apps/backend/cmd/plugin-fixture/plugin.go b/apps/backend/cmd/plugin-fixture/plugin.go new file mode 100644 index 0000000000..3000b67345 --- /dev/null +++ b/apps/backend/cmd/plugin-fixture/plugin.go @@ -0,0 +1,159 @@ +// 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" + + 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, and +// responds 200 "ok". +func (p *fixturePlugin) HandleWebhook(_ 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 + } + return &pluginsdk.WebhookResponse{Status: 200, Body: []byte("ok")}, nil +} + +// 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() +} diff --git a/apps/backend/cmd/plugin-pack/fixture_package_test.go b/apps/backend/cmd/plugin-pack/fixture_package_test.go new file mode 100644 index 0000000000..4ba5b9a162 --- /dev/null +++ b/apps/backend/cmd/plugin-pack/fixture_package_test.go @@ -0,0 +1,79 @@ +// Integration test proving Pack() accepts the real +// cmd/plugin-fixture/fixture-package directory (the same source +// `make e2e-plugin-package` packs) and produces an archive pkgtar.Install +// accepts — the exact contract the e2e suite depends on. +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/pkgtar" + "github.com/stretchr/testify/require" +) + +// stageFixturePackage copies cmd/plugin-fixture/fixture-package's +// manifest.yaml and ui/ into a fresh temp dir, plus a dummy (non-executed) +// binary at the manifest's declared path for the test host's GOOS/GOARCH — +// pkgtar.Install only requires the file to exist, not to actually run. +func stageFixturePackage(t *testing.T) string { + t.Helper() + srcDir := filepath.Join("..", "plugin-fixture", "fixture-package") + + manifestData, err := os.ReadFile(filepath.Join(srcDir, "manifest.yaml")) + require.NoError(t, err) + m, err := manifest.Parse(manifestData) + require.NoError(t, err) + execPath, ok := m.ExecutableFor(runtime.GOOS, runtime.GOARCH) + require.True(t, ok, "fixture-package manifest.yaml has no runtime.executables entry for the test host %s-%s", runtime.GOOS, runtime.GOARCH) + + stage := t.TempDir() + require.NoError(t, copyFile(filepath.Join(srcDir, "manifest.yaml"), filepath.Join(stage, "manifest.yaml"))) + require.NoError(t, copyFile(filepath.Join(srcDir, "ui", "bundle.js"), filepath.Join(stage, "ui", "bundle.js"))) + + dummyBinPath := filepath.Join(stage, filepath.FromSlash(execPath)) + require.NoError(t, os.MkdirAll(filepath.Dir(dummyBinPath), 0o755)) + require.NoError(t, os.WriteFile(dummyBinPath, []byte("#!/bin/sh\necho fixture\n"), 0o755)) + + return stage +} + +func copyFile(src, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer func() { _ = in.Close() }() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer func() { _ = out.Close() }() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} + +func TestPack_FixturePackageDir_ProducesInstallablePackage(t *testing.T) { + stage := stageFixturePackage(t) + + var buf bytes.Buffer + require.NoError(t, Pack(stage, &buf, PackOptions{PlatformOnly: true})) + + destRoot := t.TempDir() + result, err := pkgtar.Install(&buf, destRoot) + require.NoError(t, err) + require.Equal(t, "kandev-plugin-e2e", result.Manifest.ID) + require.Equal(t, "1.0.0", result.Version) +} diff --git a/apps/backend/cmd/plugin-pack/main.go b/apps/backend/cmd/plugin-pack/main.go new file mode 100644 index 0000000000..44e031f9d8 --- /dev/null +++ b/apps/backend/cmd/plugin-pack/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +func main() { + os.Exit(run(os.Args[1:])) +} + +// run parses CLI flags and packs -dir into -out. Returns a process exit +// code (0 success, 1 packaging failure, 2 usage error). +func run(args []string) int { + fs := flag.NewFlagSet("plugin-pack", flag.ContinueOnError) + dir := fs.String("dir", "", "plugin package directory (must contain manifest.yaml)") + out := fs.String("out", "", "output tar.gz path") + platformOnly := fs.Bool("platform-only", false, + "include only the current host platform's server/ executable (smaller test packages)") + if err := fs.Parse(args); err != nil { + return 2 + } + if *dir == "" || *out == "" { + fmt.Fprintln(os.Stderr, "plugin-pack: -dir and -out are required") + fs.Usage() + return 2 + } + + if err := packToFile(*dir, *out, *platformOnly); err != nil { + fmt.Fprintln(os.Stderr, "plugin-pack:", err) + return 1 + } + _, _ = fmt.Fprintf(os.Stdout, "plugin-pack: wrote %s\n", *out) + return 0 +} diff --git a/apps/backend/cmd/plugin-pack/pack.go b/apps/backend/cmd/plugin-pack/pack.go new file mode 100644 index 0000000000..3daab6da4d --- /dev/null +++ b/apps/backend/cmd/plugin-pack/pack.go @@ -0,0 +1,162 @@ +// Command plugin-pack packages a plugin source directory into the kandev +// plugin tar.gz format described in docs/plans/plugins/GRPC-CONTRACT.md §6: +// manifest.yaml plus any server/ui/assets files, with a generated +// checksums.txt covering everything else. It is a thin CLI wrapper around +// pkgtar/pkgtartest.WritePackage, reused by both apps/backend's +// `make e2e-plugin-package` target and (via `go run`, replace directive) +// external plugin repos such as kandev-plugin-hello. +// +// Usage: +// +// plugin-pack -dir -out [-platform-only] +package main + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest" +) + +const ( + manifestFileName = "manifest.yaml" + checksumsFileName = "checksums.txt" + checksumsSigFileName = "checksums.txt.sig" + serverDirPrefix = "server/" +) + +// PackOptions configures Pack. +type PackOptions struct { + // PlatformOnly restricts included server/ executables to a single + // platform (GOOS/GOARCH below), producing a smaller package — useful + // for test fixtures that only ever run on the host that built them. + PlatformOnly bool + // GOOS/GOARCH override the platform used for PlatformOnly filtering. + // Empty means runtime.GOOS/runtime.GOARCH (the normal case; overridable + // for tests). + GOOS string + GOARCH string +} + +// Pack reads every file under dir (which must contain manifest.yaml) and +// writes a kandev plugin tar.gz package to w, preserving relative paths and +// generating checksums.txt. When opts.PlatformOnly is set, server/ +// executables for platforms other than the resolved GOOS/GOARCH are +// excluded. +func Pack(dir string, w io.Writer, opts PackOptions) error { + files, err := collectPackageFiles(dir, opts) + if err != nil { + return err + } + if _, ok := files[manifestFileName]; !ok { + return fmt.Errorf("plugin-pack: %s not found under %s", manifestFileName, dir) + } + if err := pkgtartest.WritePackage(w, files); err != nil { + return fmt.Errorf("plugin-pack: writing package: %w", err) + } + return nil +} + +// collectPackageFiles walks dir and reads every regular file into a +// package-relative-path -> contents map, optionally filtering out +// non-host-platform server/ executables (see hostExecutablePath). +func collectPackageFiles(dir string, opts PackOptions) (map[string][]byte, error) { + var hostExecPath string + if opts.PlatformOnly { + execPath, err := hostExecutablePath(dir, opts) + if err != nil { + return nil, err + } + hostExecPath = execPath + } + + files := make(map[string][]byte) + walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return fmt.Errorf("plugin-pack: relative path for %s: %w", path, err) + } + rel = filepath.ToSlash(rel) + if rel == checksumsFileName || rel == checksumsSigFileName { + return fmt.Errorf("plugin-pack: %s must not be pre-supplied in %s; it is generated", rel, dir) + } + if opts.PlatformOnly && isServerExecutable(rel) && rel != hostExecPath { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("plugin-pack: reading %s: %w", rel, err) + } + files[rel] = data + return nil + }) + if walkErr != nil { + return nil, walkErr + } + return files, nil +} + +// hostExecutablePath parses dir/manifest.yaml and resolves the +// runtime.executables entry for opts.GOOS/opts.GOARCH (defaulting to +// runtime.GOOS/runtime.GOARCH). +func hostExecutablePath(dir string, opts PackOptions) (string, error) { + goos := opts.GOOS + if goos == "" { + goos = runtime.GOOS + } + goarch := opts.GOARCH + if goarch == "" { + goarch = runtime.GOARCH + } + + data, err := os.ReadFile(filepath.Join(dir, manifestFileName)) + if err != nil { + return "", fmt.Errorf("plugin-pack: reading %s for -platform-only: %w", manifestFileName, err) + } + m, err := manifest.Parse(data) + if err != nil { + return "", fmt.Errorf("plugin-pack: parsing %s: %w", manifestFileName, err) + } + execPath, ok := m.ExecutableFor(goos, goarch) + if !ok { + return "", fmt.Errorf("plugin-pack: manifest declares no runtime.executables entry for %s-%s", goos, goarch) + } + return execPath, nil +} + +// isServerExecutable reports whether rel is a package-relative path under +// server/ (the directory pkgtartest/pkgtar treat as holding runtime +// executables). +func isServerExecutable(rel string) bool { + return strings.HasPrefix(rel, serverDirPrefix) +} + +// packToFile packs dir into a fresh file at out (creating parent +// directories as needed), overwriting any existing file. +func packToFile(dir, out string, platformOnly bool) error { + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { + return fmt.Errorf("plugin-pack: creating output dir: %w", err) + } + f, err := os.Create(out) + if err != nil { + return fmt.Errorf("plugin-pack: creating %s: %w", out, err) + } + defer func() { _ = f.Close() }() + + if err := Pack(dir, f, PackOptions{PlatformOnly: platformOnly}); err != nil { + return err + } + return f.Close() +} diff --git a/apps/backend/cmd/plugin-pack/pack_test.go b/apps/backend/cmd/plugin-pack/pack_test.go new file mode 100644 index 0000000000..498ccc62d8 --- /dev/null +++ b/apps/backend/cmd/plugin-pack/pack_test.go @@ -0,0 +1,151 @@ +// Command plugin-pack tests. Exercises Pack() (the packaging core) against +// real temp-dir package layouts, verifying both the raw tar contents (for +// -platform-only filtering) and that pkgtar.Install accepts the produced +// archive end to end. +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/kandev/kandev/internal/plugins/pkgtar" + "github.com/stretchr/testify/require" +) + +// writeTestPackageDir builds a plugin package source directory (not yet +// packed) under t.TempDir(), with a manifest.yaml declaring runtime +// executables for the current host platform plus two extra platforms, a +// dummy binary for each, and a ui/bundle.js. Returns the dir. +func writeTestPackageDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + hostKey := runtime.GOOS + "-" + runtime.GOARCH + manifestYAML := fmt.Sprintf(` +id: "kandev-plugin-pack-test" +api_version: 1 +version: "1.0.0" +display_name: "Pack Test Plugin" +description: "fixture for plugin-pack tests" +author: "kandev" + +runtime: + type: binary + executables: + %s: "server/plugin-%s" + linux-arm64: "server/plugin-linux-arm64" + windows-amd64: "server/plugin-windows-amd64.exe" + +capabilities: + state: true +`, hostKey, hostKey) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "manifest.yaml"), []byte(manifestYAML), 0o644)) + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "server"), 0o755)) + for _, name := range []string{ + "plugin-" + hostKey, + "plugin-linux-arm64", + "plugin-windows-amd64.exe", + } { + require.NoError(t, os.WriteFile(filepath.Join(dir, "server", name), []byte("#!/bin/sh\necho fake-binary\n"), 0o755)) + } + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "ui"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "ui", "bundle.js"), []byte("console.log('hi');"), 0o644)) + + return dir +} + +// tarEntryNames decompresses and lists every regular-file entry name in a +// tar.gz archive. +func tarEntryNames(t *testing.T, data []byte) []string { + t.Helper() + gz, err := gzip.NewReader(bytes.NewReader(data)) + require.NoError(t, err) + defer func() { _ = gz.Close() }() + + tr := tar.NewReader(gz) + var names []string + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + if hdr.Typeflag == tar.TypeReg { + names = append(names, hdr.Name) + } + } + return names +} + +func TestPack_ProducesInstallableTarball(t *testing.T) { + dir := writeTestPackageDir(t) + + var buf bytes.Buffer + require.NoError(t, Pack(dir, &buf, PackOptions{})) + + names := tarEntryNames(t, buf.Bytes()) + require.Contains(t, names, "manifest.yaml") + require.Contains(t, names, "server/plugin-"+runtime.GOOS+"-"+runtime.GOARCH) + require.Contains(t, names, "server/plugin-linux-arm64") + require.Contains(t, names, "server/plugin-windows-amd64.exe") + require.Contains(t, names, "ui/bundle.js") + require.Contains(t, names, "checksums.txt") + + destRoot := t.TempDir() + result, err := pkgtar.Install(&buf, destRoot) + require.NoError(t, err) + require.Equal(t, "kandev-plugin-pack-test", result.Manifest.ID) + require.Equal(t, "1.0.0", result.Version) +} + +func TestPack_PlatformOnly_IncludesOnlyHostBinary(t *testing.T) { + dir := writeTestPackageDir(t) + + var buf bytes.Buffer + require.NoError(t, Pack(dir, &buf, PackOptions{PlatformOnly: true})) + + names := tarEntryNames(t, buf.Bytes()) + require.Contains(t, names, "server/plugin-"+runtime.GOOS+"-"+runtime.GOARCH) + require.NotContains(t, names, "server/plugin-linux-arm64") + require.NotContains(t, names, "server/plugin-windows-amd64.exe") + require.Contains(t, names, "manifest.yaml") + require.Contains(t, names, "ui/bundle.js") + + // The filtered package must still install cleanly on the host platform. + destRoot := t.TempDir() + result, err := pkgtar.Install(&buf, destRoot) + require.NoError(t, err) + require.Equal(t, "kandev-plugin-pack-test", result.Manifest.ID) +} + +func TestPack_MissingManifest_Errors(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "ui.js"), []byte("x"), 0o644)) + + var buf bytes.Buffer + err := Pack(dir, &buf, PackOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "manifest.yaml") +} + +func TestPackToFile_WritesTarballToDisk(t *testing.T) { + dir := writeTestPackageDir(t) + out := filepath.Join(t.TempDir(), "nested", "out.tar.gz") + + require.NoError(t, packToFile(dir, out, false)) + + data, err := os.ReadFile(out) + require.NoError(t, err) + names := tarEntryNames(t, data) + require.Contains(t, names, "manifest.yaml") +} diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 6a4371a6e7..4d1826f1ea 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -12,6 +12,7 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/hashicorp/go-plugin v1.8.0 github.com/jackc/pgx/v5 v5.9.0 github.com/jmoiron/sqlx v1.4.0 github.com/kevinburke/ssh_config v1.6.0 @@ -33,6 +34,8 @@ require ( golang.org/x/net v0.53.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.44.0 + google.golang.org/grpc v1.82.0 + google.golang.org/protobuf v1.36.11 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -52,6 +55,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/fatih/color v1.13.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect @@ -62,8 +66,11 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -75,6 +82,7 @@ require ( github.com/leodido/go-urn v1.2.4 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -85,6 +93,7 @@ require ( github.com/morikuni/aec v1.1.0 // indirect github.com/nats-io/nkeys v0.4.6 // indirect github.com/nats-io/nuid v1.0.1 // indirect + github.com/oklog/run v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect @@ -108,10 +117,8 @@ require ( golang.org/x/arch v0.3.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gotest.tools/v3 v3.5.2 // indirect ) diff --git a/apps/backend/go.sum b/apps/backend/go.sum index ab51f92c73..76405ccc3f 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -52,6 +52,8 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= @@ -97,6 +99,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -194,10 +198,16 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= +github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= @@ -210,6 +220,8 @@ github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE= github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= @@ -246,6 +258,11 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= @@ -274,6 +291,8 @@ github.com/nats-io/nkeys v0.4.6 h1:IzVe95ru2CT6ta874rt9saQRkWfe2nFj1NtvYSLqMzY= github.com/nats-io/nkeys v0.4.6/go.mod h1:4DxZNzenSVd1cYQoAa8948QY3QDjrHfcfVADymtkpts= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -313,6 +332,7 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -483,6 +503,7 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -506,6 +527,9 @@ golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -645,10 +669,10 @@ google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -665,8 +689,8 @@ google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go index e9cef97501..767d3d2a81 100644 --- a/apps/backend/internal/backendapp/boot_state.go +++ b/apps/backend/internal/backendapp/boot_state.go @@ -38,7 +38,14 @@ func bootInitialState( builder.addUserSettingsState(ctx, state, "") builder.addSettingsRouteState(ctx, state, route.Path) } - if route.Route == webapp.RouteHome { + // Home and unknown SPA routes both render the full app shell (nav, + // workspace picker) without a route-specific data payload. Unknown + // covers plugin-owned routes (e.g. /github-plugin) registered at + // runtime, which the backend classifier can't enumerate — they still + // need the base workspace/workflow/kanban context so native plugin UI + // (like host.ui.TaskCreateDialog) has workspaces and workflows to work + // with, not an empty store. + if route.Route == webapp.RouteHome || route.Route == webapp.RouteUnknown { builder.addHomeKanbanRouteState(ctx, req, state) } if route.Route == webapp.RouteTasks { diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 021d97f08c..d55f617d43 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -55,6 +55,8 @@ import ( officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" officetestharness "github.com/kandev/kandev/internal/office/testharness" "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/plugins" + pluginstore "github.com/kandev/kandev/internal/plugins/store" promptcontroller "github.com/kandev/kandev/internal/prompts/controller" prompthandlers "github.com/kandev/kandev/internal/prompts/handlers" "github.com/kandev/kandev/internal/repoclone" @@ -671,9 +673,47 @@ func bootPayload(ctx context.Context, req *http.Request, p routeParams, route we bootInitialState(ctx, req, p, route), ) payload.RouteData = bootRouteData(ctx, req, p, route) + payload.Plugins = bootActivePlugins(p) return payload } +// bootActivePlugins populates the boot payload's Plugins list from every +// active, UI-bundle-declaring plugin, per +// docs/plans/plugins/PLUGIN-API.md ("Loading model"). Gated on +// features.Plugins — separate from the /api/v1/features flag map itself, +// this is active-bundle data the frontend still gates loading on via +// useFeature("plugins"). +func bootActivePlugins(p routeParams) []webapp.ActivePluginPayload { + if !p.features.Plugins || p.services == nil || p.services.Plugins == nil { + return nil + } + records := p.services.Plugins.ActiveUIPlugins() + out := make([]webapp.ActivePluginPayload, 0, len(records)) + for _, rec := range records { + out = append(out, webapp.ActivePluginPayload{ + ID: rec.ID, + Name: rec.DisplayName, + BundleURL: "/api/plugins/" + rec.ID + "/bundle", + StyleURLs: pluginStyleURLs(rec), + }) + } + return out +} + +// pluginStyleURLs maps a plugin's root-relative ui.styles paths to +// browser-facing URLs served through the /api/plugins/:id/ui/* proxy (the +// plugin's own base_url is never exposed to the browser directly). +func pluginStyleURLs(rec pluginstore.Record) []string { + if len(rec.UI.Styles) == 0 { + return nil + } + urls := make([]string, 0, len(rec.UI.Styles)) + for _, style := range rec.UI.Styles { + urls = append(urls, "/api/plugins/"+rec.ID+"/ui"+style) + } + return urls +} + func webAssetsFS() (fs.FS, string, bool) { distDir := os.Getenv("KANDEV_WEB_DIST_DIR") if distDir == "" { @@ -938,6 +978,11 @@ func registerSecondaryRoutes( p.log.Debug("Registered Automation handlers (HTTP + WebSocket)") } + if p.features.Plugins && p.services.Plugins != nil { + plugins.RegisterRoutes(p.router, p.services.Plugins, p.services.Plugins.Deliverer(), p.log) + p.log.Debug("Registered Plugins handlers (HTTP)") + } + docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), dockerTaskTitleProvider(p.taskRepo, p.log), p.log) p.log.Debug("Registered Docker management handlers (HTTP)") diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 7a808befe3..e682d828c2 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -578,6 +578,14 @@ func startAgentInfrastructure( log.Info("Workflow sync poller started") } + // Start the plugin system's event delivery and health monitor + // background loops. Gated on features.Plugins so an unconfigured/ + // disabled deployment doesn't poll plugin health endpoints that were + // never registered. + if services.Plugins != nil && cfg.Features.Plugins { + startPluginsSubsystems(ctx, services.Plugins, eventBus, log, addCleanup) + } + // Wire automation service into orchestrator for trigger-based task creation. // The Automation subsystem is independent of the Office feature flag — it // has its own cron scheduler, GitHub poller, and webhook handler, and diff --git a/apps/backend/internal/backendapp/plugins.go b/apps/backend/internal/backendapp/plugins.go new file mode 100644 index 0000000000..e31656f900 --- /dev/null +++ b/apps/backend/internal/backendapp/plugins.go @@ -0,0 +1,56 @@ +package backendapp + +import ( + "context" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/plugins" + "github.com/kandev/kandev/internal/plugins/delivery" + pluginstore "github.com/kandev/kandev/internal/plugins/store" +) + +// pluginActivePluginsAdapter adapts *plugins.Service to delivery.PluginLister +// so the Deliverer can track which plugins should receive live deliveries +// (StatusActive) or buffer them (StatusError) without internal/plugins/delivery +// importing internal/plugins — see that package's doc comment ("backendapp +// adapts *plugins.Service to satisfy them"). +type pluginActivePluginsAdapter struct { + svc *plugins.Service +} + +// ActivePlugins implements delivery.PluginLister. +func (a pluginActivePluginsAdapter) ActivePlugins() []delivery.PluginRecord { + records := a.svc.Registry().List() + out := make([]delivery.PluginRecord, 0, len(records)) + for _, rec := range records { + if rec.Status != pluginstore.StatusActive && rec.Status != pluginstore.StatusError { + continue + } + out = append(out, delivery.PluginRecord{ + ID: rec.ID, + EventSubjects: rec.Capabilities.Events, + Status: rec.Status, + }) + } + return out +} + +// startPluginsSubsystems attaches event delivery to svc and spawns every +// already-active, runtime-managed plugin (resuming plugins that were active +// before a backend restart), registering shutdown via addCleanup. Mirrors +// how the Jira/Linear/Sentry pollers are started in +// startAgentInfrastructure: construction happens in initPluginsService +// (services.go), lifecycle start happens here once ctx/addCleanup exist. +func startPluginsSubsystems(ctx context.Context, svc *plugins.Service, eventBus bus.EventBus, log *logger.Logger, addCleanup func(func() error)) { + transport := plugins.NewRuntimeTransport(svc.Runtime()) + deliverer := delivery.New(eventBus, transport, pluginActivePluginsAdapter{svc: svc}, log) + svc.SetDeliverer(deliverer) + deliverer.Refresh() + addCleanup(func() error { deliverer.Stop(); return nil }) + addCleanup(func() error { svc.Shutdown(); return nil }) + + svc.StartActivePlugins(ctx) + + log.Info("Plugins subsystems started (event delivery + active plugin spawn)") +} diff --git a/apps/backend/internal/backendapp/plugins_test.go b/apps/backend/internal/backendapp/plugins_test.go new file mode 100644 index 0000000000..ed99acf896 --- /dev/null +++ b/apps/backend/internal/backendapp/plugins_test.go @@ -0,0 +1,198 @@ +package backendapp + +import ( + "bytes" + "context" + "fmt" + goruntime "runtime" + "testing" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/plugins" + "github.com/kandev/kandev/internal/plugins/delivery" + "github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest" + "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +func testPluginsLogger(t *testing.T) *logger.Logger { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) + if err != nil { + t.Fatalf("new logger: %v", err) + } + return log +} + +// alwaysUpRuntime is a minimal plugins.PluginRuntime fake that always +// reports a successful spawn without touching a real subprocess, so +// Service.Install/Enable can reach StatusActive in tests that only exercise +// boot-payload/registry/status behavior. +type alwaysUpRuntime struct{} + +func (alwaysUpRuntime) Start(context.Context, *store.Record, func(string) pluginsdk.Host) error { + return nil +} +func (alwaysUpRuntime) Stop(string) {} +func (alwaysUpRuntime) Get(string) (*pluginsdk.RemotePlugin, bool) { return nil, true } +func (alwaysUpRuntime) Ping(string) error { return nil } +func (alwaysUpRuntime) Running(string) bool { return true } +func (alwaysUpRuntime) RestartCount(string) int { return 0 } +func (alwaysUpRuntime) StopAll() {} + +// testPluginPackage builds a valid, runtime-managed plugin tar.gz for the +// current host platform, with a capabilities.events subscription and +// (optionally) a UI bundle — mirroring internal/plugins/service_test.go's +// testPackage (kept separate here since that helper is unexported to that +// package). +func testPluginPackage(t *testing.T, id string, withUIBundle bool) *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 %s +capabilities: + events: ["task.created"] +runtime: + type: binary + executables: + %s: server/plugin +`, id, id, platformKey) + if withUIBundle { + manifestYAML += "ui:\n bundle: \"/ui/bundle.js\"\n styles: [\"/ui/style.css\"]\n" + } + + var buf bytes.Buffer + files := map[string][]byte{ + "manifest.yaml": []byte(manifestYAML), + "server/plugin": []byte("#!/bin/sh\necho fake\n"), + } + if withUIBundle { + files["ui/bundle.js"] = []byte("export default {};") + files["ui/style.css"] = []byte("body{}") + } + if err := pkgtartest.WritePackage(&buf, files); err != nil { + t.Fatalf("WritePackage: %v", err) + } + return &buf +} + +// newTestPluginsService returns a plugins.Service backed by a temp-dir +// FSStore and an always-succeeding fake runtime, mirroring +// internal/plugins' own test helpers (kept separate here since those are +// unexported to that package). Install/Enable reach StatusActive without +// spawning a real subprocess. +func newTestPluginsService(t *testing.T) *plugins.Service { + t.Helper() + dir := t.TempDir() + fsStore := store.NewFSStore(dir) + registry := plugins.NewRegistry() + if err := registry.Load(fsStore); err != nil { + t.Fatalf("load registry: %v", err) + } + svc := plugins.NewService(fsStore, registry, nil, testPluginsLogger(t)) + svc.SetPluginsDir(dir) + svc.SetRuntime(alwaysUpRuntime{}) + return svc +} + +func installTestPluginForBoot(t *testing.T, svc *plugins.Service, id string, withUIBundle bool) *store.Record { + t.Helper() + rec, err := svc.Install(context.Background(), testPluginPackage(t, id, withUIBundle)) + if err != nil { + t.Fatalf("Install(%q): %v", id, err) + } + return rec +} + +func TestBootActivePluginsGatedOnFeatureFlag(t *testing.T) { + svc := newTestPluginsService(t) + installTestPluginForBoot(t, svc, "kandev-plugin-hello", true) + + got := bootActivePlugins(routeParams{ + features: config.FeaturesConfig{Plugins: false}, + services: &Services{Plugins: svc}, + }) + if got != nil { + t.Fatalf("bootActivePlugins() with features.Plugins=false = %v, want nil", got) + } +} + +func TestBootActivePluginsPopulatesFromActiveUIPlugins(t *testing.T) { + svc := newTestPluginsService(t) + + installTestPluginForBoot(t, svc, "kandev-plugin-hello", true) + + // Disabled — must be excluded even though it declares a bundle. + installTestPluginForBoot(t, svc, "kandev-plugin-inactive", true) + if err := svc.Disable("kandev-plugin-inactive"); err != nil { + t.Fatalf("Disable(inactive): %v", err) + } + + got := bootActivePlugins(routeParams{ + features: config.FeaturesConfig{Plugins: true}, + services: &Services{Plugins: svc}, + }) + if len(got) != 1 { + t.Fatalf("bootActivePlugins() len = %d, want 1: %+v", len(got), got) + } + entry := got[0] + if entry.ID != "kandev-plugin-hello" { + t.Fatalf("entry.ID = %q, want %q", entry.ID, "kandev-plugin-hello") + } + if entry.BundleURL != "/api/plugins/kandev-plugin-hello/bundle" { + t.Fatalf("entry.BundleURL = %q, want %q", entry.BundleURL, "/api/plugins/kandev-plugin-hello/bundle") + } + if len(entry.StyleURLs) != 1 || entry.StyleURLs[0] != "/api/plugins/kandev-plugin-hello/ui/ui/style.css" { + t.Fatalf("entry.StyleURLs = %v, want [/api/plugins/kandev-plugin-hello/ui/ui/style.css]", entry.StyleURLs) + } +} + +func TestBootActivePluginsNoServiceReturnsNil(t *testing.T) { + got := bootActivePlugins(routeParams{features: config.FeaturesConfig{Plugins: true}, services: &Services{}}) + if got != nil { + t.Fatalf("bootActivePlugins() with nil Plugins service = %v, want nil", got) + } +} + +// --- pluginActivePluginsAdapter --- + +func TestPluginActivePluginsAdapterIncludesActiveAndErrorOnly(t *testing.T) { + svc := newTestPluginsService(t) + + installTestPluginForBoot(t, svc, "kandev-plugin-active", false) // active after install + + installTestPluginForBoot(t, svc, "kandev-plugin-error", false) + if err := svc.SetStatus("kandev-plugin-error", plugins.StatusError); err != nil { + t.Fatalf("SetStatus(error): %v", err) + } + + installTestPluginForBoot(t, svc, "kandev-plugin-disabled", false) + if err := svc.Disable("kandev-plugin-disabled"); err != nil { + t.Fatalf("Disable(disabled): %v", err) + } + + adapter := pluginActivePluginsAdapter{svc: svc} + records := adapter.ActivePlugins() + + byID := make(map[string]delivery.PluginRecord, len(records)) + for _, rec := range records { + byID[rec.ID] = rec + } + if len(byID) != 2 { + t.Fatalf("ActivePlugins() len = %d, want 2: %+v", len(byID), records) + } + if _, ok := byID["kandev-plugin-disabled"]; ok { + t.Fatal("ActivePlugins() must not include a StatusDisabled plugin") + } + rec, ok := byID["kandev-plugin-active"] + if !ok { + t.Fatal("ActivePlugins() missing kandev-plugin-active") + } + if len(rec.EventSubjects) != 1 || rec.EventSubjects[0] != "task.created" { + t.Fatalf("ActivePlugins() record = %+v, want EventSubjects=[task.created]", rec) + } +} diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index fed94744df..b947f6ff8c 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -24,6 +24,7 @@ import ( "github.com/kandev/kandev/internal/integrations/secretadapter" "github.com/kandev/kandev/internal/jira" "github.com/kandev/kandev/internal/linear" + "github.com/kandev/kandev/internal/plugins" promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/sentry" @@ -108,6 +109,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories sentrySvc := initSentryService(dbPool, eventBus, repos.Secrets, log) slackSvc := initSlackService(dbPool, repos.Secrets, log) workflowSyncSvc := initWorkflowSyncService(dbPool, githubSvc, workflowSvc, taskSvc, log) + pluginsSvc := initPluginsService(cfg, dbPool, eventBus, repos.Secrets, log) shareHTTP := initShareHandlers(dbPool, repos.Task, githubSvc, log, version) // Plumb GitHub branch listing into the task service so provider-backed @@ -144,6 +146,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories WorkflowSync: workflowSyncSvc, Share: shareHTTP, Automation: automationComponents, + Plugins: pluginsSvc, // Office is constructed later in initOfficeServices once all // of its dependencies (config loader, task integrations, etc.) are available. Office: nil, @@ -454,6 +457,24 @@ func initSlackService(dbPool *db.Pool, secretsStore secrets.SecretStore, log *lo return svc } +// initPluginsService wires up the plugin system's core Service +// (registration registry, config, plugin_state store). Failures are +// non-fatal: the rest of the backend still boots without plugins. +// +// This only constructs the Service — event delivery (delivery.Deliverer) +// and health monitoring (plugins.HealthMonitor) are wired separately by +// startPluginsSubsystems (plugins.go), once addCleanup and ctx are +// available, mirroring how the Jira/Linear/Sentry pollers are started in +// startAgentInfrastructure rather than inside their init*Service functions. +func initPluginsService(cfg *config.Config, dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *plugins.Service { + svc, _, err := plugins.Provide(cfg, dbPool, secretadapter.New(secretsStore), eventBus, log) + if err != nil { + log.Warn("Plugins service initialization failed (non-fatal)", zap.Error(err)) + return nil + } + return svc +} + // buildKandevMCPURL is the URL passed to the Slack triage agent for the // Kandev MCP server. The MCP server is mounted on the same port as the rest // of the backend's HTTP API; this just centralises the path so it stays in diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index 5a646c9b67..a00965d4ab 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -15,6 +15,7 @@ import ( office "github.com/kandev/kandev/internal/office" officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" officeservice "github.com/kandev/kandev/internal/office/service" + "github.com/kandev/kandev/internal/plugins" promptservice "github.com/kandev/kandev/internal/prompts/service" promptstore "github.com/kandev/kandev/internal/prompts/store" "github.com/kandev/kandev/internal/runtimeflags" @@ -88,4 +89,10 @@ type Services struct { // events, webhooks). Independent of Office — has its own scheduler and // creates tasks via the task service. Automation *automation.Components + // Plugins is the extensible plugin system service (registration + // registry, event delivery, health monitoring). Gated on + // features.Plugins for route registration and boot-payload population, + // but always constructed (non-nil) when initialization succeeds so + // tests/tools can exercise it directly. + Plugins *plugins.Service } diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 75f17c43c1..50f69737bc 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -175,6 +175,11 @@ type FeaturesConfig struct { // Office gates the autonomous-agent feature: backend service construction, // HTTP/WS route registration, and frontend nav/route visibility. Office bool `mapstructure:"office" json:"office"` + + // Plugins gates the extensible plugin system: backend service + // construction, HTTP/WS route registration, and frontend nav/route + // visibility. + Plugins bool `mapstructure:"plugins" json:"plugins"` } // LoggingConfig holds logging configuration. diff --git a/apps/backend/internal/common/config/config_test.go b/apps/backend/internal/common/config/config_test.go index bf38b4884b..d1c41263c8 100644 --- a/apps/backend/internal/common/config/config_test.go +++ b/apps/backend/internal/common/config/config_test.go @@ -156,6 +156,7 @@ func TestFeatures_DefaultOff(t *testing.T) { // DetectEnvironment returns prod, so FeatureFlagDefaults uses the // prod value ("false") rather than the dev value ("true"). t.Setenv("KANDEV_FEATURES_OFFICE", "") + t.Setenv("KANDEV_FEATURES_PLUGINS", "") t.Setenv("KANDEV_DEBUG_DEV_MODE", "") t.Setenv("KANDEV_DEBUG_PPROF_ENABLED", "") t.Setenv("KANDEV_E2E_MOCK", "") @@ -168,6 +169,9 @@ func TestFeatures_DefaultOff(t *testing.T) { if cfg.Features.Office { t.Errorf("Features.Office = true, want false (production default must be off)") } + if cfg.Features.Plugins { + t.Errorf("Features.Plugins = true, want false (production default must be off)") + } } // TestFeatures_OfficeEnabledByEnv proves the documented opt-in path: @@ -187,6 +191,21 @@ func TestFeatures_OfficeEnabledByEnv(t *testing.T) { } } +// TestFeatures_PluginsEnabledByEnv proves the documented opt-in path: +// setting KANDEV_FEATURES_PLUGINS=true flips Features.Plugins to true. +func TestFeatures_PluginsEnabledByEnv(t *testing.T) { + t.Setenv("KANDEV_FEATURES_PLUGINS", "true") + + dir := t.TempDir() + cfg, err := LoadWithPath(dir) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + if !cfg.Features.Plugins { + t.Errorf("Features.Plugins = false, want true (KANDEV_FEATURES_PLUGINS=true must flip the flag)") + } +} + func TestServerHostFromEnv(t *testing.T) { t.Setenv("KANDEV_SERVER_HOST", "127.0.0.1") @@ -206,13 +225,13 @@ func TestServerHostFromEnv(t *testing.T) { // without a tag) would surface as a capitalized JSON key and break the // frontend's case-sensitive read in apps/web/app/actions/features.ts. func TestFeaturesConfig_JSONShape(t *testing.T) { - cfg := FeaturesConfig{Office: true} + cfg := FeaturesConfig{Office: true, Plugins: true} raw, err := json.Marshal(cfg) if err != nil { t.Fatalf("json.Marshal: %v", err) } got := string(raw) - want := `{"office":true}` + want := `{"office":true,"plugins":true}` if got != want { t.Errorf("FeaturesConfig JSON = %s; want %s — missing or wrong `json:` struct tag", got, want) } diff --git a/apps/backend/internal/plugins/delivery/deliverer.go b/apps/backend/internal/plugins/delivery/deliverer.go new file mode 100644 index 0000000000..79fa6b93b1 --- /dev/null +++ b/apps/backend/internal/plugins/delivery/deliverer.go @@ -0,0 +1,534 @@ +// Package delivery implements event delivery for installed plugins, per +// docs/plans/plugins/GRPC-CONTRACT.md §5 ("Delivery / tools / webhooks +// semantics"): subscribing each plugin to its declared event subjects on +// the bus, delivering them over the plugin's live gRPC subprocess +// connection (via the injected Transport), retrying with backoff, and +// buffering events for plugins that are in the error state. +// +// This package deliberately does not import internal/plugins (the root +// package): internal/plugins.Service.SetDeliverer takes a small Deliverer +// interface (Refresh/Flush) defined on the consumer side specifically so +// this package can satisfy it structurally without creating an import +// cycle (internal/plugins already needs to reference the Deliverer type +// before this package exists, per its "Extension points" doc comment). All +// of this package's dependencies on plugin registration/transport data are +// expressed as small local interfaces (Transport, PluginLister) instead — +// internal/plugins.RuntimeTransport and backendapp's PluginLister adapter +// satisfy them structurally. +package delivery + +import ( + "context" + "sync" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// Default tuning values, per docs/plans/plugins/GRPC-CONTRACT.md §5: 10s +// per-attempt timeout, 3 retries backed off 5s/15s/45s, and a +// 100-event/5-minute-TTL ring buffer while a plugin is in the error state. +// Tests override these via Option so retry/backoff loops don't rely on real +// sleeps. +const ( + DefaultRequestTimeout = 10 * time.Second + DefaultQueueSize = 100 + DefaultRingBufferCap = 100 + DefaultRingBufferTTL = 5 * time.Minute +) + +// DefaultRetryDelays is the backoff schedule between delivery attempts +// after the first: 5s, 15s, 45s (3 retries, per spec). +func DefaultRetryDelays() []time.Duration { + return []time.Duration{5 * time.Second, 15 * time.Second, 45 * time.Second} +} + +// Transport delivers a single event to a plugin's live subprocess. +// Satisfied by *internal/plugins.RuntimeTransport in production (a thin +// proxy over runtime.Manager.Get + RemotePlugin.DeliverEvent); tests use a +// fake. +type Transport interface { + DeliverEvent(ctx context.Context, pluginID string, e *pluginsdk.Event) error +} + +// PluginRecord is the subset of a plugin installation Deliverer needs: +// identity, the event subjects it subscribes to (each a literal subject or +// a manifest.MatchSubject wildcard pattern such as "task.*"), and its +// current lifecycle status. +type PluginRecord struct { + ID string + EventSubjects []string + Status string +} + +// PluginLister returns the plugins Deliverer should track. Implementations +// should return every plugin whose status means it should keep receiving +// (store.StatusActive) or buffering (store.StatusError) deliveries; +// disabled/registered/uninstalled plugins should be omitted; Deliverer +// tears down their subscription and worker on the next Refresh. +type PluginLister interface { + ActivePlugins() []PluginRecord +} + +// Option configures a Deliverer at construction time. +type Option func(*Deliverer) + +// WithRequestTimeout overrides the per-attempt delivery timeout (default +// DefaultRequestTimeout). +func WithRequestTimeout(t time.Duration) Option { + return func(d *Deliverer) { d.requestTimeout = t } +} + +// WithRetryDelays overrides the backoff schedule between attempts (default +// DefaultRetryDelays). len(delays) is the number of retries after the +// first attempt; pass an empty/nil slice for a single attempt with no +// retries, or all-zero durations to retry immediately (tests use this to +// avoid real sleeps). +func WithRetryDelays(delays []time.Duration) Option { + return func(d *Deliverer) { d.retryDelays = delays } +} + +// WithQueueSize overrides the bounded per-plugin delivery queue size +// (default DefaultQueueSize). Events are dropped (and logged) when a +// plugin's queue is full, so delivery never blocks the event bus. +func WithQueueSize(n int) Option { + return func(d *Deliverer) { d.queueSize = n } +} + +// WithRingBuffer overrides the per-plugin error-state buffer's capacity +// and TTL (default DefaultRingBufferCap / DefaultRingBufferTTL). +func WithRingBuffer(capacity int, ttl time.Duration) Option { + return func(d *Deliverer) { d.ringBufferCap = capacity; d.ringBufferTTL = ttl } +} + +// WithNow overrides the clock used for event occurred_at timestamps and +// ring buffer TTL bookkeeping (default time.Now). Tests inject a fake +// clock for deterministic assertions. +func WithNow(fn func() time.Time) Option { + return func(d *Deliverer) { d.nowFn = fn } +} + +// withOnProcessed is an unexported Option (only constructible from within +// this package) used by tests to observe exactly when a worker finishes +// handling each queued Delivery, so a test can wait for "all N events have +// been buffered/delivered" instead of racing a status flip against the +// worker goroutine with a sleep. +func withOnProcessed(fn func(pluginID string, d Delivery)) Option { + return func(d *Deliverer) { d.onProcessed = fn } +} + +// Deliverer subscribes each tracked plugin to the union of its declared +// event subjects on the bus and delivers events over Transport: one worker +// goroutine per plugin, sequential delivery, a bounded queue so event +// publishing is never blocked, retries with backoff, and an error-state +// ring buffer. It satisfies internal/plugins.Deliverer (Refresh/Flush) +// structurally — see the package doc comment. +type Deliverer struct { + eventBus bus.EventBus + transport Transport + lister PluginLister + log *logger.Logger + + requestTimeout time.Duration + retryDelays []time.Duration + queueSize int + ringBufferCap int + ringBufferTTL time.Duration + nowFn func() time.Time + + mu sync.Mutex + workers map[string]*pluginWorker + + // onProcessed is an unexported test seam: when set, it is invoked once + // a worker finishes handling a queued Delivery (buffered or delivered/ + // gave-up), letting tests wait deterministically for a worker to drain + // its queue instead of racing Refresh/Flush against the worker + // goroutine. Not part of any public Option — set via withOnProcessed + // from within this package's own tests only. + onProcessed func(pluginID string, d Delivery) +} + +// New constructs a Deliverer. Call Refresh once after construction (and +// again after every install/status change) to establish bus subscriptions +// from the current plugin set. +func New(eventBus bus.EventBus, transport Transport, lister PluginLister, log *logger.Logger, opts ...Option) *Deliverer { + d := &Deliverer{ + eventBus: eventBus, + transport: transport, + lister: lister, + log: log, + requestTimeout: DefaultRequestTimeout, + retryDelays: DefaultRetryDelays(), + queueSize: DefaultQueueSize, + ringBufferCap: DefaultRingBufferCap, + ringBufferTTL: DefaultRingBufferTTL, + nowFn: time.Now, + workers: make(map[string]*pluginWorker), + } + for _, opt := range opts { + opt(d) + } + return d +} + +// Refresh re-reads the active plugin set from PluginLister and reconciles +// bus subscriptions and workers: starts a worker + subscriptions for newly +// tracked plugins, updates cached status for existing ones (so an +// error<->active transition is picked up without recreating the worker or +// its buffer), and tears down workers/subscriptions for plugins no longer +// returned (disabled, uninstalled). Called by Service after Install, +// Enable, Disable, and any successful SetStatus transition — including from +// Service.handleStatusChange, invoked directly on runtime.Manager's +// supervision-loop goroutine, whose contract (see runtime.NewManager) +// requires the callback never block. Tearing down a removed worker calls +// w.stop() (which waits for its run loop to drain, including any in-flight +// delivery attempt — see pluginWorker's per-worker cancelable context) OUTSIDE +// d.mu, so a slow stop() never blocks a concurrent Refresh/Flush for a +// different plugin id that only needs the lock briefly. +func (d *Deliverer) Refresh() { + records := d.lister.ActivePlugins() + seen := make(map[string]bool, len(records)) + + d.mu.Lock() + for _, rec := range records { + seen[rec.ID] = true + w, ok := d.workers[rec.ID] + if !ok { + w = d.newWorker(rec.ID) + d.workers[rec.ID] = w + w.start() + } + w.updateRecord(rec) + d.resubscribe(w, rec) + } + + var toStop []*pluginWorker + for id, w := range d.workers { + if seen[id] { + continue + } + w.unsubscribeAll() + delete(d.workers, id) + toStop = append(toStop, w) + } + d.mu.Unlock() + + for _, w := range toStop { + w.stop() + } +} + +// Flush delivers every event buffered for pluginID (while it was in the +// error state) in order, then lets normal queue processing continue. +// Called by Service after an error -> active recovery transition. A no-op +// if pluginID has no tracked worker (e.g. it was uninstalled before +// recovery). +func (d *Deliverer) Flush(pluginID string) { + d.mu.Lock() + w, ok := d.workers[pluginID] + d.mu.Unlock() + if !ok { + return + } + for _, item := range w.buffer.Drain() { + w.enqueue(item) + } +} + +// Stop tears down every tracked worker and its bus subscriptions. Not part +// of the internal/plugins.Deliverer interface (Service never calls it); +// intended for graceful process shutdown by whatever wires this Deliverer. +func (d *Deliverer) Stop() { + d.mu.Lock() + workers := make([]*pluginWorker, 0, len(d.workers)) + for _, w := range d.workers { + workers = append(workers, w) + } + d.workers = make(map[string]*pluginWorker) + d.mu.Unlock() + + for _, w := range workers { + w.unsubscribeAll() + w.stop() + } +} + +// newWorker builds (but does not start) a worker for pluginID, wired with +// this Deliverer's current tuning options. +func (d *Deliverer) newWorker(id string) *pluginWorker { + deliverCtx, cancelDeliver := context.WithCancel(context.Background()) + return &pluginWorker{ + id: id, + deps: &workerDeps{ + transport: d.transport, + requestTimeout: d.requestTimeout, + retryDelays: d.retryDelays, + log: d.log, + onProcessed: d.onProcessed, + }, + queue: make(chan Delivery, d.queueSize), + buffer: newRingBuffer(d.ringBufferCap, d.ringBufferTTL, d.nowFn), + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + deliverCtx: deliverCtx, + cancelDeliver: cancelDeliver, + } +} + +// resubscribe replaces w's bus subscriptions with one per unique event +// subject pattern in rec.EventSubjects. +func (d *Deliverer) resubscribe(w *pluginWorker, rec PluginRecord) { + w.unsubscribeAll() + + seenPatterns := make(map[string]bool, len(rec.EventSubjects)) + for _, pattern := range rec.EventSubjects { + if pattern == "" || seenPatterns[pattern] { + continue + } + seenPatterns[pattern] = true + + sub, err := d.eventBus.Subscribe(pattern, d.makeHandler(w, pattern)) + if err != nil { + d.log.Error("plugin delivery: failed to subscribe to event subject", + zap.String("plugin_id", w.id), zap.String("subject", pattern), zap.Error(err)) + continue + } + w.addSub(sub) + } +} + +// makeHandler builds the bus.EventHandler that turns a matching event into +// a Delivery and enqueues it on w. pattern is re-checked against the +// concrete event type via manifest.MatchSubject — the same matcher plugin +// capabilities use elsewhere — so delivery is governed by one wildcard +// semantics regardless of the underlying EventBus's own subscription +// matching behavior. +func (d *Deliverer) makeHandler(w *pluginWorker, pattern string) bus.EventHandler { + return func(_ context.Context, event *bus.Event) error { + if !manifest.MatchSubject(pattern, event.Type) { + return nil + } + + payload, err := dataToMap(event.Data) + if err != nil { + d.log.Error("plugin delivery: failed to convert event payload", + zap.String("plugin_id", w.id), zap.String("event_type", event.Type), zap.Error(err)) + return nil + } + + w.enqueue(Delivery{ + PluginID: w.id, + Event: &pluginsdk.Event{ + EventID: uuid.New().String(), + EventType: event.Type, + OccurredAt: d.nowFn().UTC().Format(time.RFC3339), + WorkspaceID: workspaceIDFromData(event.Data), + Payload: payload, + }, + }) + return nil + } +} + +// workerDeps are the dependencies a pluginWorker needs to attempt +// delivery, factored out of Deliverer so pluginWorker doesn't hold a back +// reference to it. +type workerDeps struct { + transport Transport + requestTimeout time.Duration + retryDelays []time.Duration + log *logger.Logger + onProcessed func(pluginID string, d Delivery) +} + +// pluginWorker owns sequential delivery for one plugin: a bounded queue +// drained by a single goroutine (run), the bus subscriptions feeding that +// queue, and the error-state ring buffer. Lifecycle is owned by Deliverer +// (start/stop), per the goroutine-ownership convention in +// apps/backend/AGENTS.md. +type pluginWorker struct { + id string + deps *workerDeps + queue chan Delivery + buffer *ringBuffer + + stopCh chan struct{} + doneCh chan struct{} + + // deliverCtx is the parent context every delivery attempt's per-request + // timeout (attemptDeliver) is derived from, canceled by stop() via + // cancelDeliver. Without this, an in-flight attempt is bounded only by + // its own requestTimeout (default 10s, operator-configurable higher), + // so stop() — and therefore Refresh(), which calls stop() on a removed + // worker — could block for that entire duration instead of returning as + // soon as the worker is asked to stop. + deliverCtx context.Context + cancelDeliver context.CancelFunc + + mu sync.Mutex + subs []bus.Subscription + status string +} + +// start launches the worker's run loop. Must be called at most once. +func (w *pluginWorker) start() { + go w.run() +} + +// stop cancels any in-flight delivery attempt, signals the run loop to +// exit, and waits for it to drain. Idempotent only if called once +// (Deliverer never stops the same worker twice). +func (w *pluginWorker) stop() { + w.cancelDeliver() + close(w.stopCh) + <-w.doneCh +} + +// updateRecord applies the latest known status for the plugin this worker +// delivers to. +func (w *pluginWorker) updateRecord(rec PluginRecord) { + w.mu.Lock() + defer w.mu.Unlock() + w.status = rec.Status +} + +// snapshotStatus returns the worker's current known status. +func (w *pluginWorker) snapshotStatus() string { + w.mu.Lock() + defer w.mu.Unlock() + return w.status +} + +// addSub records a bus subscription as belonging to this worker, so +// unsubscribeAll can tear it down later. +func (w *pluginWorker) addSub(sub bus.Subscription) { + w.mu.Lock() + w.subs = append(w.subs, sub) + w.mu.Unlock() +} + +// unsubscribeAll cancels every bus subscription currently owned by this +// worker. +func (w *pluginWorker) unsubscribeAll() { + w.mu.Lock() + subs := w.subs + w.subs = nil + w.mu.Unlock() + + for _, sub := range subs { + if sub != nil && sub.IsValid() { + _ = sub.Unsubscribe() + } + } +} + +// enqueue pushes d onto the worker's queue without blocking: if the queue +// is full, the event is dropped and logged rather than backing up the bus +// publisher. +func (w *pluginWorker) enqueue(d Delivery) { + select { + case w.queue <- d: + default: + w.deps.log.Warn("plugin delivery queue full, dropping event", + zap.String("plugin_id", w.id), zap.String("event_type", d.Event.EventType)) + } +} + +// run drains the queue sequentially until stopCh fires. +func (w *pluginWorker) run() { + defer close(w.doneCh) + for { + select { + case <-w.stopCh: + return + case d, ok := <-w.queue: + if !ok { + return + } + w.process(d) + } + } +} + +// process buffers d (if the plugin is currently known to be in the error +// state) or attempts delivery with retries, then notifies the onProcessed +// test seam (if any) that this Delivery has been fully handled. +func (w *pluginWorker) process(d Delivery) { + if w.snapshotStatus() == store.StatusError { + if dropped := w.buffer.Add(d); dropped != "" { + w.deps.log.Warn("plugin event ring buffer overflow, dropped oldest buffered event", + zap.String("plugin_id", w.id), zap.String("dropped_delivery_id", dropped)) + } + } else { + w.deliverWithRetry(w.deliverCtx, d) + } + if w.deps.onProcessed != nil { + w.deps.onProcessed(w.id, d) + } +} + +// deliverWithRetry attempts d up to 1+len(retryDelays) times, sleeping +// (interruptibly) between attempts per the configured backoff schedule. +// Logs and gives up (event is lost from the live-delivery path; a +// subsequent health-poller error transition plus Flush is the recovery +// path for further events, per the at-least-once/error-buffer semantics in +// docs/specs/plugins/spec.md) if every attempt fails. +func (w *pluginWorker) deliverWithRetry(ctx context.Context, d Delivery) { + var lastErr error + attempts := 1 + len(w.deps.retryDelays) + for attempt := 0; attempt < attempts; attempt++ { + if attempt > 0 { + if !w.sleepOrStop(w.deps.retryDelays[attempt-1]) { + return + } + } + if err := w.attemptDeliver(ctx, d); err != nil { + lastErr = err + continue + } + return + } + w.deps.log.Warn("plugin event delivery failed after retries", + zap.String("plugin_id", w.id), + zap.String("delivery_id", d.Event.EventID), + zap.String("event_type", d.Event.EventType), + zap.Int("attempts", attempts), + zap.Error(lastErr)) +} + +// sleepOrStop blocks for d or until the worker begins stopping. Returns +// false if stop won the race, per the select-based backoff convention in +// apps/backend/AGENTS.md ("never time.Sleep in a retry/backoff loop"). +func (w *pluginWorker) sleepOrStop(d time.Duration) bool { + if d <= 0 { + select { + case <-w.stopCh: + return false + default: + return true + } + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return true + case <-w.stopCh: + return false + } +} + +// attemptDeliver makes one delivery attempt for d over Transport, bounded +// by requestTimeout. +func (w *pluginWorker) attemptDeliver(ctx context.Context, d Delivery) error { + reqCtx, cancel := context.WithTimeout(ctx, w.deps.requestTimeout) + defer cancel() + return w.deps.transport.DeliverEvent(reqCtx, w.id, d.Event) +} diff --git a/apps/backend/internal/plugins/delivery/deliverer_test.go b/apps/backend/internal/plugins/delivery/deliverer_test.go new file mode 100644 index 0000000000..d68cbe5bd5 --- /dev/null +++ b/apps/backend/internal/plugins/delivery/deliverer_test.go @@ -0,0 +1,443 @@ +package delivery + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// fakeTransport is a controllable Transport for tests: DeliverEvent calls +// through to an injectable handler (defaulting to a no-op success), so +// tests can assert on what was delivered and simulate failures/blocking +// without a real network round trip. +type fakeTransport struct { + mu sync.Mutex + handler func(pluginID string, e *pluginsdk.Event) error + // ctxHandler, when set, takes priority over handler and additionally + // receives DeliverEvent's ctx — for tests that need to observe/react to + // ctx cancellation (e.g. proving an in-flight attempt is interrupted by + // worker stop()). + ctxHandler func(ctx context.Context, pluginID string, e *pluginsdk.Event) error +} + +func (f *fakeTransport) DeliverEvent(ctx context.Context, pluginID string, e *pluginsdk.Event) error { + f.mu.Lock() + ch := f.ctxHandler + h := f.handler + f.mu.Unlock() + if ch != nil { + return ch(ctx, pluginID, e) + } + if h == nil { + return nil + } + return h(pluginID, e) +} + +func (f *fakeTransport) setHandler(h func(pluginID string, e *pluginsdk.Event) error) { + f.mu.Lock() + defer f.mu.Unlock() + f.handler = h +} + +func (f *fakeTransport) setCtxHandler(h func(ctx context.Context, pluginID string, e *pluginsdk.Event) error) { + f.mu.Lock() + defer f.mu.Unlock() + f.ctxHandler = h +} + +// fakeLister is a mutable, concurrency-safe PluginLister for tests. +type fakeLister struct { + mu sync.Mutex + records []PluginRecord +} + +func (f *fakeLister) ActivePlugins() []PluginRecord { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]PluginRecord, len(f.records)) + copy(out, f.records) + return out +} + +func (f *fakeLister) set(records ...PluginRecord) { + f.mu.Lock() + defer f.mu.Unlock() + f.records = records +} + +// newTestDeliverer wires a Deliverer with zero retry backoff by default so +// tests never rely on real sleeps; individual tests override retry delays +// when they specifically exercise backoff/retry behavior. +func newTestDeliverer(t *testing.T, eventBus bus.EventBus, transport Transport, lister PluginLister, opts ...Option) *Deliverer { + t.Helper() + base := []Option{WithRetryDelays(nil)} + d := New(eventBus, transport, lister, logger.Default(), append(base, opts...)...) + t.Cleanup(d.Stop) + return d +} + +func requireNoTimeout[T any](t *testing.T, ch <-chan T, timeout time.Duration, what string) T { + t.Helper() + select { + case v := <-ch: + return v + case <-time.After(timeout): + t.Fatalf("timed out waiting for %s", what) + var zero T + return zero + } +} + +func requireTimeout[T any](t *testing.T, ch <-chan T, window time.Duration, what string) { + t.Helper() + select { + case <-ch: + t.Fatalf("unexpected %s", what) + case <-time.After(window): + } +} + +func TestDeliverer_DeliversOnMatchingEvent(t *testing.T) { + receivedCh := make(chan *pluginsdk.Event, 1) + transport := &fakeTransport{} + transport.setHandler(func(pluginID string, e *pluginsdk.Event) error { + if pluginID != "plug1" { + t.Errorf("DeliverEvent pluginID = %q, want plug1", pluginID) + } + receivedCh <- e + return nil + }) + + eventBus := bus.NewMemoryEventBus(logger.Default()) + lister := &fakeLister{} + lister.set(PluginRecord{ID: "plug1", EventSubjects: []string{"task.*"}, Status: store.StatusActive}) + + d := newTestDeliverer(t, eventBus, transport, lister) + d.Refresh() + + ev := bus.NewEvent("task.state_changed", "test", map[string]interface{}{"task_id": "abc"}) + if err := eventBus.Publish(context.Background(), "task.state_changed", ev); err != nil { + t.Fatalf("Publish: %v", err) + } + + got := requireNoTimeout(t, receivedCh, 2*time.Second, "event delivery") + if got.EventType != "task.state_changed" { + t.Errorf("EventType = %q, want task.state_changed", got.EventType) + } + if got.EventID == "" { + t.Error("EventID must not be empty") + } + if got.Payload["task_id"] != "abc" { + t.Errorf("Payload[task_id] = %v, want abc", got.Payload["task_id"]) + } +} + +func TestDeliverer_NonMatchingEventIsNotDelivered(t *testing.T) { + receivedCh := make(chan struct{}, 1) + transport := &fakeTransport{} + transport.setHandler(func(string, *pluginsdk.Event) error { + receivedCh <- struct{}{} + return nil + }) + + eventBus := bus.NewMemoryEventBus(logger.Default()) + lister := &fakeLister{} + lister.set(PluginRecord{ID: "plug1", EventSubjects: []string{"task.*"}, Status: store.StatusActive}) + + d := newTestDeliverer(t, eventBus, transport, lister) + d.Refresh() + + ev := bus.NewEvent("office.comment.created", "test", map[string]interface{}{}) + if err := eventBus.Publish(context.Background(), "office.comment.created", ev); err != nil { + t.Fatalf("Publish: %v", err) + } + + requireTimeout(t, receivedCh, 150*time.Millisecond, "delivery for non-subscribed subject") +} + +func TestDeliverer_SequentialPerPlugin(t *testing.T) { + var inFlight int32 + release := make(chan struct{}) + arrivedCh := make(chan string, 10) + + transport := &fakeTransport{} + transport.setHandler(func(_ string, e *pluginsdk.Event) error { + if atomic.AddInt32(&inFlight, 1) > 1 { + t.Errorf("more than one concurrent delivery in flight for the same plugin") + } + arrivedCh <- e.EventID + <-release + atomic.AddInt32(&inFlight, -1) + return nil + }) + + eventBus := bus.NewMemoryEventBus(logger.Default()) + lister := &fakeLister{} + lister.set(PluginRecord{ID: "plug1", EventSubjects: []string{"task.*"}, Status: store.StatusActive}) + + d := newTestDeliverer(t, eventBus, transport, lister) + d.Refresh() + + ctx := context.Background() + _ = eventBus.Publish(ctx, "task.created", bus.NewEvent("task.created", "test", map[string]interface{}{})) + _ = eventBus.Publish(ctx, "task.updated", bus.NewEvent("task.updated", "test", map[string]interface{}{})) + + first := requireNoTimeout(t, arrivedCh, 2*time.Second, "first delivery") + + // Second delivery must not start while the first is still in flight. + requireTimeout(t, arrivedCh, 150*time.Millisecond, "second delivery starting before first completed") + + release <- struct{}{} + second := requireNoTimeout(t, arrivedCh, 2*time.Second, "second delivery") + release <- struct{}{} + + if first == second { + t.Errorf("expected two distinct delivery ids, got the same twice: %q", first) + } +} + +func TestDeliverer_RetriesOnErrorThenSucceeds(t *testing.T) { + var attempts int32 + arrivedCh := make(chan struct{}, 5) + transport := &fakeTransport{} + transport.setHandler(func(string, *pluginsdk.Event) error { + n := atomic.AddInt32(&attempts, 1) + arrivedCh <- struct{}{} + if n < 3 { + return fmt.Errorf("simulated failure %d", n) + } + return nil + }) + + eventBus := bus.NewMemoryEventBus(logger.Default()) + lister := &fakeLister{} + lister.set(PluginRecord{ID: "plug1", EventSubjects: []string{"task.*"}, Status: store.StatusActive}) + + d := newTestDeliverer(t, eventBus, transport, lister, + WithRetryDelays([]time.Duration{0, 0, 0})) + d.Refresh() + + _ = eventBus.Publish(context.Background(), "task.created", bus.NewEvent("task.created", "test", map[string]interface{}{})) + + for i := 0; i < 3; i++ { + requireNoTimeout(t, arrivedCh, 2*time.Second, fmt.Sprintf("attempt %d", i+1)) + } + if got := atomic.LoadInt32(&attempts); got != 3 { + t.Errorf("attempts = %d, want 3 (2 failures + 1 success)", got) + } +} + +func TestDeliverer_GivesUpAfterAllRetries(t *testing.T) { + var attempts int32 + arrivedCh := make(chan struct{}, 10) + transport := &fakeTransport{} + transport.setHandler(func(string, *pluginsdk.Event) error { + atomic.AddInt32(&attempts, 1) + arrivedCh <- struct{}{} + return fmt.Errorf("simulated permanent failure") + }) + + eventBus := bus.NewMemoryEventBus(logger.Default()) + lister := &fakeLister{} + lister.set(PluginRecord{ID: "plug1", EventSubjects: []string{"task.*"}, Status: store.StatusActive}) + + d := newTestDeliverer(t, eventBus, transport, lister, + WithRetryDelays([]time.Duration{0, 0, 0})) + d.Refresh() + + _ = eventBus.Publish(context.Background(), "task.created", bus.NewEvent("task.created", "test", map[string]interface{}{})) + + for i := 0; i < 4; i++ { + requireNoTimeout(t, arrivedCh, 2*time.Second, fmt.Sprintf("attempt %d", i+1)) + } + requireTimeout(t, arrivedCh, 150*time.Millisecond, "a 5th attempt (should give up after 1+3 retries)") + if got := atomic.LoadInt32(&attempts); got != 4 { + t.Errorf("attempts = %d, want 4 (1 initial + 3 retries)", got) + } +} + +func TestDeliverer_BuffersWhileErrorAndFlushReplaysInOrder(t *testing.T) { + arrivedCh := make(chan string, 10) + transport := &fakeTransport{} + transport.setHandler(func(_ string, e *pluginsdk.Event) error { + arrivedCh <- e.EventType + return nil + }) + + eventBus := bus.NewMemoryEventBus(logger.Default()) + lister := &fakeLister{} + lister.set(PluginRecord{ID: "plug1", EventSubjects: []string{"task.*"}, Status: store.StatusError}) + + processedCh := make(chan struct{}, 10) + d := newTestDeliverer(t, eventBus, transport, lister, + withOnProcessed(func(string, Delivery) { processedCh <- struct{}{} })) + d.Refresh() + + ctx := context.Background() + _ = eventBus.Publish(ctx, "task.created", bus.NewEvent("task.created", "test", map[string]interface{}{})) + _ = eventBus.Publish(ctx, "task.updated", bus.NewEvent("task.updated", "test", map[string]interface{}{})) + + // Wait until the worker has actually buffered both events before + // flipping status and flushing, so this test doesn't race the worker + // goroutine's queue-drain against the recovery transition below. + requireNoTimeout(t, processedCh, 2*time.Second, "first event buffered") + requireNoTimeout(t, processedCh, 2*time.Second, "second event buffered") + + requireTimeout(t, arrivedCh, 150*time.Millisecond, "delivery while plugin is in error state") + + // Recovery: status flips to active (mirrors Service.handleStatusChange + // after the runtime manager reports the plugin healthy again) and + // Refresh/Flush are called, mirroring the + // Service.SetStatus -> Refresh, Service.handleStatusChange -> Flush + // contract. + lister.set(PluginRecord{ID: "plug1", EventSubjects: []string{"task.*"}, Status: store.StatusActive}) + d.Refresh() + d.Flush("plug1") + + first := requireNoTimeout(t, arrivedCh, 2*time.Second, "replayed first buffered event") + second := requireNoTimeout(t, arrivedCh, 2*time.Second, "replayed second buffered event") + + if first != "task.created" || second != "task.updated" { + t.Errorf("replay order = [%s %s], want [task.created task.updated]", first, second) + } +} + +func TestDeliverer_RingBufferOverflowDropsOldest(t *testing.T) { + arrivedCh := make(chan string, 10) + transport := &fakeTransport{} + transport.setHandler(func(_ string, e *pluginsdk.Event) error { + arrivedCh <- e.EventType + return nil + }) + + eventBus := bus.NewMemoryEventBus(logger.Default()) + lister := &fakeLister{} + lister.set(PluginRecord{ID: "plug1", EventSubjects: []string{"task.*"}, Status: store.StatusError}) + + processedCh := make(chan struct{}, 10) + d := newTestDeliverer(t, eventBus, transport, lister, WithRingBuffer(2, 5*time.Minute), + withOnProcessed(func(string, Delivery) { processedCh <- struct{}{} })) + d.Refresh() + + ctx := context.Background() + _ = eventBus.Publish(ctx, "task.created", bus.NewEvent("task.created", "test", map[string]interface{}{})) + _ = eventBus.Publish(ctx, "task.updated", bus.NewEvent("task.updated", "test", map[string]interface{}{})) + _ = eventBus.Publish(ctx, "task.deleted", bus.NewEvent("task.deleted", "test", map[string]interface{}{})) + + // Wait until the worker has buffered all three events (and evicted the + // oldest) before flipping status and flushing. + for i := 0; i < 3; i++ { + requireNoTimeout(t, processedCh, 2*time.Second, fmt.Sprintf("event %d buffered", i+1)) + } + + lister.set(PluginRecord{ID: "plug1", EventSubjects: []string{"task.*"}, Status: store.StatusActive}) + d.Refresh() + d.Flush("plug1") + + first := requireNoTimeout(t, arrivedCh, 2*time.Second, "replayed first surviving buffered event") + second := requireNoTimeout(t, arrivedCh, 2*time.Second, "replayed second surviving buffered event") + if first != "task.updated" || second != "task.deleted" { + t.Errorf("replay order = [%s %s], want [task.updated task.deleted] (task.created should have been dropped as oldest on overflow)", first, second) + } + requireTimeout(t, arrivedCh, 150*time.Millisecond, "a 3rd replayed event") +} + +func TestDeliverer_RefreshStopsDeliveryWhenPluginRemoved(t *testing.T) { + arrivedCh := make(chan struct{}, 5) + transport := &fakeTransport{} + transport.setHandler(func(string, *pluginsdk.Event) error { + arrivedCh <- struct{}{} + return nil + }) + + eventBus := bus.NewMemoryEventBus(logger.Default()) + lister := &fakeLister{} + lister.set(PluginRecord{ID: "plug1", EventSubjects: []string{"task.*"}, Status: store.StatusActive}) + + d := newTestDeliverer(t, eventBus, transport, lister) + d.Refresh() + + _ = eventBus.Publish(context.Background(), "task.created", bus.NewEvent("task.created", "test", map[string]interface{}{})) + requireNoTimeout(t, arrivedCh, 2*time.Second, "delivery while plugin active") + + // Disabled: PluginLister stops returning it (mirrors Service filtering + // out StatusDisabled). + lister.set() + d.Refresh() + + _ = eventBus.Publish(context.Background(), "task.updated", bus.NewEvent("task.updated", "test", map[string]interface{}{})) + requireTimeout(t, arrivedCh, 150*time.Millisecond, "delivery after plugin removed from active set") +} + +// TestDeliverer_RefreshReturnsPromptlyDuringInFlightDelivery pins the fix +// for Refresh blocking on a worker's in-flight delivery attempt: before the +// fix, attemptDeliver's ctx was context.Background() (only bounded by +// requestTimeout, here deliberately set far longer than the test's +// timeout), and stop() waited on that same attempt to finish before +// returning — so Refresh (which calls stop() for a removed worker) could +// block for the full requestTimeout. Service.handleStatusChange calls +// Refresh from the runtime supervision goroutine, which must never block +// (runtime.NewManager's contract). The fix derives attemptDeliver's ctx +// from a per-worker context canceled by stop(), so the in-flight attempt +// is interrupted immediately instead. +func TestDeliverer_RefreshReturnsPromptlyDuringInFlightDelivery(t *testing.T) { + entered := make(chan struct{}) + released := make(chan struct{}) + var sawCancel int32 + + transport := &fakeTransport{} + transport.setCtxHandler(func(ctx context.Context, _ string, _ *pluginsdk.Event) error { + close(entered) + select { + case <-ctx.Done(): + atomic.AddInt32(&sawCancel, 1) + return ctx.Err() + case <-released: + return nil + } + }) + + eventBus := bus.NewMemoryEventBus(logger.Default()) + lister := &fakeLister{} + lister.set(PluginRecord{ID: "plug1", EventSubjects: []string{"task.*"}, Status: store.StatusActive}) + + d := newTestDeliverer(t, eventBus, transport, lister, WithRequestTimeout(30*time.Second)) + d.Refresh() + + _ = eventBus.Publish(context.Background(), "task.created", bus.NewEvent("task.created", "test", map[string]interface{}{})) + select { + case <-entered: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for the delivery attempt to start") + } + + // Remove the plugin so Refresh must tear its worker down while the + // delivery attempt above is still blocked inside the transport call. + lister.set() + + done := make(chan struct{}) + go func() { + d.Refresh() + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Refresh() did not return promptly while a delivery was in flight (requestTimeout was 30s)") + } + + if atomic.LoadInt32(&sawCancel) != 1 { + t.Fatal("in-flight delivery attempt was never canceled by worker stop()") + } + close(released) +} diff --git a/apps/backend/internal/plugins/delivery/goleak_test.go b/apps/backend/internal/plugins/delivery/goleak_test.go new file mode 100644 index 0000000000..59962ab855 --- /dev/null +++ b/apps/backend/internal/plugins/delivery/goleak_test.go @@ -0,0 +1,16 @@ +package delivery + +import ( + "testing" + + "go.uber.org/goleak" +) + +// TestMain asserts that no goroutines from this package outlive the test +// process. Every pluginWorker's run loop is started by Deliverer.Refresh +// and must be stopped via Deliverer.Stop (or Refresh dropping the plugin) — +// goleak catches a regression where a worker is created without a matching +// stop path. +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/apps/backend/internal/plugins/delivery/payload.go b/apps/backend/internal/plugins/delivery/payload.go new file mode 100644 index 0000000000..bae8b23827 --- /dev/null +++ b/apps/backend/internal/plugins/delivery/payload.go @@ -0,0 +1,38 @@ +package delivery + +import ( + "encoding/json" + "fmt" +) + +// dataToMap converts a bus.Event's Data field (arbitrary JSON-serializable +// Go value: struct, map, nil, ...) into the map[string]any shape +// pluginsdk.Event.Payload expects, by round-tripping through JSON. A nil +// data value converts to a nil map (pluginsdk distinguishes "no payload" +// from "empty payload" across the wire). +func dataToMap(data interface{}) (map[string]any, error) { + if data == nil { + return nil, nil + } + raw, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("marshal event data: %w", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("unmarshal event data as map: %w", err) + } + return m, nil +} + +// workspaceIDFromData extracts data["workspace_id"] when data is a +// map[string]interface{} carrying a string value, and returns "" otherwise +// (e.g. for struct-typed event data, or maps without the key). +func workspaceIDFromData(data interface{}) string { + m, ok := data.(map[string]interface{}) + if !ok { + return "" + } + id, _ := m["workspace_id"].(string) + return id +} diff --git a/apps/backend/internal/plugins/delivery/ringbuffer.go b/apps/backend/internal/plugins/delivery/ringbuffer.go new file mode 100644 index 0000000000..5b30633fec --- /dev/null +++ b/apps/backend/internal/plugins/delivery/ringbuffer.go @@ -0,0 +1,103 @@ +package delivery + +import ( + "sync" + "time" + + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// Delivery is one queued/buffered event delivery job for a single plugin. +type Delivery struct { + PluginID string + Event *pluginsdk.Event +} + +// bufferedDelivery pairs a Delivery with the time it was buffered, for TTL +// eviction. +type bufferedDelivery struct { + delivery Delivery + addedAt time.Time +} + +// ringBuffer holds events for one plugin while it is in the error state, so +// Deliverer.Flush can replay them in order after a health-poller recovery +// transition. Capacity and TTL are the numbers in +// docs/specs/plugins/spec.md ("Event webhook delivery"): 100 events / 5 +// minutes. The oldest entry is dropped on overflow; expired entries are +// purged lazily on the next Add, Drain, or Len call. +type ringBuffer struct { + mu sync.Mutex + cap int + ttl time.Duration + items []bufferedDelivery + nowFn func() time.Time +} + +// newRingBuffer returns a ring buffer bounded to capacity entries, each +// evicted after ttl. nowFn defaults to time.Now when nil; tests inject a +// fake clock for deterministic TTL assertions. +func newRingBuffer(capacity int, ttl time.Duration, nowFn func() time.Time) *ringBuffer { + if nowFn == nil { + nowFn = time.Now + } + return &ringBuffer{cap: capacity, ttl: ttl, nowFn: nowFn} +} + +// Add appends d to the buffer, purging expired entries first and then +// evicting the oldest remaining entry if still at capacity. Returns the +// event id of the entry dropped due to overflow, or "" if none was +// dropped. +func (r *ringBuffer) Add(d Delivery) (droppedID string) { + r.mu.Lock() + defer r.mu.Unlock() + + r.purgeExpiredLocked() + + if r.cap > 0 && len(r.items) >= r.cap && len(r.items) > 0 { + droppedID = r.items[0].delivery.Event.EventID + r.items = r.items[1:] + } + r.items = append(r.items, bufferedDelivery{delivery: d, addedAt: r.nowFn()}) + return droppedID +} + +// Drain returns every non-expired buffered delivery in insertion (oldest +// first) order and empties the buffer. +func (r *ringBuffer) Drain() []Delivery { + r.mu.Lock() + defer r.mu.Unlock() + + r.purgeExpiredLocked() + out := make([]Delivery, len(r.items)) + for i, it := range r.items { + out[i] = it.delivery + } + r.items = nil + return out +} + +// Len reports the number of currently buffered (non-expired) deliveries. +func (r *ringBuffer) Len() int { + r.mu.Lock() + defer r.mu.Unlock() + r.purgeExpiredLocked() + return len(r.items) +} + +// purgeExpiredLocked drops entries older than ttl from the front of items +// (insertion order == age order, so this is a prefix trim). Caller must +// hold mu. +func (r *ringBuffer) purgeExpiredLocked() { + if r.ttl <= 0 { + return + } + cutoff := r.nowFn().Add(-r.ttl) + idx := 0 + for idx < len(r.items) && r.items[idx].addedAt.Before(cutoff) { + idx++ + } + if idx > 0 { + r.items = r.items[idx:] + } +} diff --git a/apps/backend/internal/plugins/delivery/ringbuffer_test.go b/apps/backend/internal/plugins/delivery/ringbuffer_test.go new file mode 100644 index 0000000000..bf511423fd --- /dev/null +++ b/apps/backend/internal/plugins/delivery/ringbuffer_test.go @@ -0,0 +1,95 @@ +package delivery + +import ( + "testing" + "time" + + "github.com/kandev/kandev/pkg/pluginsdk" +) + +func delivery(id string) Delivery { + return Delivery{Event: &pluginsdk.Event{EventID: id}} +} + +func TestRingBuffer_DrainReturnsInOrder(t *testing.T) { + rb := newRingBuffer(100, 5*time.Minute, nil) + + rb.Add(delivery("1")) + rb.Add(delivery("2")) + rb.Add(delivery("3")) + + got := rb.Drain() + if len(got) != 3 { + t.Fatalf("Drain() returned %d items, want 3", len(got)) + } + for i, id := range []string{"1", "2", "3"} { + if got[i].Event.EventID != id { + t.Errorf("Drain()[%d].Event.EventID = %q, want %q", i, got[i].Event.EventID, id) + } + } +} + +func TestRingBuffer_DrainEmptiesBuffer(t *testing.T) { + rb := newRingBuffer(100, 5*time.Minute, nil) + rb.Add(delivery("1")) + + _ = rb.Drain() + + if got := rb.Drain(); len(got) != 0 { + t.Errorf("second Drain() returned %d items, want 0", len(got)) + } +} + +func TestRingBuffer_OverflowDropsOldest(t *testing.T) { + rb := newRingBuffer(2, 5*time.Minute, nil) + + rb.Add(delivery("1")) + rb.Add(delivery("2")) + dropped := rb.Add(delivery("3")) + + if dropped != "1" { + t.Errorf("Add() dropped = %q, want 1", dropped) + } + got := rb.Drain() + if len(got) != 2 { + t.Fatalf("Drain() returned %d items, want 2", len(got)) + } + if got[0].Event.EventID != "2" || got[1].Event.EventID != "3" { + t.Errorf("Drain() = %v, want [2 3]", got) + } +} + +func TestRingBuffer_TTLExpiresOldEntries(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + nowFn := func() time.Time { return now } + rb := newRingBuffer(100, 5*time.Minute, nowFn) + + rb.Add(delivery("stale")) + now = now.Add(6 * time.Minute) + rb.Add(delivery("fresh")) + + got := rb.Drain() + if len(got) != 1 { + t.Fatalf("Drain() returned %d items, want 1 (stale entry should have expired)", len(got)) + } + if got[0].Event.EventID != "fresh" { + t.Errorf("Drain()[0].Event.EventID = %q, want fresh", got[0].Event.EventID) + } +} + +func TestRingBuffer_LenReflectsNonExpiredCount(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + nowFn := func() time.Time { return now } + rb := newRingBuffer(100, 5*time.Minute, nowFn) + + rb.Add(delivery("1")) + rb.Add(delivery("2")) + if got := rb.Len(); got != 2 { + t.Fatalf("Len() = %d, want 2", got) + } + + now = now.Add(6 * time.Minute) + if got := rb.Len(); got != 0 { + t.Errorf("Len() after TTL expiry = %d, want 0", got) + } +} diff --git a/apps/backend/internal/plugins/dto.go b/apps/backend/internal/plugins/dto.go new file mode 100644 index 0000000000..51f6e0b975 --- /dev/null +++ b/apps/backend/internal/plugins/dto.go @@ -0,0 +1,74 @@ +package plugins + +import "github.com/kandev/kandev/internal/plugins/store" + +// InstallRequest is the JSON body of POST /api/plugins/install when +// installing from a URL: {"url": "https://.../plugin-1.0.0.tar.gz"}. The +// same endpoint also accepts a multipart/form-data upload with a "package" +// field instead of this body — see Controller.install. +type InstallRequest struct { + URL string `json:"url"` +} + +// InstallResponse is the body of a successful POST /api/plugins/install. +// Warning is set (and the plugin's status left as "error") when the +// package was installed but its initial spawn/handshake failed — the +// operator can inspect Plugin.Status and retry via POST /:id/enable. +type InstallResponse struct { + Plugin *store.Record `json:"plugin"` + Warning string `json:"warning,omitempty"` +} + +// UpdateConfigRequest is the PATCH /api/plugins/:id body: the full +// operator-editable config, replacing whatever was previously stored (see +// store.Store.SetConfig). +type UpdateConfigRequest struct { + Config map[string]any `json:"config"` +} + +// PluginToolDTO is one entry of GET /api/plugins/tools: a declared tool plus +// the id of the plugin that owns it. Only tools from StatusActive plugins +// are listed — per the state machine, disabled/error plugins have their +// tools hidden from the agent tool set. +type PluginToolDTO struct { + PluginID string `json:"plugin_id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + InputSchema map[string]any `json:"input_schema,omitempty"` +} + +// SyncResult is the body of a successful POST /api/plugins/sync (and the +// return value of Service.Sync / Service.bootScan): what the filesystem +// scan under the plugins directory found and did this run, per +// docs/specs/plugins/spec.md ("Filesystem sideloading & sync"). +type SyncResult struct { + // Added lists the plugin ids of directory sideloads + // (///manifest.yaml found with no existing + // {id}.yml record) registered this run. Always registered + // StatusDisabled — sideloads are unverified and are never auto-spawned. + Added []string `json:"added"` + // Installed lists the plugin ids of dropped *.tar.gz packages installed + // (and, on a healthy spawn, activated) via the normal verified install + // pipeline (Service.Install) this run. Each source tarball is deleted + // from the plugins directory once its install succeeds. + Installed []string `json:"installed"` + // Missing lists the plugin ids of existing records whose InstallPath no + // longer exists on disk. Each is stopped (if running) and transitioned + // to StatusError. + Missing []string `json:"missing"` + // Errors lists every per-item failure encountered this run: a rejected + // dir-sideload candidate (invalid/mismatched manifest.yaml, or a + // lower-priority version dir skipped in favor of the lexically greatest + // when more than one is found for the same unregistered id), or a + // dropped tarball that failed pkgtar's verify/validate pipeline and was + // left in place. + Errors []SyncError `json:"errors"` +} + +// SyncError is one entry of SyncResult.Errors: the filesystem path the +// problem was found at, and a human-readable reason. +type SyncError struct { + Path string `json:"path"` + Reason string `json:"reason"` +} diff --git a/apps/backend/internal/plugins/goleak_test.go b/apps/backend/internal/plugins/goleak_test.go new file mode 100644 index 0000000000..c296ee2da5 --- /dev/null +++ b/apps/backend/internal/plugins/goleak_test.go @@ -0,0 +1,15 @@ +package plugins + +import ( + "testing" + + "go.uber.org/goleak" +) + +// TestMain asserts that no goroutines from this package outlive the test +// process. HealthMonitor owns a single ticker loop guarded by Start/Stop — +// goleak catches regressions where a Start path forgets to register on the +// WaitGroup, or a Stop path returns before the loop drains. +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/apps/backend/internal/plugins/handlers.go b/apps/backend/internal/plugins/handlers.go new file mode 100644 index 0000000000..6e3d99dcc6 --- /dev/null +++ b/apps/backend/internal/plugins/handlers.go @@ -0,0 +1,428 @@ +package plugins + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/plugins/pkgtar" + "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// maxWebhookBodyBytes caps the request body the webhook relay +// (POST/GET /api/plugins/:id/webhooks/:key) will read before relaying it to +// a plugin's live subprocess. Without a cap, io.ReadAll(ctx.Request.Body) +// is an unbounded read an external, unauthenticated webhook caller could +// use to exhaust backend memory. 4 MiB comfortably covers realistic webhook +// payloads (GitHub/Slack/Jira event bodies are KB-sized) while bounding +// worst-case memory use per request. +const maxWebhookBodyBytes = 4 << 20 // 4 MiB + +// Controller holds the plugin HTTP handlers: operator-facing management +// (install/list/get/config/uninstall/enable/disable), the tools listing, +// the bundle/UI static-file serving (from the extracted package on disk), +// and the external webhook relay (HTTP -> Host RPC over the live +// subprocess). +type Controller struct { + svc *Service + log *logger.Logger +} + +// RegisterRoutes wires the plugin HTTP surface. deliverer is accepted for +// parity with the backendapp wiring (svc.SetDeliverer(deliverer) happens +// alongside this call) — no handler in this file calls it directly, since +// Service already notifies it on every install/status change. +func RegisterRoutes(router *gin.Engine, svc *Service, _ Deliverer, log *logger.Logger) { + ctrl := &Controller{svc: svc, log: log} + + api := router.Group("/api/plugins") + api.POST("/install", ctrl.install) + api.POST("/sync", ctrl.sync) + api.GET("", ctrl.list) + api.GET("/tools", ctrl.listTools) + api.GET("/:id", ctrl.get) + api.PATCH("/:id", ctrl.updateConfig) + api.DELETE("/:id", ctrl.uninstall) + api.POST("/:id/enable", ctrl.enable) + api.POST("/:id/disable", ctrl.disable) + + api.GET("/:id/bundle", ctrl.bundle) + api.GET("/:id/ui/*path", ctrl.ui) + api.POST("/:id/webhooks/:key", ctrl.webhook) + api.GET("/:id/webhooks/:key", ctrl.webhook) +} + +// --- Management --- + +// install serves POST /api/plugins/install: JSON {"url": "..."} or a +// multipart/form-data upload with a "package" field. +func (c *Controller) install(ctx *gin.Context) { + rec, err := c.installFromRequest(ctx) + if err != nil { + if rec == nil { + c.writeInstallError(ctx, err) + return + } + ctx.JSON(http.StatusCreated, InstallResponse{Plugin: rec, Warning: err.Error()}) + return + } + ctx.JSON(http.StatusCreated, InstallResponse{Plugin: rec}) +} + +// installFromRequest dispatches to Service.Install (multipart upload) or +// Service.InstallFromURL (JSON body), based on the request's Content-Type. +func (c *Controller) installFromRequest(ctx *gin.Context) (*store.Record, error) { + if strings.HasPrefix(ctx.ContentType(), "multipart/form-data") { + fileHeader, err := ctx.FormFile("package") + if err != nil { + return nil, errBadRequest("missing multipart field \"package\"") + } + f, err := fileHeader.Open() + if err != nil { + return nil, errBadRequest("failed to read uploaded package") + } + defer func() { _ = f.Close() }() + return c.svc.Install(ctx.Request.Context(), f) + } + + var req InstallRequest + if err := ctx.ShouldBindJSON(&req); err != nil || req.URL == "" { + return nil, errBadRequest("invalid payload: url required (or a multipart \"package\" upload)") + } + return c.svc.InstallFromURL(ctx.Request.Context(), req.URL) +} + +// errBadRequest is a sentinel-ish wrapper writeInstallError recognizes to +// always map to 400, for installFromRequest's own input-validation errors +// (as opposed to pkgtar's package-content errors). +type errBadRequest string + +func (e errBadRequest) Error() string { return string(e) } + +// writeInstallError maps an Install/InstallFromURL error to the right HTTP +// status: pkgtar.ErrVersionExists -> 409 (matches the frozen contract's +// "ErrVersionExists -> 409 semantics"), every other pkgtar validation +// error and errBadRequest -> 400, anything else -> 500. +func (c *Controller) writeInstallError(ctx *gin.Context, err error) { + var badReq errBadRequest + switch { + case errors.Is(err, pkgtar.ErrVersionExists): + ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case errors.As(err, &badReq), + errors.Is(err, pkgtar.ErrManifestInvalid), + errors.Is(err, pkgtar.ErrMissingChecksums), + errors.Is(err, pkgtar.ErrUnlistedFile), + errors.Is(err, pkgtar.ErrChecksumMismatch), + errors.Is(err, pkgtar.ErrPathTraversal), + errors.Is(err, pkgtar.ErrPlatformNotSupported): + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.log.Warn("plugin install error", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } +} + +// sync serves POST /api/plugins/sync: runs Service.Sync (dir sideloads, +// dropped tarballs, missing-install detection) and returns the resulting +// SyncResult. +func (c *Controller) sync(ctx *gin.Context) { + result, err := c.svc.Sync(ctx.Request.Context()) + if err != nil { + c.log.Warn("plugin sync error", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, result) +} + +func (c *Controller) list(ctx *gin.Context) { + ctx.JSON(http.StatusOK, gin.H{"plugins": c.svc.List()}) +} + +func (c *Controller) get(ctx *gin.Context) { + record, err := c.svc.Get(ctx.Param("id")) + if err != nil { + c.writeLookupError(ctx, err) + return + } + ctx.JSON(http.StatusOK, record) +} + +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 { + c.writeLookupError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"updated": true}) +} + +func (c *Controller) uninstall(ctx *gin.Context) { + if err := c.svc.Uninstall(ctx.Param("id")); err != nil { + c.writeLookupError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) enable(ctx *gin.Context) { + if err := c.svc.Enable(ctx.Param("id")); err != nil { + c.writeLookupError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"enabled": true}) +} + +func (c *Controller) disable(ctx *gin.Context) { + if err := c.svc.Disable(ctx.Param("id")); err != nil { + c.writeLookupError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"disabled": true}) +} + +// writeLookupError maps common Service errors to HTTP status codes shared +// by most management handlers. +func (c *Controller) writeLookupError(ctx *gin.Context, err error) { + if errors.Is(err, store.ErrNotFound) { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + var invalidErr *ErrInvalidTransition + if errors.As(err, &invalidErr) { + ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.log.Warn("plugin handler error", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +} + +// --- Tools listing --- + +// listTools aggregates the declared tools of every StatusActive plugin +// (GET /api/plugins/tools). Listing only — wiring these into the agent's +// invocable tool set is out of scope for this task. +func (c *Controller) listTools(ctx *gin.Context) { + var tools []PluginToolDTO + for _, rec := range c.svc.List() { + if rec.Status != StatusActive { + continue + } + for _, tool := range rec.Tools { + tools = append(tools, PluginToolDTO{ + PluginID: rec.ID, + Name: tool.Name, + DisplayName: tool.DisplayName, + Description: tool.Description, + InputSchema: tool.InputSchema, + }) + } + } + if tools == nil { + tools = []PluginToolDTO{} + } + ctx.JSON(http.StatusOK, gin.H{"tools": tools}) +} + +// --- Bundle / UI static file serving --- + +// activeRecord resolves id to a StatusActive plugin record, writing the +// appropriate error response and returning ok=false otherwise. Bundle/UI +// serving only applies to active plugins: there's no extracted-and-running +// process to trust the files of otherwise (disabled/error/uninstalled). +func (c *Controller) activeRecord(ctx *gin.Context) (*store.Record, bool) { + record, err := c.svc.Get(ctx.Param("id")) + if err != nil { + c.writeLookupError(ctx, err) + return nil, false + } + if record.Status != StatusActive { + ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "plugin is not active"}) + return nil, false + } + return record, true +} + +// bundle serves GET /api/plugins/:id/bundle: the plugin's declared +// ui.bundle file, read from disk under rec.InstallPath, forcing +// Content-Type: text/javascript so the SPA's dynamic import() always sees +// a JS module. +func (c *Controller) bundle(ctx *gin.Context) { + record, ok := c.activeRecord(ctx) + if !ok { + return + } + if record.UI.Bundle == "" { + ctx.JSON(http.StatusNotFound, gin.H{"error": "plugin has no UI bundle"}) + return + } + serveInstalledFile(ctx, record.InstallPath, record.UI.Bundle, "text/javascript; charset=utf-8") +} + +// ui serves GET /api/plugins/:id/ui/*path: the remainder of the path, +// verbatim, from disk under rec.InstallPath (mirrors ui.bundle/ui.styles' +// root-relative path convention — e.g. requesting ".../ui/ui/style.css" +// resolves rec.InstallPath + "/ui/style.css", the manifest's declared +// ui.styles entry). +func (c *Controller) ui(ctx *gin.Context) { + record, ok := c.activeRecord(ctx) + if !ok { + return + } + subPath := ctx.Param("path") + if subPath == "" { + subPath = "/" + } + serveInstalledFile(ctx, record.InstallPath, subPath, "") +} + +// serveInstalledFile serves relPath from disk under root via +// http.FileServer(http.Dir(root)), which rejects ".."-containing paths +// itself (net/http.Dir.Open cleans the path and refuses to escape root) — +// this is the "path-traversal safe via http.FileServer on a rooted FS" +// requirement. contentType, if non-empty, is set before serving so +// FileServer's extension-based sniffing is skipped (net/http only +// auto-detects when the header is unset). +func serveInstalledFile(ctx *gin.Context, root, relPath, contentType string) { + if contentType != "" { + ctx.Writer.Header().Set("Content-Type", contentType) + } + req := ctx.Request.Clone(ctx.Request.Context()) + req.URL.Path = relPath + http.FileServer(http.Dir(root)).ServeHTTP(ctx.Writer, req) +} + +// --- External webhook relay --- + +// webhook serves POST/GET /api/plugins/:id/webhooks/:key: validates :key +// against the plugin's manifest-declared webhooks (404 for an undeclared +// key — this endpoint must not blindly relay an arbitrary caller-supplied +// key to the subprocess), reads the body capped at maxWebhookBodyBytes (413 +// if exceeded), builds a pluginsdk.WebhookRequest from the inbound HTTP +// request, relays it to the plugin's live subprocess via +// Service.InvokeWebhook, then writes back the plugin's WebhookResponse +// verbatim. +func (c *Controller) webhook(ctx *gin.Context) { + id := ctx.Param("id") + record, err := c.svc.Get(id) + if err != nil { + c.writeLookupError(ctx, err) + return + } + + key := ctx.Param("key") + if !manifestDeclaresWebhookKey(record, key) { + ctx.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("plugin %q has no webhook %q", id, key)}) + return + } + + body, err := readCappedWebhookBody(ctx) + if err != nil { + return // readCappedWebhookBody already wrote the error response + } + + req := &pluginsdk.WebhookRequest{ + WebhookKey: key, + Method: ctx.Request.Method, + Query: ctx.Request.URL.RawQuery, + Headers: flattenHeaders(ctx.Request.Header), + Body: body, + } + + resp, err := c.svc.InvokeWebhook(ctx.Request.Context(), id, req) + if err != nil { + ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + writeWebhookResponse(ctx, resp) +} + +// webhookStatusForResponse validates a plugin-supplied WebhookResponse.Status +// against the HTTP status code range net/http's WriteHeader accepts +// ([100, 599] — RFC 9110 informational/success/redirection/client +// error/server error classes). ok is false for anything outside that range, +// which gin's ResponseWriter.WriteHeader panics on (recovered into a bare +// 500 with no useful body by gin's recovery middleware) — a single +// misbehaving plugin should get a clear 502 instead of taking down the +// whole request with a panic. +func webhookStatusForResponse(status int32) (int, bool) { + if status < 100 || status > 599 { + return 0, false + } + return int(status), true +} + +// writeWebhookResponse turns a plugin's WebhookResponse into the outbound +// HTTP response: an out-of-range Status is rejected as 502 (see +// webhookStatusForResponse) before ever reaching ctx.Writer.WriteHeader; +// otherwise headers, status, and body are relayed verbatim. +func writeWebhookResponse(ctx *gin.Context, resp *pluginsdk.WebhookResponse) { + status, ok := webhookStatusForResponse(resp.Status) + if !ok { + ctx.JSON(http.StatusBadGateway, gin.H{ + "error": fmt.Sprintf("plugin returned invalid webhook status %d", resp.Status), + }) + return + } + for k, v := range resp.Headers { + ctx.Writer.Header().Set(k, v) + } + ctx.Writer.WriteHeader(status) + _, _ = ctx.Writer.Write(resp.Body) +} + +// manifestDeclaresWebhookKey reports whether record's manifest declares a +// webhooks[] entry with the given key. +func manifestDeclaresWebhookKey(record *store.Record, key string) bool { + for _, wh := range record.Webhooks { + if wh.Key == key { + return true + } + } + return false +} + +// readCappedWebhookBody reads ctx.Request.Body bounded at +// maxWebhookBodyBytes via http.MaxBytesReader, writing the 413 response +// itself (and returning a non-nil error as a sentinel to the caller) when +// the body exceeds the cap, so a single external webhook POST cannot +// exhaust backend memory via an unbounded io.ReadAll. +func readCappedWebhookBody(ctx *gin.Context) ([]byte, error) { + ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxWebhookBodyBytes) + body, err := io.ReadAll(ctx.Request.Body) + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + ctx.JSON(http.StatusRequestEntityTooLarge, gin.H{ + "error": fmt.Sprintf("webhook body exceeds max size of %d bytes", maxWebhookBodyBytes), + }) + return nil, err + } + ctx.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"}) + return nil, err + } + return body, nil +} + +// flattenHeaders converts a net/http.Header (map[string][]string) into the +// single-valued map[string]string WebhookRequest.Headers expects, +// per §3 of docs/plans/plugins/GRPC-CONTRACT.md: multi-valued headers are +// joined by ", ". +func flattenHeaders(h http.Header) map[string]string { + out := make(map[string]string, len(h)) + for k, v := range h { + out[k] = strings.Join(v, ", ") + } + return out +} diff --git a/apps/backend/internal/plugins/handlers_test.go b/apps/backend/internal/plugins/handlers_test.go new file mode 100644 index 0000000000..93facd8f4f --- /dev/null +++ b/apps/backend/internal/plugins/handlers_test.go @@ -0,0 +1,542 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + goruntime "runtime" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest" + "github.com/kandev/kandev/internal/plugins/state" + "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// newTestStateStore returns an in-memory-sqlite-backed *state.Store, for +// handler tests exercising plugins that declare the state capability. +func newTestStateStore(t *testing.T) *state.Store { + t.Helper() + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + t.Cleanup(func() { _ = conn.Close() }) + + st, err := state.NewStore(db.NewPool(conn, conn)) + if err != nil { + t.Fatalf("new state store: %v", err) + } + return st +} + +func newTestRouter(t *testing.T) (*gin.Engine, *Service) { + t.Helper() + gin.SetMode(gin.TestMode) + svc, _, _ := newTestService(t) + svc.SetState(newTestStateStore(t)) + router := gin.New() + RegisterRoutes(router, svc, nil, testLogger(t)) + return router, svc +} + +func doRequest(router *gin.Engine, method, path string, body string, headers map[string]string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + for k, v := range headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func doMultipartInstall(t *testing.T, router *gin.Engine, pkg *bytes.Buffer) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + w := multipart.NewWriter(&body) + part, err := w.CreateFormFile("package", "plugin.tar.gz") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + if _, err := io.Copy(part, pkg); err != nil { + t.Fatalf("copy package into form: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/plugins/install", &body) + req.Header.Set("Content-Type", w.FormDataContentType()) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func TestInstallHandlerFromURLCreatesActivePlugin(t *testing.T) { + router, svc := newTestRouter(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(testPackage(t, "kandev-plugin-slack", "1.0.0", false).Bytes()) + })) + defer srv.Close() + + rec := doRequest(router, http.MethodPost, "/api/plugins/install", fmt.Sprintf(`{"url":%q}`, srv.URL), nil) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var resp InstallResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp.Plugin.ID != "kandev-plugin-slack" { + t.Fatalf("Plugin.ID = %q, want %q", resp.Plugin.ID, "kandev-plugin-slack") + } + if resp.Plugin.Status != StatusActive { + t.Fatalf("Plugin.Status = %q, want %q", resp.Plugin.Status, StatusActive) + } + + if _, err := svc.Get("kandev-plugin-slack"); err != nil { + t.Fatalf("plugin not persisted in service: %v", err) + } +} + +func TestInstallHandlerMultipartUpload(t *testing.T) { + router, svc := newTestRouter(t) + + rec := doMultipartInstall(t, router, testPackage(t, "kandev-plugin-slack", "1.0.0", false)) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + if _, err := svc.Get("kandev-plugin-slack"); err != nil { + t.Fatalf("plugin not persisted in service: %v", err) + } +} + +func TestInstallHandlerMissingURLReturns400(t *testing.T) { + router, _ := newTestRouter(t) + rec := doRequest(router, http.MethodPost, "/api/plugins/install", `{}`, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestInstallHandlerDuplicateVersionReturns409(t *testing.T) { + router, _ := newTestRouter(t) + pkg := testPackage(t, "kandev-plugin-slack", "1.0.0", false) + pkgBytes := pkg.Bytes() + + first := doMultipartInstall(t, router, bytes.NewBuffer(pkgBytes)) + if first.Code != http.StatusCreated { + t.Fatalf("first install status = %d, want 201, body=%s", first.Code, first.Body.String()) + } + + second := doMultipartInstall(t, router, bytes.NewBuffer(pkgBytes)) + if second.Code != http.StatusConflict { + t.Fatalf("second install status = %d, want 409, body=%s", second.Code, second.Body.String()) + } +} + +func TestListHandlerReturnsInstalledPlugins(t *testing.T) { + router, svc := newTestRouter(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + + rec := doRequest(router, http.MethodGet, "/api/plugins", "", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var body struct { + Plugins []*store.Record `json:"plugins"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(body.Plugins) != 1 { + t.Fatalf("len(plugins) = %d, want 1", len(body.Plugins)) + } +} + +func TestGetHandlerMissingReturns404(t *testing.T) { + router, _ := newTestRouter(t) + rec := doRequest(router, http.MethodGet, "/api/plugins/missing", "", nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } +} + +func TestEnableDisableHandlersTransitionStatus(t *testing.T) { + router, svc := newTestRouter(t) + installTestPlugin(t, svc, "kandev-plugin-slack") // already active after install + + rec := doRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-slack/disable", "", nil) + if rec.Code != http.StatusOK { + t.Fatalf("disable status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + got, _ := svc.Get("kandev-plugin-slack") + if got.Status != StatusDisabled { + t.Fatalf("status after disable = %q, want %q", got.Status, StatusDisabled) + } + + rec = doRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-slack/enable", "", nil) + if rec.Code != http.StatusOK { + t.Fatalf("enable status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + got, _ = svc.Get("kandev-plugin-slack") + if got.Status != StatusActive { + t.Fatalf("status after enable = %q, want %q", got.Status, StatusActive) + } +} + +func TestUpdateConfigHandlerPersists(t *testing.T) { + router, svc := newTestRouter(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + + rec := doRequest(router, http.MethodPatch, "/api/plugins/kandev-plugin-slack", `{"config":{"default_channel":"#dev"}}`, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } +} + +func TestUninstallHandlerRemovesPlugin(t *testing.T) { + router, svc := newTestRouter(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + + rec := doRequest(router, http.MethodDelete, "/api/plugins/kandev-plugin-slack", "", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if _, err := svc.Get("kandev-plugin-slack"); err == nil { + t.Fatal("plugin still present after uninstall") + } +} + +// toolPackage builds a valid runtime-managed package that also declares a +// single tool named "_tool", for GET /api/plugins/tools tests. +func toolPackage(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 +tools: + - name: %s_tool + display_name: Tool +runtime: + type: binary + executables: + %s: server/plugin +`, id, 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 TestListToolsOnlyIncludesActivePlugins(t *testing.T) { + router, svc := newTestRouter(t) + + if _, err := svc.Install(t.Context(), toolPackage(t, "kandev-plugin-slack")); err != nil { + t.Fatalf("Install(slack): %v", err) + } + if _, err := svc.Install(t.Context(), toolPackage(t, "kandev-plugin-jira")); err != nil { + t.Fatalf("Install(jira): %v", err) + } + // kandev-plugin-jira gets disabled — its tool must not be listed. + if err := svc.Disable("kandev-plugin-jira"); err != nil { + t.Fatalf("Disable(jira): %v", err) + } + + rec := doRequest(router, http.MethodGet, "/api/plugins/tools", "", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var body struct { + Tools []PluginToolDTO `json:"tools"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(body.Tools) != 1 || body.Tools[0].PluginID != "kandev-plugin-slack" { + t.Fatalf("tools = %+v, want exactly one tool from kandev-plugin-slack", body.Tools) + } +} + +func TestBundleHandlerServesFileFromDisk(t *testing.T) { + router, svc := newTestRouter(t) + if _, err := svc.Install(t.Context(), testPackage(t, "kandev-plugin-ui", "1.0.0", true)); err != nil { + t.Fatalf("Install: %v", err) + } + + rec := doRequest(router, http.MethodGet, "/api/plugins/kandev-plugin-ui/bundle", "", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Content-Type"); got != "text/javascript; charset=utf-8" { + t.Fatalf("Content-Type = %q, want text/javascript; charset=utf-8", got) + } + if rec.Body.String() != "export default {};" { + t.Fatalf("body = %q, want the bundle file contents", rec.Body.String()) + } +} + +func TestUIHandlerServesStyleFileFromDisk(t *testing.T) { + router, svc := newTestRouter(t) + if _, err := svc.Install(t.Context(), testPackage(t, "kandev-plugin-ui", "1.0.0", true)); err != nil { + t.Fatalf("Install: %v", err) + } + + rec := doRequest(router, http.MethodGet, "/api/plugins/kandev-plugin-ui/ui/ui/style.css", "", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + if rec.Body.String() != "body{}" { + t.Fatalf("body = %q, want the style file contents", rec.Body.String()) + } +} + +func TestUIHandlerPathTraversalRejected(t *testing.T) { + router, svc := newTestRouter(t) + if _, err := svc.Install(t.Context(), testPackage(t, "kandev-plugin-ui", "1.0.0", true)); err != nil { + t.Fatalf("Install: %v", err) + } + + rec := doRequest(router, http.MethodGet, "/api/plugins/kandev-plugin-ui/ui/../../../../etc/passwd", "", nil) + if rec.Code == http.StatusOK { + t.Fatalf("status = 200, want a non-200 response for a path-traversal attempt, body=%s", rec.Body.String()) + } +} + +func TestBundleHandlerInactivePluginReturns503(t *testing.T) { + router, svc := newTestRouter(t) + if _, err := svc.Install(t.Context(), testPackage(t, "kandev-plugin-ui", "1.0.0", true)); err != nil { + t.Fatalf("Install: %v", err) + } + if err := svc.Disable("kandev-plugin-ui"); err != nil { + t.Fatalf("Disable: %v", err) + } + + rec := doRequest(router, http.MethodGet, "/api/plugins/kandev-plugin-ui/bundle", "", nil) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503, body=%s", rec.Code, rec.Body.String()) + } +} + +// webhookPackage builds a valid runtime-managed package that declares +// exactly one webhook with the given key, for POST/GET +// /api/plugins/:id/webhooks/:key tests. +func webhookPackage(t *testing.T, id, key 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 +webhooks: + - key: %s + method: POST +runtime: + type: binary + executables: + %s: server/plugin +`, id, key, 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 TestWebhookHandlerUnknownPluginReturns404(t *testing.T) { + router, _ := newTestRouter(t) + rec := doRequest(router, http.MethodPost, "/api/plugins/missing/webhooks/key1", "{}", nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404, body=%s", rec.Code, rec.Body.String()) + } +} + +// TestWebhookHandlerUndeclaredKeyReturns404 pins the fix that the webhook +// relay validates :key against the plugin's manifest-declared webhooks +// before ever reaching the live subprocess: a key the plugin never +// registered must 404, not be blindly forwarded. +func TestWebhookHandlerUndeclaredKeyReturns404(t *testing.T) { + router, svc := newTestRouter(t) + if _, err := svc.Install(t.Context(), webhookPackage(t, "kandev-plugin-slack", "declared-key")); err != nil { + t.Fatalf("Install: %v", err) + } + + rec := doRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-slack/webhooks/undeclared-key", "{}", nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404, body=%s", rec.Code, rec.Body.String()) + } +} + +// TestWebhookHandlerOversizedBodyReturns413 pins the fix that the webhook +// relay caps the request body instead of an unbounded io.ReadAll (an +// external-caller-triggerable OOM DoS otherwise). +func TestWebhookHandlerOversizedBodyReturns413(t *testing.T) { + router, svc := newTestRouter(t) + if _, err := svc.Install(t.Context(), webhookPackage(t, "kandev-plugin-slack", "key1")); err != nil { + t.Fatalf("Install: %v", err) + } + + oversized := strings.Repeat("a", maxWebhookBodyBytes+1) + rec := doRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-slack/webhooks/key1", oversized, nil) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413, body=%s", rec.Code, rec.Body.String()) + } +} + +func TestWebhookHandlerNotRunningReturns503(t *testing.T) { + router, svc := newTestRouter(t) + if _, err := svc.Install(t.Context(), webhookPackage(t, "kandev-plugin-slack", "key1")); err != nil { + t.Fatalf("Install: %v", err) + } + if err := svc.Disable("kandev-plugin-slack"); err != nil { + t.Fatalf("Disable: %v", err) + } + + rec := doRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-slack/webhooks/key1", "{}", nil) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503, body=%s", rec.Code, rec.Body.String()) + } +} + +// TestWebhookStatusForResponse_ValidStatusesPassThrough pins that any +// syntactically valid HTTP status code a plugin returns is relayed +// unchanged. +func TestWebhookStatusForResponse_ValidStatusesPassThrough(t *testing.T) { + for _, status := range []int32{100, 200, 204, 302, 404, 500, 599} { + got, ok := webhookStatusForResponse(status) + if !ok { + t.Fatalf("webhookStatusForResponse(%d) ok = false, want true", status) + } + if got != int(status) { + t.Fatalf("webhookStatusForResponse(%d) = %d, want %d", status, got, status) + } + } +} + +// TestWebhookStatusForResponse_OutOfRangeStatusesAreRejected pins the fix +// for a plugin returning a status outside [100, 599]: gin's WriteHeader +// panics (-> 500) on such a code, so the webhook relay must reject it +// before ever calling WriteHeader. +func TestWebhookStatusForResponse_OutOfRangeStatusesAreRejected(t *testing.T) { + for _, status := range []int32{0, -1, 99, 600, 1000} { + if _, ok := webhookStatusForResponse(status); ok { + t.Fatalf("webhookStatusForResponse(%d) ok = true, want false (out of [100,599])", status) + } + } +} + +// TestWriteWebhookResponse_OutOfRangePluginStatusReturns502 exercises the +// exact code path the webhook handler uses to turn a plugin's +// WebhookResponse into the outbound HTTP response: given an out-of-range +// plugin status, it must write 502 instead of ever calling +// ctx.Writer.WriteHeader with the invalid code (gin panics -> bare 500 on +// an out-of-range WriteHeader call). +func TestWriteWebhookResponse_OutOfRangePluginStatusReturns502(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + + writeWebhookResponse(ctx, &pluginsdk.WebhookResponse{Status: 0, Body: []byte("boom")}) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502, body=%s", rec.Code, rec.Body.String()) + } +} + +// TestWriteWebhookResponse_ValidStatusRelaysHeadersAndBody proves the +// normal (in-range) path still relays the plugin's status, headers, and +// body verbatim. +func TestWriteWebhookResponse_ValidStatusRelaysHeadersAndBody(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + + writeWebhookResponse(ctx, &pluginsdk.WebhookResponse{ + Status: 201, + Headers: map[string]string{"X-Plugin": "slack"}, + Body: []byte(`{"ok":true}`), + }) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201", rec.Code) + } + if got := rec.Header().Get("X-Plugin"); got != "slack" { + t.Fatalf("X-Plugin header = %q, want %q", got, "slack") + } + if rec.Body.String() != `{"ok":true}` { + t.Fatalf("body = %q, want %q", rec.Body.String(), `{"ok":true}`) + } +} + +func TestSyncHandlerRegistersDirSideload(t *testing.T) { + router, svc := newTestRouter(t) + pluginsDir := svc.pluginsDir + versionDir := filepath.Join(pluginsDir, "kandev-plugin-side", "1.0.0") + if err := os.MkdirAll(versionDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + manifestYAML := fmt.Sprintf(` +id: kandev-plugin-side +api_version: 1 +version: "1.0.0" +display_name: Sideloaded +runtime: + type: binary + executables: + %s: server/plugin +`, goruntime.GOOS+"-"+goruntime.GOARCH) + if err := os.WriteFile(filepath.Join(versionDir, "manifest.yaml"), []byte(manifestYAML), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + rec := doRequest(router, http.MethodPost, "/api/plugins/sync", "", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + + var resp SyncResult + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(resp.Added) != 1 || resp.Added[0] != "kandev-plugin-side" { + t.Fatalf("Added = %v, want [kandev-plugin-side]", resp.Added) + } + + got, err := svc.Get("kandev-plugin-side") + if err != nil { + t.Fatalf("Get(): %v", err) + } + if got.Status != StatusDisabled { + t.Fatalf("Status = %q, want %q", got.Status, StatusDisabled) + } +} diff --git a/apps/backend/internal/plugins/host.go b/apps/backend/internal/plugins/host.go new file mode 100644 index 0000000000..85bedeed37 --- /dev/null +++ b/apps/backend/internal/plugins/host.go @@ -0,0 +1,134 @@ +package plugins + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/state" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// pluginHost implements pluginsdk.Host (the kandev.plugin.v1 Host RPCs, §3 +// of docs/plans/plugins/GRPC-CONTRACT.md) for exactly one plugin. Service +// hands runtime.Manager a fresh pluginHost per spawn/restart via +// hostForPlugin, bound to that plugin's id and its manifest-declared +// capabilities at spawn time. Every method is capability-gated: State +// methods (GetState/SetState/DeleteState/ListState) require +// capabilities.state, RevealSecret requires capabilities.secrets. EmitEvent +// is intentionally ungated — the frozen contract's capability-gating list +// (§5) only names state and secrets (api_read/api_write reserved for +// future); event emission has no boolean capability to gate on. +type pluginHost struct { + pluginID string + capabilities manifest.Capabilities + + state *state.Store + secrets SecretRevealer + bus bus.EventBus +} + +var _ pluginsdk.Host = (*pluginHost)(nil) + +// permissionDenied builds the gRPC error RemotePlugin/Host RPCs return for +// an undeclared capability, matching the wire-level message from +// docs/specs/plugins/spec.md ("Permissions"): "capability '' not +// declared". +func permissionDenied(capability string) error { + return status.Errorf(codes.PermissionDenied, "capability '%s' not declared", capability) +} + +func (h *pluginHost) GetState(ctx context.Context, scope, scopeID, key string) (map[string]any, bool, error) { + if !h.capabilities.State { + return nil, false, permissionDenied("state") + } + raw, found, err := h.state.Get(ctx, h.pluginID, scope, scopeID, key) + if err != nil || !found { + return nil, found, err + } + value, err := unmarshalStateValue(raw) + if err != nil { + return nil, false, err + } + return value, true, nil +} + +func (h *pluginHost) SetState(ctx context.Context, scope, scopeID, key string, value map[string]any) error { + if !h.capabilities.State { + return permissionDenied("state") + } + raw, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("plugins: marshal state value: %w", err) + } + return h.state.Set(ctx, h.pluginID, scope, scopeID, key, raw) +} + +func (h *pluginHost) DeleteState(ctx context.Context, scope, scopeID, key string) error { + if !h.capabilities.State { + return permissionDenied("state") + } + return h.state.Delete(ctx, h.pluginID, scope, scopeID, key) +} + +func (h *pluginHost) ListState(ctx context.Context, scope, scopeID string) ([]pluginsdk.StateEntry, error) { + if !h.capabilities.State { + return nil, permissionDenied("state") + } + entries, err := h.state.List(ctx, h.pluginID, scope, scopeID) + if err != nil { + return nil, err + } + out := make([]pluginsdk.StateEntry, len(entries)) + for i, e := range entries { + value, err := unmarshalStateValue(e.Value) + if err != nil { + return nil, err + } + out[i] = pluginsdk.StateEntry{ + Key: e.Key, + Value: value, + UpdatedAt: e.UpdatedAt.UTC().Format(time.RFC3339), + } + } + return out, nil +} + +func (h *pluginHost) RevealSecret(ctx context.Context, ref string) (string, error) { + if !h.capabilities.Secrets { + return "", permissionDenied("secrets") + } + if h.secrets == nil { + return "", errors.New("plugins: secret vault not configured") + } + return h.secrets.Reveal(ctx, ref) +} + +// EmitEvent publishes a plugin-originated event onto the bus, subject +// "plugin.." (per the task's build instructions). A no-op if no +// event bus was wired (e.g. early boot, or a test Service without one). +func (h *pluginHost) EmitEvent(ctx context.Context, name string, payload map[string]any) error { + if h.bus == nil { + return nil + } + subject := "plugin." + h.pluginID + "." + name + event := bus.NewEvent(subject, "plugin:"+h.pluginID, payload) + return h.bus.Publish(ctx, subject, event) +} + +// unmarshalStateValue decodes a plugin_state row's JSON value into a +// Go-native map, matching pluginsdk's Struct<->map[string]any convention. +func unmarshalStateValue(raw json.RawMessage) (map[string]any, error) { + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil { + return nil, fmt.Errorf("plugins: unmarshal state value: %w", err) + } + return value, nil +} diff --git a/apps/backend/internal/plugins/host_test.go b/apps/backend/internal/plugins/host_test.go new file mode 100644 index 0000000000..e0f6515193 --- /dev/null +++ b/apps/backend/internal/plugins/host_test.go @@ -0,0 +1,207 @@ +package plugins + +import ( + "context" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/state" +) + +// newHostTestStateStore returns an in-memory-sqlite-backed *state.Store, for +// pluginHost tests exercising the state capability gate. +func newHostTestStateStore(t *testing.T) *state.Store { + t.Helper() + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + t.Cleanup(func() { _ = conn.Close() }) + + st, err := state.NewStore(db.NewPool(conn, conn)) + if err != nil { + t.Fatalf("new state store: %v", err) + } + return st +} + +// assertPermissionDenied asserts err is a gRPC PermissionDenied status whose +// message matches the frozen contract's wire-level format: +// "capability '' not declared". +func assertPermissionDenied(t *testing.T, err error, capability string) { + t.Helper() + if err == nil { + t.Fatal("err = nil, want PermissionDenied") + } + st, ok := status.FromError(err) + if !ok { + t.Fatalf("err = %v, want a gRPC status error", err) + } + if st.Code() != codes.PermissionDenied { + t.Fatalf("code = %v, want %v", st.Code(), codes.PermissionDenied) + } + want := "capability '" + capability + "' not declared" + if st.Message() != want { + t.Fatalf("message = %q, want %q", st.Message(), want) + } +} + +func TestPluginHost_GetState_DeniedWithoutStateCapability(t *testing.T) { + h := &pluginHost{pluginID: "p1", state: newHostTestStateStore(t)} + _, _, err := h.GetState(context.Background(), "instance", "", "k") + assertPermissionDenied(t, err, "state") +} + +func TestPluginHost_SetState_DeniedWithoutStateCapability(t *testing.T) { + h := &pluginHost{pluginID: "p1", state: newHostTestStateStore(t)} + err := h.SetState(context.Background(), "instance", "", "k", map[string]any{"x": 1.0}) + assertPermissionDenied(t, err, "state") +} + +func TestPluginHost_DeleteState_DeniedWithoutStateCapability(t *testing.T) { + h := &pluginHost{pluginID: "p1", state: newHostTestStateStore(t)} + err := h.DeleteState(context.Background(), "instance", "", "k") + assertPermissionDenied(t, err, "state") +} + +func TestPluginHost_ListState_DeniedWithoutStateCapability(t *testing.T) { + h := &pluginHost{pluginID: "p1", state: newHostTestStateStore(t)} + _, err := h.ListState(context.Background(), "instance", "") + assertPermissionDenied(t, err, "state") +} + +func TestPluginHost_RevealSecret_DeniedWithoutSecretsCapability(t *testing.T) { + secrets := newFakeSecretRevealer() + secrets.set("ref", "shh") + h := &pluginHost{pluginID: "p1", secrets: secrets} + _, err := h.RevealSecret(context.Background(), "ref") + assertPermissionDenied(t, err, "secrets") +} + +// TestPluginHost_StateMethods_SucceedWithStateCapability proves the state +// RPCs actually work end to end (through the real state.Store) once the +// capability is declared, and that state is scoped to this host's own +// pluginID: a second pluginHost for a different id never sees it. +func TestPluginHost_StateMethods_SucceedWithStateCapability(t *testing.T) { + st := newHostTestStateStore(t) + h := &pluginHost{pluginID: "p1", capabilities: manifest.Capabilities{State: true}, state: st} + ctx := context.Background() + + if err := h.SetState(ctx, "instance", "", "k", map[string]any{"x": 1.0}); err != nil { + t.Fatalf("SetState() unexpected error: %v", err) + } + + value, found, err := h.GetState(ctx, "instance", "", "k") + if err != nil { + t.Fatalf("GetState() unexpected error: %v", err) + } + if !found { + t.Fatal("GetState() found = false, want true after SetState") + } + if value["x"] != 1.0 { + t.Fatalf("GetState() value = %v, want {x: 1}", value) + } + + entries, err := h.ListState(ctx, "instance", "") + if err != nil { + t.Fatalf("ListState() unexpected error: %v", err) + } + if len(entries) != 1 || entries[0].Key != "k" { + t.Fatalf("ListState() = %v, want one entry for key %q", entries, "k") + } + + if err := h.DeleteState(ctx, "instance", "", "k"); err != nil { + t.Fatalf("DeleteState() unexpected error: %v", err) + } + if _, found, err := h.GetState(ctx, "instance", "", "k"); err != nil || found { + t.Fatalf("GetState() after DeleteState found=%v err=%v, want found=false", found, err) + } + + // A different plugin id must never see p1's state. + other := &pluginHost{pluginID: "p2", capabilities: manifest.Capabilities{State: true}, state: st} + if err := h.SetState(ctx, "instance", "", "shared-key", map[string]any{"v": true}); err != nil { + t.Fatalf("SetState() unexpected error: %v", err) + } + if _, found, err := other.GetState(ctx, "instance", "", "shared-key"); err != nil || found { + t.Fatalf("plugin p2 saw p1's state: found=%v err=%v, want found=false", found, err) + } +} + +// TestPluginHost_RevealSecret_SucceedsWithSecretsCapability proves +// RevealSecret reaches the wired SecretRevealer once the capability is +// declared. +func TestPluginHost_RevealSecret_SucceedsWithSecretsCapability(t *testing.T) { + secrets := newFakeSecretRevealer() + secrets.set("ref", "cleartext") + h := &pluginHost{ + pluginID: "p1", + capabilities: manifest.Capabilities{Secrets: true}, + secrets: secrets, + } + got, err := h.RevealSecret(context.Background(), "ref") + if err != nil { + t.Fatalf("RevealSecret() unexpected error: %v", err) + } + if got != "cleartext" { + t.Fatalf("RevealSecret() = %q, want %q", got, "cleartext") + } +} + +// TestPluginHost_RevealSecret_ErrorsWithoutVaultWired proves the "vault not +// configured" branch is reachable even when the capability is declared, for +// a Service constructed without SetSecrets (e.g. some tests). +func TestPluginHost_RevealSecret_ErrorsWithoutVaultWired(t *testing.T) { + h := &pluginHost{pluginID: "p1", capabilities: manifest.Capabilities{Secrets: true}} + _, err := h.RevealSecret(context.Background(), "ref") + if err == nil { + t.Fatal("RevealSecret() with no vault wired unexpectedly succeeded") + } +} + +func TestPluginHost_EmitEvent_PublishesOnPluginNamespacedSubject(t *testing.T) { + log := testLogger(t) + memBus := bus.NewMemoryEventBus(log) + t.Cleanup(memBus.Close) + + received := make(chan *bus.Event, 1) + sub, err := memBus.Subscribe("plugin.p1.widget.created", func(_ context.Context, e *bus.Event) error { + received <- e + return nil + }) + if err != nil { + t.Fatalf("Subscribe() unexpected error: %v", err) + } + t.Cleanup(func() { _ = sub.Unsubscribe() }) + + h := &pluginHost{pluginID: "p1", bus: memBus} + if err := h.EmitEvent(context.Background(), "widget.created", map[string]any{"id": "w1"}); err != nil { + t.Fatalf("EmitEvent() unexpected error: %v", err) + } + + select { + case e := <-received: + if e.Type != "plugin.p1.widget.created" { + t.Fatalf("event.Type = %q, want %q", e.Type, "plugin.p1.widget.created") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for EmitEvent's publish to reach the subscriber") + } +} + +// TestPluginHost_EmitEvent_NilBusIsANoOp pins the documented "no event bus +// wired" no-op contract (early boot, or a test Service without one). +func TestPluginHost_EmitEvent_NilBusIsANoOp(t *testing.T) { + h := &pluginHost{pluginID: "p1"} + if err := h.EmitEvent(context.Background(), "widget.created", nil); err != nil { + t.Fatalf("EmitEvent() with nil bus unexpected error: %v", err) + } +} diff --git a/apps/backend/internal/plugins/invoke.go b/apps/backend/internal/plugins/invoke.go new file mode 100644 index 0000000000..df2a5f60f4 --- /dev/null +++ b/apps/backend/internal/plugins/invoke.go @@ -0,0 +1,48 @@ +package plugins + +import ( + "context" + "fmt" + "time" + + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// DefaultToolTimeout bounds a single agent tool invocation +// (Service.InvokeTool), per docs/specs/plugins/spec.md ("Agent tool +// invocation"). +const DefaultToolTimeout = 30 * time.Second + +// InvokeTool routes a tool call to id's live subprocess via the runtime +// manager's RemotePlugin.InvokeTool RPC, applying DefaultToolTimeout on top +// of ctx. Returns an error if id has no live process (not active, or +// mid-restart). +func (s *Service) InvokeTool(ctx context.Context, id string, req *pluginsdk.ToolRequest) (*pluginsdk.ToolResponse, error) { + remote, ok := s.pluginRemote(id) + if !ok { + return nil, fmt.Errorf("plugins: plugin %q is not running", id) + } + reqCtx, cancel := context.WithTimeout(ctx, DefaultToolTimeout) + defer cancel() + return remote.InvokeTool(reqCtx, req) +} + +// InvokeWebhook routes an inbound webhook to id's live subprocess via the +// runtime manager's RemotePlugin.HandleWebhook RPC. Used by +// POST/GET /api/plugins/:id/webhooks/:key. +func (s *Service) InvokeWebhook(ctx context.Context, id string, req *pluginsdk.WebhookRequest) (*pluginsdk.WebhookResponse, error) { + remote, ok := s.pluginRemote(id) + if !ok { + return nil, fmt.Errorf("plugins: plugin %q is not running", id) + } + return remote.HandleWebhook(ctx, req) +} + +// pluginRemote returns the live RemotePlugin for id, if the runtime manager +// is wired and currently tracking a running process for it. +func (s *Service) pluginRemote(id string) (*pluginsdk.RemotePlugin, bool) { + if s.runtime == nil { + return nil, false + } + return s.runtime.Get(id) +} diff --git a/apps/backend/internal/plugins/manifest/capability.go b/apps/backend/internal/plugins/manifest/capability.go new file mode 100644 index 0000000000..01b280c54e --- /dev/null +++ b/apps/backend/internal/plugins/manifest/capability.go @@ -0,0 +1,41 @@ +package manifest + +// HasEvent reports whether the manifest's declared event subscriptions +// (Capabilities.Events) cover the given concrete event name, including +// wildcard subscriptions such as "task.*". +func (m *Manifest) HasEvent(name string) bool { + for _, pattern := range m.Capabilities.Events { + if MatchSubject(pattern, name) { + return true + } + } + return false +} + +// CanRead reports whether the manifest declares read access to resource via +// Capabilities.APIRead. +func (m *Manifest) CanRead(resource string) bool { + return containsString(m.Capabilities.APIRead, resource) +} + +// CanWrite reports whether the manifest declares write access to resource +// via Capabilities.APIWrite. +func (m *Manifest) CanWrite(resource string) bool { + return containsString(m.Capabilities.APIWrite, resource) +} + +// HasUIBundle reports whether the manifest declares a native UI bundle via +// UISection.Bundle. +func (m *Manifest) HasUIBundle() bool { + return m.UI.Bundle != "" +} + +// containsString reports whether target is present in values. +func containsString(values []string, target string) bool { + for _, v := range values { + if v == target { + return true + } + } + return false +} diff --git a/apps/backend/internal/plugins/manifest/manifest.go b/apps/backend/internal/plugins/manifest/manifest.go new file mode 100644 index 0000000000..609825273e --- /dev/null +++ b/apps/backend/internal/plugins/manifest/manifest.go @@ -0,0 +1,107 @@ +// Package manifest defines the plugin manifest data model and validation +// rules described in docs/specs/plugins/spec.md ("Data model / Plugin +// registration"). A manifest is the YAML document an operator supplies to +// POST /api/plugins/register. +package manifest + +import "gopkg.in/yaml.v3" + +// Manifest is the plugin registration manifest. +type Manifest struct { + ID string `yaml:"id" json:"id"` + APIVersion int `yaml:"api_version" json:"api_version"` + Version string `yaml:"version" json:"version"` + DisplayName string `yaml:"display_name" json:"display_name"` + Description string `yaml:"description" json:"description"` + Author string `yaml:"author" json:"author"` + Categories []string `yaml:"categories" json:"categories"` + + BaseURL string `yaml:"base_url" json:"base_url"` + + Endpoints Endpoints `yaml:"endpoints" json:"endpoints"` + Capabilities Capabilities `yaml:"capabilities" json:"capabilities"` + + Tools []Tool `yaml:"tools,omitempty" json:"tools,omitempty"` + Webhooks []Webhook `yaml:"webhooks,omitempty" json:"webhooks,omitempty"` + + ConfigSchema map[string]any `yaml:"config_schema,omitempty" json:"config_schema,omitempty"` + + UI UISection `yaml:"ui,omitempty" json:"ui,omitempty"` + + Runtime Runtime `yaml:"runtime,omitempty" json:"runtime,omitempty"` + MinKandevVersion string `yaml:"min_kandev_version,omitempty" json:"min_kandev_version,omitempty"` +} + +// Runtime declares that a plugin ships a kandev-managed binary rather than +// registering an already-running remote service. Type is currently only +// ever "binary"; kandev spawns the matching Executables entry as a +// subprocess. Executables maps "-" (e.g. "linux-amd64", +// "darwin-arm64", "windows-amd64") to a package-relative path under +// server/ (Windows values already include the ".exe" suffix). +type Runtime struct { + Type string `yaml:"type,omitempty" json:"type,omitempty"` + Executables map[string]string `yaml:"executables,omitempty" json:"executables,omitempty"` +} + +// Endpoints declares the HTTP paths kandev calls on the plugin. +type Endpoints struct { + Health string `yaml:"health" json:"health"` + Events string `yaml:"events" json:"events"` + Tools string `yaml:"tools" json:"tools"` + Webhooks string `yaml:"webhooks" json:"webhooks"` +} + +// Capabilities declares what the plugin is allowed to do. +type Capabilities struct { + Events []string `yaml:"events,omitempty" json:"events,omitempty"` + APIRead []string `yaml:"api_read,omitempty" json:"api_read,omitempty"` + APIWrite []string `yaml:"api_write,omitempty" json:"api_write,omitempty"` + State bool `yaml:"state,omitempty" json:"state,omitempty"` + Secrets bool `yaml:"secrets,omitempty" json:"secrets,omitempty"` +} + +// Tool is an agent-invocable tool the plugin registers. +type Tool struct { + Name string `yaml:"name" json:"name"` + DisplayName string `yaml:"display_name" json:"display_name"` + Description string `yaml:"description" json:"description"` + InputSchema map[string]any `yaml:"input_schema,omitempty" json:"input_schema,omitempty"` +} + +// Webhook is a proxied external webhook endpoint the plugin declares. +type Webhook struct { + Key string `yaml:"key" json:"key"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Method string `yaml:"method,omitempty" json:"method,omitempty"` +} + +// UISection declares UI pages the plugin contributes, and/or a native UI +// bundle. Bundle is a root-relative path on the plugin process serving an ES +// module (e.g. "/ui/bundle.js"), fetched by kandev via +// /api/plugins/{id}/bundle and proxied to the plugin. Styles are optional +// root-relative CSS paths served alongside the bundle. Pages remain +// optional/secondary when a bundle is declared: native routes come from the +// bundle at runtime. +type UISection struct { + Pages []UIPage `yaml:"pages,omitempty" json:"pages,omitempty"` + Bundle string `yaml:"bundle,omitempty" json:"bundle,omitempty"` + Styles []string `yaml:"styles,omitempty" json:"styles,omitempty"` +} + +// UIPage is a single UI page contributed by the plugin. +type UIPage struct { + Key string `yaml:"key" json:"key"` + Title string `yaml:"title" json:"title"` + Path string `yaml:"path" json:"path"` + Surface string `yaml:"surface" json:"surface"` +} + +// Parse decodes a plugin manifest from YAML bytes. It does not validate the +// result; callers should call (*Manifest).Validate() afterward. +func Parse(data []byte) (*Manifest, error) { + var m Manifest + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, err + } + return &m, nil +} diff --git a/apps/backend/internal/plugins/manifest/manifest_test.go b/apps/backend/internal/plugins/manifest/manifest_test.go new file mode 100644 index 0000000000..501b4a93c1 --- /dev/null +++ b/apps/backend/internal/plugins/manifest/manifest_test.go @@ -0,0 +1,483 @@ +package manifest + +import ( + "strings" + "testing" +) + +const validManifestYAML = ` +id: "kandev-plugin-slack" +api_version: 1 +version: "1.0.0" +display_name: "Slack Notifications" +description: "Post to Slack on task events" +author: "kandev" +categories: ["connector"] + +base_url: "http://localhost:9100" + +endpoints: + health: "/health" + events: "/events" + tools: "/tools/{tool_name}" + webhooks: "/webhooks/{webhook_key}" + +capabilities: + events: ["task.created", "task.*"] + api_read: ["tasks", "agents"] + api_write: ["tasks"] + state: true + secrets: true + +tools: + - name: "slack_send_message" + display_name: "Send Slack Message" + description: "Posts a message to a Slack channel" + input_schema: + type: object + properties: + channel: { type: string } + text: { type: string } + required: ["channel", "text"] + +webhooks: + - key: "slack-events" + description: "Slack Events API webhook" + method: "POST" + +config_schema: + type: object + properties: + bot_token_secret: { type: string, description: "Secret reference for Slack bot token" } + required: ["bot_token_secret"] + +ui: + pages: + - key: "slack-settings" + title: "Slack Settings" + path: "/settings" + surface: "settings" +` + +func TestParse_ValidManifestParsesID(t *testing.T) { + m, err := Parse([]byte(validManifestYAML)) + if err != nil { + t.Fatalf("Parse() unexpected error: %v", err) + } + if m.ID != "kandev-plugin-slack" { + t.Fatalf("m.ID = %q, want %q", m.ID, "kandev-plugin-slack") + } +} + +func TestValidate_ValidManifestPasses(t *testing.T) { + m, err := Parse([]byte(validManifestYAML)) + if err != nil { + t.Fatalf("Parse() unexpected error: %v", err) + } + if err := m.Validate(); err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } +} + +// validManifest returns a freshly parsed copy of the valid fixture so each +// test can mutate it independently without affecting others. +func validManifest(t *testing.T) *Manifest { + t.Helper() + m, err := Parse([]byte(validManifestYAML)) + if err != nil { + t.Fatalf("Parse() unexpected error: %v", err) + } + return m +} + +func TestValidate_RejectsBadIDPattern(t *testing.T) { + m := validManifest(t) + m.ID = "Kandev_Plugin!" + + if err := m.Validate(); err == nil { + t.Fatal("Validate() expected error for bad id pattern, got nil") + } +} + +// TestValidate_RejectsIDEndingInDotConfig pins the fix for an id whose +// ".yml" record filename would collide with FSStore's +// ".config.yml" operator-config naming convention (see +// store.isRecordFile): registration must reject this shape up front rather +// than let it silently vanish from FSStore.List() after install. +func TestValidate_RejectsIDEndingInDotConfig(t *testing.T) { + m := validManifest(t) + m.ID = "foo.config" + + err := m.Validate() + if err == nil { + t.Fatal("Validate() expected error for an id ending in \".config\", got nil") + } + if !strings.Contains(err.Error(), "id") { + t.Fatalf("Validate() error = %q, want it to mention the id", err.Error()) + } +} + +func TestValidate_RejectsInvalidManifests(t *testing.T) { + tests := []struct { + name string + mutate func(m *Manifest) + wantErr string // substring expected in the error message + }{ + { + name: "missing base_url", + mutate: func(m *Manifest) { m.BaseURL = "" }, + wantErr: "base_url", + }, + { + name: "missing health endpoint", + mutate: func(m *Manifest) { m.Endpoints.Health = "" }, + wantErr: "endpoints.health", + }, + { + name: "missing events endpoint", + mutate: func(m *Manifest) { m.Endpoints.Events = "" }, + wantErr: "endpoints.events", + }, + { + name: "missing tools endpoint", + mutate: func(m *Manifest) { m.Endpoints.Tools = "" }, + wantErr: "endpoints.tools", + }, + { + name: "missing webhooks endpoint", + mutate: func(m *Manifest) { m.Endpoints.Webhooks = "" }, + wantErr: "endpoints.webhooks", + }, + { + name: "unknown category", + mutate: func(m *Manifest) { m.Categories = []string{"not-a-real-category"} }, + wantErr: "category", + }, + { + name: "unknown ui surface", + mutate: func(m *Manifest) { + m.UI.Pages[0].Surface = "not-a-real-surface" + }, + wantErr: "surface", + }, + { + name: "duplicate tool keys", + mutate: func(m *Manifest) { + m.Tools = append(m.Tools, Tool{Name: "slack_send_message", DisplayName: "Dup"}) + }, + wantErr: "duplicate tool", + }, + { + name: "duplicate webhook keys", + mutate: func(m *Manifest) { + m.Webhooks = append(m.Webhooks, Webhook{Key: "slack-events", Method: "POST"}) + }, + wantErr: "duplicate webhook", + }, + { + name: "unsupported api_version", + mutate: func(m *Manifest) { m.APIVersion = 2 }, + wantErr: "api_version", + }, + { + name: "empty version", + mutate: func(m *Manifest) { m.Version = "" }, + wantErr: "version", + }, + { + name: "version is a single dot", + mutate: func(m *Manifest) { m.Version = "." }, + wantErr: "version", + }, + { + name: "version contains a path separator", + mutate: func(m *Manifest) { m.Version = "1.0/0" }, + wantErr: "version", + }, + { + name: "version contains a traversal segment", + mutate: func(m *Manifest) { m.Version = "../../etc" }, + wantErr: "version", + }, + { + name: "version contains whitespace", + mutate: func(m *Manifest) { m.Version = "1.0.0 " }, + wantErr: "version", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := validManifest(t) + tt.mutate(m) + + err := m.Validate() + if err == nil { + t.Fatalf("Validate() expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestHasEvent_ExactSubscriptionMatches(t *testing.T) { + m := validManifest(t) + if !m.HasEvent("task.created") { + t.Fatal("HasEvent(\"task.created\") = false, want true (exact subscription)") + } +} + +func TestHasEvent_WildcardSubscriptionMatches(t *testing.T) { + // validManifestYAML declares capabilities.events: ["task.created", "task.*"] + m := validManifest(t) + if !m.HasEvent("task.state_changed") { + t.Fatal(`HasEvent("task.state_changed") = false, want true via "task.*" subscription`) + } +} + +func TestHasEvent_UndeclaredEventDoesNotMatch(t *testing.T) { + m := validManifest(t) + if m.HasEvent("office.comment.created") { + t.Fatal(`HasEvent("office.comment.created") = true, want false (not declared)`) + } +} + +func TestCanRead_DeclaredResourceAllowed(t *testing.T) { + m := validManifest(t) + if !m.CanRead("tasks") { + t.Fatal(`CanRead("tasks") = false, want true (declared in api_read)`) + } +} + +func TestCanRead_UndeclaredResourceDenied(t *testing.T) { + m := validManifest(t) + if m.CanRead("projects") { + t.Fatal(`CanRead("projects") = true, want false (not declared in api_read)`) + } +} + +func TestCanWrite_DeclaredResourceAllowed(t *testing.T) { + m := validManifest(t) + if !m.CanWrite("tasks") { + t.Fatal(`CanWrite("tasks") = false, want true (declared in api_write)`) + } +} + +func TestCanWrite_UndeclaredResourceDenied(t *testing.T) { + m := validManifest(t) + if m.CanWrite("comments") { + t.Fatal(`CanWrite("comments") = true, want false (not declared in api_write)`) + } +} + +func TestValidate_RootRelativeBundleAndStylesPass(t *testing.T) { + m := validManifest(t) + m.UI.Bundle = "/ui/bundle.js" + m.UI.Styles = []string{"/ui/bundle.css"} + + if err := m.Validate(); err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } +} + +func TestValidate_RejectsNonRootRelativeBundle(t *testing.T) { + m := validManifest(t) + m.UI.Bundle = "ui/bundle.js" + + err := m.Validate() + if err == nil { + t.Fatal("Validate() expected error for non-root-relative ui.bundle, got nil") + } + if !strings.Contains(err.Error(), "bundle") { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), "bundle") + } +} + +func TestValidate_RejectsNonRootRelativeStyle(t *testing.T) { + m := validManifest(t) + m.UI.Bundle = "/ui/bundle.js" + m.UI.Styles = []string{"ui/bundle.css"} + + err := m.Validate() + if err == nil { + t.Fatal("Validate() expected error for non-root-relative ui.styles entry, got nil") + } + if !strings.Contains(err.Error(), "styles") { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), "styles") + } +} + +func TestValidate_ManifestWithBundleAndNoPagesPasses(t *testing.T) { + m := validManifest(t) + m.UI.Pages = nil + m.UI.Bundle = "/ui/bundle.js" + + if err := m.Validate(); err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } +} + +func TestHasUIBundle_TrueWhenBundleSet(t *testing.T) { + m := validManifest(t) + m.UI.Bundle = "/ui/bundle.js" + + if !m.HasUIBundle() { + t.Fatal("HasUIBundle() = false, want true when ui.bundle is set") + } +} + +func TestHasUIBundle_FalseWhenBundleUnset(t *testing.T) { + m := validManifest(t) + + if m.HasUIBundle() { + t.Fatal("HasUIBundle() = true, want false when ui.bundle is unset") + } +} + +const managedManifestYAML = ` +id: "kandev-plugin-hello" +api_version: 1 +version: "1.0.0" +display_name: "Hello Plugin" +description: "A runtime-managed example plugin" +author: "kandev" +categories: ["tools"] + +runtime: + type: binary + executables: + linux-amd64: "server/plugin-linux-amd64" + darwin-arm64: "server/plugin-darwin-arm64" + windows-amd64: "server/plugin-windows-amd64.exe" + +capabilities: + state: true +` + +func managedManifest(t *testing.T) *Manifest { + t.Helper() + m, err := Parse([]byte(managedManifestYAML)) + if err != nil { + t.Fatalf("Parse() unexpected error: %v", err) + } + return m +} + +func TestValidate_ManagedManifestPasses(t *testing.T) { + m := managedManifest(t) + if err := m.Validate(); err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } +} + +func TestIsManaged_TrueForBinaryRuntime(t *testing.T) { + m := managedManifest(t) + if !m.IsManaged() { + t.Fatal("IsManaged() = false, want true for runtime.type=binary") + } +} + +func TestIsManaged_FalseForLegacyRemote(t *testing.T) { + m := validManifest(t) + if m.IsManaged() { + t.Fatal("IsManaged() = true, want false for a legacy base_url manifest") + } +} + +func TestExecutableFor_ReturnsMatchingPlatform(t *testing.T) { + m := managedManifest(t) + path, ok := m.ExecutableFor("linux", "amd64") + if !ok { + t.Fatal("ExecutableFor(\"linux\", \"amd64\") ok = false, want true") + } + if path != "server/plugin-linux-amd64" { + t.Fatalf("ExecutableFor(\"linux\", \"amd64\") = %q, want %q", path, "server/plugin-linux-amd64") + } +} + +func TestExecutableFor_ReturnsFalseForMissingPlatform(t *testing.T) { + m := managedManifest(t) + if _, ok := m.ExecutableFor("freebsd", "arm64"); ok { + t.Fatal("ExecutableFor(\"freebsd\", \"arm64\") ok = true, want false") + } +} + +func TestValidate_RejectsManagedManifestWithBaseURL(t *testing.T) { + m := managedManifest(t) + m.BaseURL = "http://localhost:9100" + + err := m.Validate() + if err == nil { + t.Fatal("Validate() expected error for runtime-managed manifest with base_url, got nil") + } + if !strings.Contains(err.Error(), "base_url") { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), "base_url") + } +} + +func TestValidate_RejectsManagedManifestWithEndpoints(t *testing.T) { + m := managedManifest(t) + m.Endpoints.Health = "/health" + + err := m.Validate() + if err == nil { + t.Fatal("Validate() expected error for runtime-managed manifest with endpoints, got nil") + } + if !strings.Contains(err.Error(), "endpoints") { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), "endpoints") + } +} + +func TestValidate_RejectsManagedManifestWithNoExecutables(t *testing.T) { + m := managedManifest(t) + m.Runtime.Executables = nil + + err := m.Validate() + if err == nil { + t.Fatal("Validate() expected error for runtime-managed manifest with no executables, got nil") + } + if !strings.Contains(err.Error(), "executables") { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), "executables") + } +} + +func TestValidate_RejectsManagedManifestWithTraversalExecutablePath(t *testing.T) { + m := managedManifest(t) + m.Runtime.Executables["linux-amd64"] = "../evil" + + err := m.Validate() + if err == nil { + t.Fatal("Validate() expected error for traversal executable path, got nil") + } + if !strings.Contains(err.Error(), "runtime.executables") { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), "runtime.executables") + } +} + +func TestValidate_RejectsManagedManifestWithAbsoluteExecutablePath(t *testing.T) { + m := managedManifest(t) + m.Runtime.Executables["linux-amd64"] = "/etc/passwd" + + err := m.Validate() + if err == nil { + t.Fatal("Validate() expected error for absolute executable path, got nil") + } + if !strings.Contains(err.Error(), "runtime.executables") { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), "runtime.executables") + } +} + +func TestValidate_RejectsUnknownRuntimeType(t *testing.T) { + m := managedManifest(t) + m.Runtime.Type = "docker" + + err := m.Validate() + if err == nil { + t.Fatal("Validate() expected error for unknown runtime.type, got nil") + } + if !strings.Contains(err.Error(), "runtime.type") { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), "runtime.type") + } +} diff --git a/apps/backend/internal/plugins/manifest/match.go b/apps/backend/internal/plugins/manifest/match.go new file mode 100644 index 0000000000..92248aac12 --- /dev/null +++ b/apps/backend/internal/plugins/manifest/match.go @@ -0,0 +1,32 @@ +package manifest + +import "strings" + +// MatchSubject reports whether subject matches pattern, where pattern may +// use "*" as a single-segment wildcard. Segments are dot-separated, mirroring +// event subject naming (e.g. "task.created", "agent.completed"). +// +// A pattern segment of "*" matches exactly one subject segment. All other +// pattern segments must match the corresponding subject segment exactly. +// The pattern and subject must have the same number of segments to match. +func MatchSubject(pattern, subject string) bool { + if pattern == subject { + return true + } + + patternSegments := strings.Split(pattern, ".") + subjectSegments := strings.Split(subject, ".") + if len(patternSegments) != len(subjectSegments) { + return false + } + + for i, seg := range patternSegments { + if seg == "*" { + continue + } + if seg != subjectSegments[i] { + return false + } + } + return true +} diff --git a/apps/backend/internal/plugins/manifest/match_test.go b/apps/backend/internal/plugins/manifest/match_test.go new file mode 100644 index 0000000000..8ac9f71620 --- /dev/null +++ b/apps/backend/internal/plugins/manifest/match_test.go @@ -0,0 +1,82 @@ +package manifest + +import "testing" + +func TestMatchSubject(t *testing.T) { + tests := []struct { + name string + pattern string + subject string + want bool + }{ + { + name: "exact match", + pattern: "task.created", + subject: "task.created", + want: true, + }, + { + name: "trailing wildcard matches one segment", + pattern: "task.*", + subject: "task.created", + want: true, + }, + { + name: "trailing wildcard matches a different single segment", + pattern: "office.*", + subject: "office.comment", + want: true, + }, + { + name: "wildcard does not match multiple segments", + pattern: "task.*", + subject: "task.state.changed", + want: false, + }, + { + name: "literal mismatch fails", + pattern: "task.created", + subject: "task.updated", + want: false, + }, + { + name: "different prefix fails even with wildcard", + pattern: "task.*", + subject: "office.created", + want: false, + }, + { + name: "cross-plugin event wildcard", + pattern: "plugin.kandev-plugin-jira.*", + subject: "plugin.kandev-plugin-jira.sync-completed", + want: true, + }, + { + name: "cross-plugin event wildcard wrong plugin id", + pattern: "plugin.kandev-plugin-jira.*", + subject: "plugin.kandev-plugin-slack.sync-completed", + want: false, + }, + { + name: "bare wildcard matches single-segment subject", + pattern: "*", + subject: "task", + want: true, + }, + { + name: "empty pattern and subject match", + pattern: "", + subject: "", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MatchSubject(tt.pattern, tt.subject) + if got != tt.want { + t.Fatalf("MatchSubject(%q, %q) = %v, want %v", tt.pattern, tt.subject, got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/plugins/manifest/runtime.go b/apps/backend/internal/plugins/manifest/runtime.go new file mode 100644 index 0000000000..d732134028 --- /dev/null +++ b/apps/backend/internal/plugins/manifest/runtime.go @@ -0,0 +1,21 @@ +package manifest + +// runtimeTypeBinary is the only currently supported Runtime.Type value. +const runtimeTypeBinary = "binary" + +// IsManaged reports whether the manifest declares a kandev-managed binary +// runtime (runtime.type: binary), as opposed to a legacy-remote manifest +// that registers an already-running service via base_url/endpoints. +func (m *Manifest) IsManaged() bool { + return m.Runtime.Type == runtimeTypeBinary +} + +// ExecutableFor returns the package-relative executable path declared for +// the given host platform (goos/goarch, e.g. "linux", "amd64"), and whether +// an entry exists. The returned path is relative to the extracted package +// root and already includes any platform-specific suffix (e.g. ".exe" on +// Windows); callers must not append anything to it. +func (m *Manifest) ExecutableFor(goos, goarch string) (string, bool) { + path, ok := m.Runtime.Executables[goos+"-"+goarch] + return path, ok +} diff --git a/apps/backend/internal/plugins/manifest/semver.go b/apps/backend/internal/plugins/manifest/semver.go new file mode 100644 index 0000000000..3db51d45f4 --- /dev/null +++ b/apps/backend/internal/plugins/manifest/semver.go @@ -0,0 +1,55 @@ +package manifest + +import ( + "strconv" + "strings" +) + +// CompareVersions performs a best-effort, dependency-free comparison of two +// dotted version strings (e.g. plugin manifest "version" values), for +// callers that need to pick the "greatest" of a small set of versions +// (sideload directory scanning, min_kandev_version enforcement) without a +// full semver parser. Each "."-separated segment is compared numerically +// when both sides parse as a non-negative integer; the moment a segment +// pair does not (a pre-release suffix like "1.0.0-beta", a malformed +// version, etc.), comparison falls back to a plain byte-wise string compare +// of the two original, full version strings so the function always returns +// a total order rather than panicking or guessing. +// +// Returns -1 if ab. +func CompareVersions(a, b string) int { + if a == b { + return 0 + } + as := strings.Split(a, ".") + bs := strings.Split(b, ".") + n := len(as) + if len(bs) > n { + n = len(bs) + } + for i := 0; i < n; i++ { + av, bv := segmentAt(as, i), segmentAt(bs, i) + ai, aErr := strconv.Atoi(av) + bi, bErr := strconv.Atoi(bv) + if aErr != nil || bErr != nil { + return strings.Compare(a, b) + } + if ai != bi { + if ai < bi { + return -1 + } + return 1 + } + } + return 0 +} + +// segmentAt returns segments[i], or "0" if i is out of range (a version +// with fewer dotted segments than its comparison partner) so "1.0" compares +// equal to "1.0.0" rather than being treated as unparseable. +func segmentAt(segments []string, i int) string { + if i < len(segments) { + return segments[i] + } + return "0" +} diff --git a/apps/backend/internal/plugins/manifest/semver_test.go b/apps/backend/internal/plugins/manifest/semver_test.go new file mode 100644 index 0000000000..8eb7b7b709 --- /dev/null +++ b/apps/backend/internal/plugins/manifest/semver_test.go @@ -0,0 +1,38 @@ +package manifest + +import "testing" + +func TestCompareVersions(t *testing.T) { + tests := []struct { + name string + a string + b string + want int + }{ + {name: "equal versions", a: "1.0.0", b: "1.0.0", want: 0}, + {name: "simple ascending patch", a: "1.0.0", b: "1.0.1", want: -1}, + {name: "simple descending patch", a: "1.0.1", b: "1.0.0", want: 1}, + { + name: "double-digit minor beats lexically-larger single-digit minor", + a: "9.0.0", b: "10.0.0", want: -1, + }, + { + name: "double-digit minor beats lexically-larger single-digit minor (reversed)", + a: "10.0.0", b: "9.0.0", want: 1, + }, + {name: "shorter version with fewer segments is less", a: "1.0", b: "1.0.1", want: -1}, + {name: "trailing zero segment is equal to the shorter form", a: "1.0", b: "1.0.0", want: 0}, + { + name: "non-numeric segment falls back to string compare", + a: "1.0.0-beta", b: "1.0.0", want: 1, // "1.0.0-beta" > "1.0.0" lexically + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CompareVersions(tt.a, tt.b); got != tt.want { + t.Fatalf("CompareVersions(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/plugins/manifest/validate.go b/apps/backend/internal/plugins/manifest/validate.go new file mode 100644 index 0000000000..78b964a3bd --- /dev/null +++ b/apps/backend/internal/plugins/manifest/validate.go @@ -0,0 +1,235 @@ +package manifest + +import ( + "errors" + "fmt" + "path" + "regexp" + "strings" +) + +// idPattern matches the required plugin id shape: lowercase alphanumerics, +// dots, underscores, and hyphens, starting with a lowercase alphanumeric. +var idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + +// supportedAPIVersion is the only api_version this kandev build accepts. +const supportedAPIVersion = 1 + +// validCategories are the allowed values for Manifest.Categories entries. +var validCategories = map[string]bool{ + "connector": true, + "automation": true, + "tools": true, + "analytics": true, +} + +// validUISurfaces are the allowed values for UIPage.Surface. +var validUISurfaces = map[string]bool{ + "settings": true, + "task-panel": true, + "main-nav": true, +} + +// Validate checks the manifest against the plugin registration rules +// described in docs/specs/plugins/spec.md. It returns nil if the manifest +// is well-formed, or a joined error describing every violation found. +func (m *Manifest) Validate() error { + var errs []error + errs = append(errs, m.validateIdentity()...) + errs = append(errs, m.validateVersion()...) + errs = append(errs, m.validateRuntimeType()...) + if m.IsManaged() { + errs = append(errs, m.validateManagedRuntime()...) + } else { + errs = append(errs, m.validateEndpoints()...) + } + errs = append(errs, m.validateCategories()...) + errs = append(errs, m.validateUIPages()...) + errs = append(errs, m.validateUIBundle()...) + errs = append(errs, m.validateTools()...) + errs = append(errs, m.validateWebhooks()...) + return errors.Join(errs...) +} + +// dotConfigSuffix mirrors store.dotConfigSuffix: an id ending in ".config" +// would make its ".yml" record filename collide with FSStore's +// ".config.yml" operator-config naming convention (store.isRecordFile), +// silently hiding the record from FSStore.List(). Rejected here too so +// registration fails fast instead of relying solely on the store-level +// guard. +const dotConfigSuffix = ".config" + +// validateIdentity checks id pattern and api_version. +func (m *Manifest) validateIdentity() []error { + var errs []error + if !idPattern.MatchString(m.ID) { + errs = append(errs, fmt.Errorf("invalid plugin id %q: must match %s", m.ID, idPattern.String())) + } else if strings.HasSuffix(m.ID, dotConfigSuffix) { + errs = append(errs, fmt.Errorf("invalid plugin id %q: must not end in %q", m.ID, dotConfigSuffix)) + } + if m.APIVersion != supportedAPIVersion { + errs = append(errs, fmt.Errorf("unsupported api_version %d: only %d is supported", m.APIVersion, supportedAPIVersion)) + } + return errs +} + +// validateVersion checks that version is a non-empty, path-safe single path +// segment: it is used as a filesystem directory name both at install time +// (pkgtar.extractPackage joins destRoot//) and to resolve an +// already-installed plugin's data on disk, so an empty, "."/".."-only, or +// separator-containing value would either produce a confusing deep failure +// (securejoin does reject traversal, but only after a misleading top-level +// error) or silently collide with another version's directory. +func (m *Manifest) validateVersion() []error { + v := m.Version + if v == "" { + return []error{errors.New("version must not be empty")} + } + if strings.TrimSpace(v) != v || strings.ContainsAny(v, " \t\n\r") { + return []error{fmt.Errorf("version %q must not contain whitespace", v)} + } + if strings.ContainsAny(v, "/\\") { + return []error{fmt.Errorf("version %q must be a single path segment (no \"/\" or \"\\\")", v)} + } + if v == "." || v == ".." { + return []error{fmt.Errorf("version %q must not be %q or %q", v, ".", "..")} + } + return nil +} + +// validateRuntimeType checks that runtime.type, when set, is the only +// currently supported value ("binary"). An empty runtime.type is valid: it +// means the manifest is legacy-remote (base_url/endpoints). +func (m *Manifest) validateRuntimeType() []error { + if m.Runtime.Type != "" && m.Runtime.Type != runtimeTypeBinary { + return []error{fmt.Errorf("runtime.type %q is invalid: only %q is supported", m.Runtime.Type, runtimeTypeBinary)} + } + return nil +} + +// validateManagedRuntime checks the rules that apply to a runtime-managed +// manifest (runtime.type: binary): at least one executables entry, every +// entry a clean package-relative path, and base_url/endpoints absent since +// a managed plugin is spawned by kandev rather than registered remotely. +func (m *Manifest) validateManagedRuntime() []error { + var errs []error + if len(m.Runtime.Executables) == 0 { + errs = append(errs, errors.New("runtime.executables must declare at least one entry when runtime.type is \"binary\"")) + } + for platformKey, execPath := range m.Runtime.Executables { + if err := validateRelativePackagePath(execPath); err != nil { + errs = append(errs, fmt.Errorf("runtime.executables[%q]: %w", platformKey, err)) + } + } + if m.BaseURL != "" { + errs = append(errs, errors.New("base_url must be empty for a runtime-managed plugin (runtime.type: binary)")) + } + if m.Endpoints != (Endpoints{}) { + errs = append(errs, errors.New("endpoints must be empty for a runtime-managed plugin (runtime.type: binary)")) + } + return errs +} + +// validateRelativePackagePath checks that p is a non-empty, clean, +// package-relative path: no leading "/" and no ".." segment. +func validateRelativePackagePath(p string) error { + if p == "" { + return errors.New("path must not be empty") + } + if path.IsAbs(p) { + return fmt.Errorf("path %q must be relative", p) + } + cleaned := path.Clean(p) + if cleaned != p || cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return fmt.Errorf("path %q must be a clean relative path with no \"..\" segments", p) + } + return nil +} + +// validateEndpoints checks base_url and the required endpoint paths. +func (m *Manifest) validateEndpoints() []error { + var errs []error + if m.BaseURL == "" { + errs = append(errs, errors.New("base_url is required")) + } + if m.Endpoints.Health == "" { + errs = append(errs, errors.New("endpoints.health is required")) + } + if m.Endpoints.Events == "" { + errs = append(errs, errors.New("endpoints.events is required")) + } + if m.Endpoints.Tools == "" { + errs = append(errs, errors.New("endpoints.tools is required")) + } + if m.Endpoints.Webhooks == "" { + errs = append(errs, errors.New("endpoints.webhooks is required")) + } + return errs +} + +// validateCategories checks each category against the known enum. +func (m *Manifest) validateCategories() []error { + var errs []error + for _, c := range m.Categories { + if !validCategories[c] { + errs = append(errs, fmt.Errorf("unknown category %q", c)) + } + } + return errs +} + +// validateUIPages checks each declared UI page's surface against the known +// enum (settings | task-panel | main-nav). +func (m *Manifest) validateUIPages() []error { + var errs []error + for _, p := range m.UI.Pages { + if !validUISurfaces[p.Surface] { + errs = append(errs, fmt.Errorf("unknown ui surface %q for page %q", p.Surface, p.Key)) + } + } + return errs +} + +// validateUIBundle checks that ui.bundle, if set, and every ui.styles entry +// are root-relative paths (start with "/"). ui.pages remain optional/valid +// on their own; a bundle-only manifest (no pages) is valid. +func (m *Manifest) validateUIBundle() []error { + var errs []error + if m.UI.Bundle != "" && !strings.HasPrefix(m.UI.Bundle, "/") { + errs = append(errs, fmt.Errorf("ui.bundle %q must be a root-relative path (start with \"/\")", m.UI.Bundle)) + } + for _, style := range m.UI.Styles { + if !strings.HasPrefix(style, "/") { + errs = append(errs, fmt.Errorf("ui.styles entry %q must be a root-relative path (start with \"/\")", style)) + } + } + return errs +} + +// validateTools checks for duplicate tool names. +func (m *Manifest) validateTools() []error { + seen := make(map[string]bool, len(m.Tools)) + var errs []error + for _, tool := range m.Tools { + if seen[tool.Name] { + errs = append(errs, fmt.Errorf("duplicate tool name %q", tool.Name)) + continue + } + seen[tool.Name] = true + } + return errs +} + +// validateWebhooks checks for duplicate webhook keys. +func (m *Manifest) validateWebhooks() []error { + seen := make(map[string]bool, len(m.Webhooks)) + var errs []error + for _, wh := range m.Webhooks { + if seen[wh.Key] { + errs = append(errs, fmt.Errorf("duplicate webhook key %q", wh.Key)) + continue + } + seen[wh.Key] = true + } + return errs +} diff --git a/apps/backend/internal/plugins/pkgtar/pkgtar.go b/apps/backend/internal/plugins/pkgtar/pkgtar.go new file mode 100644 index 0000000000..cc82b05585 --- /dev/null +++ b/apps/backend/internal/plugins/pkgtar/pkgtar.go @@ -0,0 +1,548 @@ +// Package pkgtar implements the kandev plugin package format described in +// docs/plans/plugins/GRPC-CONTRACT.md §6: a tar.gz archive containing +// manifest.yaml, a set of per-platform server/ executables, an optional +// ui/ bundle, and a checksums.txt covering every other file. Inspect reads +// only the manifest (no disk writes); Install verifies and atomically +// extracts a package into a per-plugin, per-version directory; Remove +// deletes an installed plugin's directory tree. +package pkgtar + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "runtime" + "strings" + + "github.com/kandev/kandev/internal/plugins/manifest" +) + +// pluginIDPattern mirrors the manifest id rule: a single clean path segment +// of lowercase alphanumerics, dots, underscores, and hyphens. Used as an +// inline barrier before any id is joined into a filesystem path. +var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + +const ( + manifestFileName = "manifest.yaml" + checksumsFileName = "checksums.txt" + checksumsSigFileName = "checksums.txt.sig" + + // maxManifestSize caps how much of manifest.yaml Inspect will read into + // memory before giving up. + maxManifestSize = 1 << 20 // 1 MiB +) + +// maxPackageFileSize caps any single file's size during Install, and +// maxPackageTotalSize caps the sum of all files' ACTUAL (decompressed) +// bytes copied during Install — see readArchive. Declared as vars (not +// consts), mirroring VerifySignature below, purely so this package's own +// tests can override them to exercise the cap logic against small fixtures +// instead of allocating real 200MB payloads; production defaults are +// unchanged. +var ( + maxPackageFileSize int64 = 200 << 20 // 200 MiB + maxPackageTotalSize int64 = 200 << 20 // 200 MiB +) + +// Sentinel errors returned (wrapped) by Install. Use errors.Is to check for +// a specific failure category. +var ( + // ErrManifestInvalid means manifest.yaml is missing, fails to parse, + // fails manifest.Validate(), or is not a runtime-managed manifest + // (manifest.IsManaged() == false). + ErrManifestInvalid = errors.New("pkgtar: manifest invalid") + // ErrMissingChecksums means the package has no checksums.txt entry. + ErrMissingChecksums = errors.New("pkgtar: missing checksums.txt") + // ErrUnlistedFile means the package contains a file that checksums.txt + // does not list. + ErrUnlistedFile = errors.New("pkgtar: file not listed in checksums.txt") + // ErrChecksumMismatch means a file's sha256 does not match the value + // recorded in checksums.txt. + ErrChecksumMismatch = errors.New("pkgtar: checksum mismatch") + // ErrPathTraversal means an archive entry's path escapes the package + // root (absolute path, "..", or a symlink/hardlink entry). + ErrPathTraversal = errors.New("pkgtar: unsafe archive path") + // ErrPlatformNotSupported means the manifest does not declare an + // executable for the current host's runtime.GOOS-runtime.GOARCH. + ErrPlatformNotSupported = errors.New("pkgtar: host platform not supported by package") + // ErrVersionExists means destRoot// already exists. + ErrVersionExists = errors.New("pkgtar: version already installed") +) + +// VerifySignature is an injectable hook for verifying checksums.txt.sig +// against the checksums.txt bytes it signs. It is nil by default, meaning +// signature verification is not performed: a present checksums.txt.sig +// entry with no VerifySignature configured leaves InstallResult.Signed +// false (unverified, not merely "present") — Install still succeeds since +// signing is optional. Set this (e.g. to an ed25519 verifier) to enable +// real verification; a non-nil error from it fails Install outright, and a +// nil error sets InstallResult.Signed=true. +var VerifySignature func(sig, checksums []byte) error + +// InstallResult describes the outcome of a successful Install. +type InstallResult struct { + // Manifest is the parsed, validated manifest.yaml of the installed + // package. + Manifest *manifest.Manifest + // InstallPath is the absolute path the package was extracted to: + // destRoot//. + InstallPath string + // Version is Manifest.Version, duplicated here for convenience. + Version string + // Signed reports whether the package included a checksums.txt.sig + // entry AND a configured VerifySignature verifier successfully + // verified it. A present-but-unverified signature (no verifier wired) + // or a missing signature both leave this false. See VerifySignature. + Signed bool +} + +// Inspect streams r (a kandev plugin tar.gz package), extracts only +// manifest.yaml into memory (capped at maxManifestSize), and parses + +// validates it. It performs no checksum verification and writes nothing to +// disk; it exists to preview a package's declared capabilities before +// installing it. +func Inspect(r io.Reader) (*manifest.Manifest, error) { + tr, closeReader, err := openTarGz(r) + if err != nil { + return nil, err + } + defer closeReader() + + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil, fmt.Errorf("pkgtar: %s not found in package", manifestFileName) + } + if err != nil { + return nil, fmt.Errorf("pkgtar: reading tar entry: %w", err) + } + if hdr.Typeflag != tar.TypeReg { + continue + } + name, err := cleanArchivePath(hdr.Name) + if err != nil || name != manifestFileName { + continue + } + return parseManifestEntry(tr, hdr.Size) + } +} + +// parseManifestEntry reads a manifest.yaml tar entry (capped at +// maxManifestSize) and parses + validates it. +func parseManifestEntry(tr *tar.Reader, size int64) (*manifest.Manifest, error) { + if size > maxManifestSize { + return nil, fmt.Errorf("pkgtar: %s exceeds max size of %d bytes", manifestFileName, maxManifestSize) + } + data, err := readCapped(tr, maxManifestSize) + if err != nil { + return nil, fmt.Errorf("pkgtar: reading %s: %w", manifestFileName, err) + } + m, err := manifest.Parse(data) + if err != nil { + return nil, fmt.Errorf("pkgtar: parsing %s: %w", manifestFileName, err) + } + if err := m.Validate(); err != nil { + return nil, fmt.Errorf("pkgtar: invalid %s: %w", manifestFileName, err) + } + return m, nil +} + +// Install verifies and atomically extracts a kandev plugin package (tar.gz, +// read fully from r) into destRoot///, per +// docs/plans/plugins/GRPC-CONTRACT.md §6: +// +// 1. Read the whole archive (size-capped, path-traversal-rejected). +// 2. Require checksums.txt covering every other file; verify every hash +// and reject unlisted files. +// 3. Parse + validate manifest.yaml; require it to be runtime-managed. +// 4. Require an executable declared for the current host platform. +// 5. Extract to a temp dir under destRoot//, chmod declared +// executables to 0755, then atomically rename into place. Fails with +// ErrVersionExists if destRoot// already exists. +func Install(r io.Reader, destRoot string) (*InstallResult, error) { + files, err := readArchive(r) + if err != nil { + return nil, err + } + + signed, err := verifyPackageIntegrity(files) + if err != nil { + return nil, err + } + + m, execPath, err := validateInstallManifest(files) + if err != nil { + return nil, err + } + + versionDir, err := extractPackage(destRoot, m, files, execPath) + if err != nil { + return nil, err + } + + return &InstallResult{ + Manifest: m, + InstallPath: versionDir, + Version: m.Version, + Signed: signed, + }, nil +} + +// verifyPackageIntegrity requires checksums.txt, verifies every listed +// file's hash, rejects unlisted files, and (when present) runs +// checksums.txt.sig through VerifySignature. signed is true only when a +// VerifySignature verifier is configured AND it actually succeeded — a +// present-but-unverified signature (no verifier wired, the default) is +// reported as unsigned rather than claiming a guarantee nothing checked. A +// present signature that fails verification fails Install outright (a +// tampered/invalid signature is worse than no signature at all); signing +// otherwise remains optional and never blocks an unsigned install. +func verifyPackageIntegrity(files map[string][]byte) (signed bool, err error) { + checksumsData, ok := files[checksumsFileName] + if !ok { + return false, ErrMissingChecksums + } + checks, err := parseChecksums(checksumsData) + if err != nil { + return false, err + } + if err := verifyChecksums(files, checks); err != nil { + return false, err + } + + sigData, hasSig := files[checksumsSigFileName] + if !hasSig || VerifySignature == nil { + return false, nil + } + if err := VerifySignature(sigData, checksumsData); err != nil { + return false, fmt.Errorf("pkgtar: signature verification failed: %w", err) + } + return true, nil +} + +// validateInstallManifest parses manifest.yaml out of files, validates it, +// requires it to be runtime-managed, and resolves the current host +// platform's executable path (which must itself be present in files). +func validateInstallManifest(files map[string][]byte) (*manifest.Manifest, string, error) { + manifestData, ok := files[manifestFileName] + if !ok { + return nil, "", fmt.Errorf("%w: missing %s", ErrManifestInvalid, manifestFileName) + } + m, err := manifest.Parse(manifestData) + if err != nil { + return nil, "", fmt.Errorf("%w: %v", ErrManifestInvalid, err) + } + if err := m.Validate(); err != nil { + return nil, "", fmt.Errorf("%w: %v", ErrManifestInvalid, err) + } + if !m.IsManaged() { + return nil, "", fmt.Errorf("%w: manifest is not runtime-managed (runtime.type must be \"binary\")", ErrManifestInvalid) + } + + execPath, ok := m.ExecutableFor(runtime.GOOS, runtime.GOARCH) + if !ok { + return nil, "", fmt.Errorf("%w: %s-%s", ErrPlatformNotSupported, runtime.GOOS, runtime.GOARCH) + } + if _, ok := files[execPath]; !ok { + return nil, "", fmt.Errorf("%w: declared executable %q not found in package", ErrManifestInvalid, execPath) + } + return m, execPath, nil +} + +// extractPackage writes files into a temp dir under destRoot//, chmods +// every declared runtime executable to 0755, and atomically renames the +// temp dir to destRoot//. It fails with ErrVersionExists if +// that directory already exists, and cleans up the temp dir on any error. +func extractPackage(destRoot string, m *manifest.Manifest, files map[string][]byte, hostExecPath string) (string, error) { + pluginDir, err := securejoin(destRoot, m.ID) + if err != nil { + return "", err + } + versionDir, err := securejoin(pluginDir, m.Version) + if err != nil { + return "", err + } + + if _, err := os.Stat(versionDir); err == nil { + return "", fmt.Errorf("%w: %s", ErrVersionExists, versionDir) + } else if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("pkgtar: checking install path: %w", err) + } + + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + return "", fmt.Errorf("pkgtar: creating plugin dir: %w", err) + } + tmpDir, err := os.MkdirTemp(pluginDir, ".tmp-*") + if err != nil { + return "", fmt.Errorf("pkgtar: creating temp dir: %w", err) + } + succeeded := false + defer func() { + if !succeeded { + _ = os.RemoveAll(tmpDir) + } + }() + + _ = hostExecPath // the whole executables set is chmodded, not just the host one + execSet := executablePaths(m) + if err := writePackageFiles(tmpDir, files, execSet); err != nil { + return "", err + } + + if err := os.Rename(tmpDir, versionDir); err != nil { + return "", fmt.Errorf("pkgtar: finalizing install: %w", err) + } + succeeded = true + return versionDir, nil +} + +// writePackageFiles writes every entry in files under root, creating +// parent directories as needed, and chmods entries listed in execSet to +// 0755. Every entry name is archive-controlled, so each destination path +// is re-validated with securejoin at this write sink even though +// cleanArchivePath already rejected unsafe names when the archive was +// read (defense in depth: this is the actual filesystem write). +func writePackageFiles(root string, files map[string][]byte, execSet map[string]bool) error { + for name, data := range files { + dest, err := securejoin(root, filepath.FromSlash(name)) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("pkgtar: creating dir for %s: %w", name, err) + } + mode := os.FileMode(0o644) + if execSet[name] { + mode = 0o755 + } + if err := os.WriteFile(dest, data, mode); err != nil { + return fmt.Errorf("pkgtar: writing %s: %w", name, err) + } + } + return nil +} + +// securejoin joins root and rel (a path derived from archive-controlled +// input: an archive entry name or a manifest-declared executable path) +// and verifies the result stays within root. It is the sink-level guard +// against path traversal / "zip slip": callers upstream (cleanArchivePath, +// manifest.Validate) already reject unsafe names, but this re-checks +// containment right before the filesystem operation that uses the path. +func securejoin(root, rel string) (string, error) { + if filepath.IsAbs(rel) { + return "", fmt.Errorf("%w: absolute path %q", ErrPathTraversal, rel) + } + dst := filepath.Join(root, rel) + cleanRoot := filepath.Clean(root) + relToRoot, err := filepath.Rel(cleanRoot, filepath.Clean(dst)) + if err != nil { + return "", fmt.Errorf("%w: %s", ErrPathTraversal, rel) + } + if relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(os.PathSeparator)) || filepath.IsAbs(relToRoot) { + return "", fmt.Errorf("%w: %s", ErrPathTraversal, rel) + } + return dst, nil +} + +// executablePaths returns the set of package-relative paths declared by +// any of the manifest's runtime.executables entries (not just the host +// platform's), so a multi-platform package has every included executable +// chmodded regardless of which one the host will run. +func executablePaths(m *manifest.Manifest) map[string]bool { + set := make(map[string]bool, len(m.Runtime.Executables)) + for _, p := range m.Runtime.Executables { + set[p] = true + } + return set +} + +// Remove deletes destRoot// entirely: every installed version plus the +// plugin's data directory. +func Remove(destRoot, id string) error { + if !pluginIDPattern.MatchString(id) { + return fmt.Errorf("pkgtar: invalid plugin id %q", id) + } + dir, err := securejoin(destRoot, id) + if err != nil { + return err + } + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("pkgtar: removing %s: %w", dir, err) + } + return nil +} + +// readArchive fully decompresses and un-tars r into a name->contents map, +// enforcing per-file and total size caps and rejecting any entry that is +// not a plain file at a clean, package-relative path (no symlinks, +// hardlinks, absolute paths, or ".."). +// +// Both caps are enforced against bytes ACTUALLY copied out of the +// decompressed stream, not the archive-declared hdr.Size: hdr.Size comes +// from the (attacker-controlled) tar header, and a gzip stream can be +// crafted to decompress to far more data than a small compressed payload +// suggests. Every entry's read is independently bounded by +// io.LimitReader inside readCapped regardless of what hdr.Size claims, and +// the running `total` is accumulated from len(data) — the real byte count +// — after that bounded read, so a mismatched/lied Size cannot smuggle more +// decompressed data past either cap. +func readArchive(r io.Reader) (map[string][]byte, error) { + tr, closeReader, err := openTarGz(r) + if err != nil { + return nil, err + } + defer closeReader() + + files := make(map[string][]byte) + var total int64 + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("pkgtar: reading tar entry: %w", err) + } + + switch hdr.Typeflag { + case tar.TypeDir: + continue + case tar.TypeReg: + // handled below + default: + return nil, fmt.Errorf("%w: entry %q has unsupported type (symlinks/hardlinks/devices are not allowed)", ErrPathTraversal, hdr.Name) + } + + name, err := cleanArchivePath(hdr.Name) + if err != nil { + return nil, err + } + if hdr.Size > maxPackageFileSize { + // Fast pre-check: reject an implausible declared size before + // even attempting the (still independently bounded) read. + return nil, fmt.Errorf("pkgtar: file %s exceeds max size of %d bytes", name, maxPackageFileSize) + } + data, err := readCapped(tr, maxPackageFileSize) + if err != nil { + return nil, fmt.Errorf("pkgtar: reading %s: %w", name, err) + } + total += int64(len(data)) + if total > maxPackageTotalSize { + return nil, fmt.Errorf("pkgtar: package exceeds max total size of %d bytes", maxPackageTotalSize) + } + files[name] = data + } + return files, nil +} + +// openTarGz wraps r in a gzip reader and a tar reader, returning a closer +// that releases the gzip reader. +func openTarGz(r io.Reader) (*tar.Reader, func(), error) { + gz, err := gzip.NewReader(r) + if err != nil { + return nil, nil, fmt.Errorf("pkgtar: invalid gzip stream: %w", err) + } + return tar.NewReader(gz), func() { _ = gz.Close() }, nil +} + +// readCapped reads at most maxSize+1 bytes from r and errors if that many +// were available, i.e. the entry exceeds maxSize bytes. +func readCapped(r io.Reader, maxSize int64) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, maxSize+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > maxSize { + return nil, fmt.Errorf("entry exceeds max size of %d bytes", maxSize) + } + return data, nil +} + +// cleanArchivePath cleans and validates a tar entry name: it must not be +// empty, absolute, contain a backslash, or escape the package root via +// "..". Backslashes are rejected outright rather than treated as a path +// separator: path.Clean/path.IsAbs only understand "/", so an entry like +// "server/..\\..\\escape.exe" would pass through unchanged (no leading ".." +// segment under "/"-only splitting) and could then be reinterpreted as an +// actual traversal by filepath.Join/filepath.Clean on a host where "\\" is +// a separator (Windows) — securejoin (the write-sink guard) runs +// filepath.Join too, so the same risk applies there. +func cleanArchivePath(name string) (string, error) { + if name == "" { + return "", fmt.Errorf("%w: empty entry name", ErrPathTraversal) + } + if strings.ContainsRune(name, '\\') { + return "", fmt.Errorf("%w: backslash in entry name %q", ErrPathTraversal, name) + } + if path.IsAbs(name) { + return "", fmt.Errorf("%w: absolute path %q", ErrPathTraversal, name) + } + cleaned := path.Clean(name) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || path.IsAbs(cleaned) { + return "", fmt.Errorf("%w: %q", ErrPathTraversal, name) + } + return cleaned, nil +} + +// parseChecksums parses "sha256 path" lines (sha256sum-style: hex digest, +// whitespace, path) into a map of clean path -> lowercase hex digest. +func parseChecksums(data []byte) (map[string]string, error) { + checks := make(map[string]string) + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) != 2 { + return nil, fmt.Errorf("pkgtar: malformed %s line: %q", checksumsFileName, line) + } + name, err := cleanArchivePath(fields[1]) + if err != nil { + return nil, err + } + checks[name] = strings.ToLower(fields[0]) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("pkgtar: reading %s: %w", checksumsFileName, err) + } + return checks, nil +} + +// verifyChecksums checks that every file in files (other than checksums.txt +// and checksums.txt.sig) is listed in checks with a matching sha256, that +// no file is unlisted, and that checksums.txt doesn't reference a file that +// isn't in the archive. +func verifyChecksums(files map[string][]byte, checks map[string]string) error { + for name, data := range files { + if name == checksumsFileName || name == checksumsSigFileName { + continue + } + want, ok := checks[name] + if !ok { + return fmt.Errorf("%w: %s", ErrUnlistedFile, name) + } + sum := sha256.Sum256(data) + got := hex.EncodeToString(sum[:]) + if got != want { + return fmt.Errorf("%w: %s", ErrChecksumMismatch, name) + } + } + for name := range checks { + if _, ok := files[name]; !ok { + return fmt.Errorf("pkgtar: %s references missing file %s", checksumsFileName, name) + } + } + return nil +} diff --git a/apps/backend/internal/plugins/pkgtar/pkgtar_test.go b/apps/backend/internal/plugins/pkgtar/pkgtar_test.go new file mode 100644 index 0000000000..ab06eadb7b --- /dev/null +++ b/apps/backend/internal/plugins/pkgtar/pkgtar_test.go @@ -0,0 +1,758 @@ +package pkgtar + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest" +) + +// hostPlatformKey is the "-" key the current test process +// runs under, matching manifest.ExecutableFor(runtime.GOOS, runtime.GOARCH). +var hostPlatformKey = runtime.GOOS + "-" + runtime.GOARCH + +const managedManifestTemplate = ` +id: "kandev-plugin-hello" +api_version: 1 +version: %q +display_name: "Hello Plugin" +description: "A runtime-managed example plugin" +author: "kandev" +categories: ["tools"] + +runtime: + type: binary + executables: + %s: "server/plugin-%s" + +capabilities: + state: true +` + +// managedManifestYAML returns a valid runtime-managed manifest YAML for the +// given version, declaring exactly one executable for the host platform. +func managedManifestYAML(version string) []byte { + return []byte(fmt.Sprintf(managedManifestTemplate, version, hostPlatformKey, hostPlatformKey)) +} + +// buildValidFiles returns the file set for a minimal, valid, multi-file +// package: manifest.yaml, the host-platform executable, and a UI asset. +func buildValidFiles(version string) map[string][]byte { + return map[string][]byte{ + "manifest.yaml": managedManifestYAML(version), + "server/plugin-" + hostPlatformKey: []byte("#!/bin/sh\necho hello\n"), + "ui/bundle.js": []byte("export default {};\n"), + "assets/icon.svg": []byte(""), + } +} + +// writeValidPackage builds a valid signed-less package and returns its +// tar.gz bytes. +func writeValidPackage(t *testing.T, version string) []byte { + t.Helper() + var buf bytes.Buffer + if err := pkgtartest.WritePackage(&buf, buildValidFiles(version)); err != nil { + t.Fatalf("pkgtartest.WritePackage() unexpected error: %v", err) + } + return buf.Bytes() +} + +func TestInstall_HappyPathMultiFileHostPlatform(t *testing.T) { + destRoot := t.TempDir() + pkg := writeValidPackage(t, "1.0.0") + + result, err := Install(bytes.NewReader(pkg), destRoot) + if err != nil { + t.Fatalf("Install() unexpected error: %v", err) + } + + wantPath := filepath.Join(destRoot, "kandev-plugin-hello", "1.0.0") + if result.InstallPath != wantPath { + t.Fatalf("InstallPath = %q, want %q", result.InstallPath, wantPath) + } + if result.Version != "1.0.0" { + t.Fatalf("Version = %q, want %q", result.Version, "1.0.0") + } + if result.Signed { + t.Fatal("Signed = true, want false (no checksums.txt.sig in package)") + } + if result.Manifest == nil || result.Manifest.ID != "kandev-plugin-hello" { + t.Fatalf("Manifest = %+v, want ID kandev-plugin-hello", result.Manifest) + } + + for _, rel := range []string{"manifest.yaml", "server/plugin-" + hostPlatformKey, "ui/bundle.js", "assets/icon.svg", "checksums.txt"} { + if _, err := os.Stat(filepath.Join(wantPath, rel)); err != nil { + t.Fatalf("expected extracted file %s: %v", rel, err) + } + } +} + +func TestInstall_ChmodsExecutableTo0755(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file mode bits are not meaningful on windows") + } + destRoot := t.TempDir() + pkg := writeValidPackage(t, "1.0.0") + + result, err := Install(bytes.NewReader(pkg), destRoot) + if err != nil { + t.Fatalf("Install() unexpected error: %v", err) + } + + execPath := filepath.Join(result.InstallPath, "server", "plugin-"+hostPlatformKey) + info, err := os.Stat(execPath) + if err != nil { + t.Fatalf("stat executable: %v", err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("executable mode = %o, want %o", info.Mode().Perm(), 0o755) + } + + // A non-executable file should not have been made executable. + assetPath := filepath.Join(result.InstallPath, "assets", "icon.svg") + assetInfo, err := os.Stat(assetPath) + if err != nil { + t.Fatalf("stat asset: %v", err) + } + if assetInfo.Mode().Perm() == 0o755 { + t.Fatalf("asset mode = %o, want an unexecuted mode (e.g. 0644)", assetInfo.Mode().Perm()) + } +} + +// packageWithFakeSig returns buildValidFiles(version) plus an unverifiable +// checksums.txt.sig entry (its bytes don't need to be a real signature +// unless the test also wires a VerifySignature that inspects them). +func packageWithFakeSig(version string) map[string][]byte { + files := buildValidFiles(version) + withSig := make(map[string][]byte, len(files)+1) + for name, data := range files { + withSig[name] = data + } + withSig["checksums.txt.sig"] = []byte("fake-signature-bytes") + return withSig +} + +// TestInstall_SignedFalseWhenSigPresentButUnverified pins the fix for +// Install claiming Signed=true just because a checksums.txt.sig entry +// exists, even with no VerifySignature verifier configured (the default). +// Signed must only be true once a verifier actually ran and succeeded — a +// present-but-unverified signature is not a "signed" guarantee. +func TestInstall_SignedFalseWhenSigPresentButUnverified(t *testing.T) { + destRoot := t.TempDir() + pkg := buildRawPackageWithChecksums(t, packageWithFakeSig("1.0.0")) + + result, err := Install(bytes.NewReader(pkg), destRoot) + if err != nil { + t.Fatalf("Install() unexpected error: %v", err) + } + if result.Signed { + t.Fatal("Signed = true, want false: checksums.txt.sig is present but no VerifySignature verifier is configured") + } +} + +// TestInstall_SignedTrueWhenSignatureVerifies pins that Signed=true only +// once a configured VerifySignature verifier actually ran and succeeded. +func TestInstall_SignedTrueWhenSignatureVerifies(t *testing.T) { + destRoot := t.TempDir() + pkg := buildRawPackageWithChecksums(t, packageWithFakeSig("1.0.0")) + + var gotSig, gotChecksums []byte + VerifySignature = func(sig, checksums []byte) error { + gotSig, gotChecksums = sig, checksums + return nil + } + t.Cleanup(func() { VerifySignature = nil }) + + result, err := Install(bytes.NewReader(pkg), destRoot) + if err != nil { + t.Fatalf("Install() unexpected error: %v", err) + } + if !result.Signed { + t.Fatal("Signed = false, want true: a configured VerifySignature verifier succeeded") + } + if string(gotSig) != "fake-signature-bytes" { + t.Fatalf("VerifySignature sig arg = %q, want the checksums.txt.sig bytes", gotSig) + } + if len(gotChecksums) == 0 { + t.Fatal("VerifySignature checksums arg was empty, want the checksums.txt bytes") + } +} + +// TestInstall_ErrorsWhenSignatureVerificationFails pins that Install fails +// outright (package not extracted) when a configured VerifySignature +// verifier rejects the signature — this is a "signed but tampered/invalid" +// package, not merely "unsigned". +func TestInstall_ErrorsWhenSignatureVerificationFails(t *testing.T) { + destRoot := t.TempDir() + pkg := buildRawPackageWithChecksums(t, packageWithFakeSig("1.0.0")) + + VerifySignature = func(_, _ []byte) error { return errors.New("bad signature") } + t.Cleanup(func() { VerifySignature = nil }) + + _, err := Install(bytes.NewReader(pkg), destRoot) + if err == nil { + t.Fatal("Install() expected error when signature verification fails, got nil") + } + assertNoPartialInstall(t, destRoot, "kandev-plugin-hello") +} + +func TestInspect_ReturnsManifestWithoutSideEffects(t *testing.T) { + pkg := writeValidPackage(t, "2.3.4") + watchDir := t.TempDir() + + m, err := Inspect(bytes.NewReader(pkg)) + if err != nil { + t.Fatalf("Inspect() unexpected error: %v", err) + } + if m.ID != "kandev-plugin-hello" { + t.Fatalf("m.ID = %q, want %q", m.ID, "kandev-plugin-hello") + } + if m.Version != "2.3.4" { + t.Fatalf("m.Version = %q, want %q", m.Version, "2.3.4") + } + + entries, err := os.ReadDir(watchDir) + if err != nil { + t.Fatalf("ReadDir() unexpected error: %v", err) + } + if len(entries) != 0 { + t.Fatalf("Inspect() wrote %d entries to an unrelated dir, want 0 (no disk side effects)", len(entries)) + } +} + +func TestInspect_RejectsInvalidManifest(t *testing.T) { + files := map[string][]byte{ + "manifest.yaml": []byte("id: \"Bad Id!\"\napi_version: 1\nversion: \"1.0.0\"\n"), + } + var buf bytes.Buffer + if err := pkgtartest.WritePackage(&buf, files); err != nil { + t.Fatalf("pkgtartest.WritePackage() unexpected error: %v", err) + } + + if _, err := Inspect(bytes.NewReader(buf.Bytes())); err == nil { + t.Fatal("Inspect() expected error for invalid manifest, got nil") + } +} + +func TestInstall_BadChecksumRejected(t *testing.T) { + destRoot := t.TempDir() + files := buildValidFiles("1.0.0") + pkg := buildRawPackageWithBadChecksum(t, files, "ui/bundle.js") + + _, err := Install(bytes.NewReader(pkg), destRoot) + if err == nil { + t.Fatal("Install() expected error for bad checksum, got nil") + } + if !errors.Is(err, ErrChecksumMismatch) { + t.Fatalf("Install() error = %v, want ErrChecksumMismatch", err) + } + assertNoPartialInstall(t, destRoot, "kandev-plugin-hello") +} + +func TestInstall_MissingChecksumsRejected(t *testing.T) { + destRoot := t.TempDir() + files := buildValidFiles("1.0.0") + pkg := buildRawPackage(t, files) // no checksums.txt entry + + _, err := Install(bytes.NewReader(pkg), destRoot) + if err == nil { + t.Fatal("Install() expected error for missing checksums.txt, got nil") + } + if !errors.Is(err, ErrMissingChecksums) { + t.Fatalf("Install() error = %v, want ErrMissingChecksums", err) + } +} + +func TestInstall_UnlistedFileRejected(t *testing.T) { + destRoot := t.TempDir() + files := buildValidFiles("1.0.0") + + var base bytes.Buffer + baseFiles := map[string][]byte{ + "manifest.yaml": files["manifest.yaml"], + "server/plugin-" + hostPlatformKey: files["server/plugin-"+hostPlatformKey], + } + if err := pkgtartest.WritePackage(&base, baseFiles); err != nil { + t.Fatalf("pkgtartest.WritePackage() unexpected error: %v", err) + } + extracted := extractAll(t, base.Bytes()) + + // Add a file that is NOT covered by checksums.txt. + withExtra := map[string][]byte{ + "manifest.yaml": files["manifest.yaml"], + "server/plugin-" + hostPlatformKey: files["server/plugin-"+hostPlatformKey], + "ui/bundle.js": files["ui/bundle.js"], // unlisted + "checksums.txt": extracted["checksums.txt"], + } + pkg := buildRawPackage(t, withExtra) + + _, err := Install(bytes.NewReader(pkg), destRoot) + if err == nil { + t.Fatal("Install() expected error for unlisted file, got nil") + } + if !errors.Is(err, ErrUnlistedFile) { + t.Fatalf("Install() error = %v, want ErrUnlistedFile", err) + } +} + +func TestInstall_TraversalPathRejected(t *testing.T) { + destRoot := t.TempDir() + files := buildValidFiles("1.0.0") + files["../evil.txt"] = []byte("pwned") + pkg := buildRawPackageWithChecksums(t, files) + + _, err := Install(bytes.NewReader(pkg), destRoot) + if err == nil { + t.Fatal("Install() expected error for path traversal entry, got nil") + } + if !errors.Is(err, ErrPathTraversal) { + t.Fatalf("Install() error = %v, want ErrPathTraversal", err) + } +} + +func TestInstall_AbsolutePathRejected(t *testing.T) { + destRoot := t.TempDir() + files := buildValidFiles("1.0.0") + files["/etc/passwd"] = []byte("pwned") + pkg := buildRawPackageWithChecksums(t, files) + + _, err := Install(bytes.NewReader(pkg), destRoot) + if err == nil { + t.Fatal("Install() expected error for absolute path entry, got nil") + } + if !errors.Is(err, ErrPathTraversal) { + t.Fatalf("Install() error = %v, want ErrPathTraversal", err) + } +} + +// TestInstall_BackslashTraversalRejected pins the fix for a Windows-style +// traversal entry: cleanArchivePath previously only treated "/" as a path +// separator, so "server/..\\..\\escape.exe" passed path.Clean unchanged (it +// has no leading ".." segment under POSIX splitting) and would later be +// interpreted by filepath.Join/filepath.Clean on Windows as an actual +// escape via the backslash-delimited "..\\.." segments. +func TestInstall_BackslashTraversalRejected(t *testing.T) { + destRoot := t.TempDir() + files := buildValidFiles("1.0.0") + files[`server/..\..\escape.exe`] = []byte("pwned") + pkg := buildRawPackageWithChecksums(t, files) + + _, err := Install(bytes.NewReader(pkg), destRoot) + if err == nil { + t.Fatal("Install() expected error for a backslash-traversal entry, got nil") + } + if !errors.Is(err, ErrPathTraversal) { + t.Fatalf("Install() error = %v, want ErrPathTraversal", err) + } +} + +func TestCleanArchivePath_RejectsBackslash(t *testing.T) { + for _, name := range []string{ + `server\evil.exe`, + `server/..\..\escape.exe`, + `..\evil.exe`, + } { + if _, err := cleanArchivePath(name); err == nil { + t.Fatalf("cleanArchivePath(%q) expected error, got nil", name) + } else if !errors.Is(err, ErrPathTraversal) { + t.Fatalf("cleanArchivePath(%q) error = %v, want ErrPathTraversal", name, err) + } + } +} + +// withSmallSizeCaps temporarily overrides maxPackageFileSize/ +// maxPackageTotalSize (test-only vars, see pkgtar.go) so cap-enforcement +// tests can use small fixtures instead of allocating real 200MB payloads. +func withSmallSizeCaps(t *testing.T, perFile, total int64) { + t.Helper() + origFile, origTotal := maxPackageFileSize, maxPackageTotalSize + maxPackageFileSize, maxPackageTotalSize = perFile, total + t.Cleanup(func() { maxPackageFileSize, maxPackageTotalSize = origFile, origTotal }) +} + +func TestInstall_SingleFileExceedingPerFileCapRejected(t *testing.T) { + withSmallSizeCaps(t, 100, 10_000) + destRoot := t.TempDir() + + files := buildValidFiles("1.0.0") + files["assets/icon.svg"] = bytes.Repeat([]byte("A"), 200) // exceeds the 100-byte per-file cap + pkg := buildRawPackageWithChecksums(t, files) + + _, err := Install(bytes.NewReader(pkg), destRoot) + if err == nil { + t.Fatal("Install() expected error for a file exceeding the per-file size cap, got nil") + } +} + +// TestReadArchive_TotalSizeCapUsesActualBytesCopied pins the fix that the +// running total is accumulated from bytes actually copied out of the +// decompressed stream (readCapped's return value), not the archive-declared +// hdr.Size: three files that individually fit under the per-file cap but +// whose combined ACTUAL bytes exceed the total cap must still be rejected. +func TestReadArchive_TotalSizeCapUsesActualBytesCopied(t *testing.T) { + withSmallSizeCaps(t, 1000, 2000) + destRoot := t.TempDir() + + files := buildValidFiles("1.0.0") + files["assets/a"] = bytes.Repeat([]byte("A"), 900) + files["assets/b"] = bytes.Repeat([]byte("B"), 900) + files["assets/c"] = bytes.Repeat([]byte("C"), 900) // pushes the real total past the 2000-byte cap + pkg := buildRawPackageWithChecksums(t, files) + + _, err := Install(bytes.NewReader(pkg), destRoot) + if err == nil { + t.Fatal("Install() expected error once actual bytes copied exceed the total size cap, got nil") + } +} + +func TestReadArchive_UnderTotalCapSucceeds(t *testing.T) { + withSmallSizeCaps(t, 1000, 100_000) + destRoot := t.TempDir() + + pkg := writeValidPackage(t, "1.0.0") // well under 100_000 bytes total + if _, err := Install(bytes.NewReader(pkg), destRoot); err != nil { + t.Fatalf("Install() unexpected error under the total size cap: %v", err) + } +} + +func TestInstall_SymlinkEntryRejected(t *testing.T) { + destRoot := t.TempDir() + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + files := buildValidFiles("1.0.0") + for name, data := range files { + writeRawEntry(t, tw, name, data, tar.TypeReg) + } + writeRawEntry(t, tw, "server/evil-link", nil, tar.TypeSymlink) + checksums := checksumsFor(files) + writeRawEntry(t, tw, "checksums.txt", checksums, tar.TypeReg) + if err := tw.Close(); err != nil { + t.Fatalf("tar Close() error: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gzip Close() error: %v", err) + } + + _, err := Install(bytes.NewReader(buf.Bytes()), destRoot) + if err == nil { + t.Fatal("Install() expected error for symlink entry, got nil") + } +} + +func TestInstall_MissingHostPlatformKeyRejected(t *testing.T) { + destRoot := t.TempDir() + otherPlatform := "plan9-arm" + manifestYAML := []byte(fmt.Sprintf(managedManifestTemplate, "1.0.0", otherPlatform, otherPlatform)) + files := map[string][]byte{ + "manifest.yaml": manifestYAML, + "server/plugin-" + otherPlatform: []byte("binary"), + } + pkg := buildRawPackageWithChecksums(t, files) + + _, err := Install(bytes.NewReader(pkg), destRoot) + if err == nil { + t.Fatal("Install() expected error for missing host platform key, got nil") + } + if !errors.Is(err, ErrPlatformNotSupported) { + t.Fatalf("Install() error = %v, want ErrPlatformNotSupported", err) + } +} + +func TestInstall_DuplicateVersionRejected(t *testing.T) { + destRoot := t.TempDir() + pkg := writeValidPackage(t, "1.0.0") + + if _, err := Install(bytes.NewReader(pkg), destRoot); err != nil { + t.Fatalf("first Install() unexpected error: %v", err) + } + + pkg2 := writeValidPackage(t, "1.0.0") + _, err := Install(bytes.NewReader(pkg2), destRoot) + if err == nil { + t.Fatal("second Install() expected ErrVersionExists, got nil") + } + if !errors.Is(err, ErrVersionExists) { + t.Fatalf("Install() error = %v, want ErrVersionExists", err) + } +} + +func TestInstall_InvalidManifestRejected(t *testing.T) { + destRoot := t.TempDir() + files := map[string][]byte{ + "manifest.yaml": []byte("id: \"Bad Id!\"\napi_version: 1\nversion: \"1.0.0\"\n"), + } + pkg := buildRawPackageWithChecksums(t, files) + + _, err := Install(bytes.NewReader(pkg), destRoot) + if err == nil { + t.Fatal("Install() expected error for invalid manifest, got nil") + } + if !errors.Is(err, ErrManifestInvalid) { + t.Fatalf("Install() error = %v, want ErrManifestInvalid", err) + } +} + +func TestInstall_LegacyRemoteManifestRejected(t *testing.T) { + destRoot := t.TempDir() + legacyManifest := []byte(` +id: "kandev-plugin-legacy" +api_version: 1 +version: "1.0.0" +display_name: "Legacy" +description: "legacy remote plugin" +author: "kandev" +categories: ["tools"] +base_url: "http://localhost:9100" +endpoints: + health: "/health" + events: "/events" + tools: "/tools/{tool_name}" + webhooks: "/webhooks/{webhook_key}" +`) + files := map[string][]byte{"manifest.yaml": legacyManifest} + pkg := buildRawPackageWithChecksums(t, files) + + _, err := Install(bytes.NewReader(pkg), destRoot) + if err == nil { + t.Fatal("Install() expected error for legacy-remote (non-managed) manifest, got nil") + } + if !errors.Is(err, ErrManifestInvalid) { + t.Fatalf("Install() error = %v, want ErrManifestInvalid", err) + } +} + +func TestInstall_AtomicNoPartialDirOnFailure(t *testing.T) { + destRoot := t.TempDir() + files := buildValidFiles("1.0.0") + pkg := buildRawPackageWithBadChecksum(t, files, "manifest.yaml") + + if _, err := Install(bytes.NewReader(pkg), destRoot); err == nil { + t.Fatal("Install() expected error, got nil") + } + assertNoPartialInstall(t, destRoot, "kandev-plugin-hello") +} + +func TestRemove_DeletesAllVersionsAndData(t *testing.T) { + destRoot := t.TempDir() + pkg1 := writeValidPackage(t, "1.0.0") + if _, err := Install(bytes.NewReader(pkg1), destRoot); err != nil { + t.Fatalf("Install() unexpected error: %v", err) + } + pkg2 := writeValidPackage(t, "2.0.0") + if _, err := Install(bytes.NewReader(pkg2), destRoot); err != nil { + t.Fatalf("Install() unexpected error: %v", err) + } + + pluginDir := filepath.Join(destRoot, "kandev-plugin-hello") + dataDir := filepath.Join(pluginDir, "data") + if err := os.MkdirAll(dataDir, 0o755); err != nil { + t.Fatalf("MkdirAll(data) error: %v", err) + } + + if err := Remove(destRoot, "kandev-plugin-hello"); err != nil { + t.Fatalf("Remove() unexpected error: %v", err) + } + + if _, err := os.Stat(pluginDir); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("plugin dir still exists after Remove(): err = %v", err) + } +} + +func TestRemove_RejectsUnsafeID(t *testing.T) { + destRoot := t.TempDir() + if err := Remove(destRoot, "../escape"); err == nil { + t.Fatal("Remove() expected error for unsafe id, got nil") + } +} + +// --- test helpers: raw archive construction for negative-path scenarios --- + +func buildRawPackage(t *testing.T, files map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for name, data := range files { + writeRawEntry(t, tw, name, data, tar.TypeReg) + } + if err := tw.Close(); err != nil { + t.Fatalf("tar Close() error: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gzip Close() error: %v", err) + } + return buf.Bytes() +} + +func buildRawPackageWithChecksums(t *testing.T, files map[string][]byte) []byte { + t.Helper() + withChecksums := make(map[string][]byte, len(files)+1) + for name, data := range files { + withChecksums[name] = data + } + withChecksums["checksums.txt"] = checksumsFor(files) + return buildRawPackage(t, withChecksums) +} + +func buildRawPackageWithBadChecksum(t *testing.T, files map[string][]byte, corrupt string) []byte { + t.Helper() + checksums := checksumsFor(files) + // Flip a character in the recorded hash for `corrupt` so verification fails. + lines := strings.Split(string(checksums), "\n") + for i, line := range lines { + if strings.HasSuffix(line, " "+corrupt) { + lines[i] = "0000000000000000000000000000000000000000000000000000000000000000 " + corrupt + } + } + withChecksums := make(map[string][]byte, len(files)+1) + for name, data := range files { + withChecksums[name] = data + } + withChecksums["checksums.txt"] = []byte(strings.Join(lines, "\n")) + return buildRawPackage(t, withChecksums) +} + +// checksumsFor computes "sha256 path" lines for every entry in files, +// excluding checksums.txt and checksums.txt.sig themselves (per the +// package format: checksums.txt lists every OTHER file). +func checksumsFor(files map[string][]byte) []byte { + var buf bytes.Buffer + for name, data := range files { + if name == "checksums.txt" || name == "checksums.txt.sig" { + continue + } + sum := sha256.Sum256(data) + fmt.Fprintf(&buf, "%s %s\n", hex.EncodeToString(sum[:]), name) + } + return buf.Bytes() +} + +func writeRawEntry(t *testing.T, tw *tar.Writer, name string, data []byte, typeflag byte) { + t.Helper() + hdr := &tar.Header{ + Name: name, + Typeflag: typeflag, + Mode: 0o644, + Size: int64(len(data)), + } + if typeflag == tar.TypeSymlink { + hdr.Linkname = "/etc/passwd" + hdr.Size = 0 + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("WriteHeader(%s) error: %v", name, err) + } + if len(data) > 0 { + if _, err := tw.Write(data); err != nil { + t.Fatalf("Write(%s) error: %v", name, err) + } + } +} + +// extractAll un-gzips and un-tars pkg into a name->data map, for tests that +// need to inspect or reuse a generated checksums.txt. +func extractAll(t *testing.T, pkg []byte) map[string][]byte { + t.Helper() + gz, err := gzip.NewReader(bytes.NewReader(pkg)) + if err != nil { + t.Fatalf("gzip.NewReader() error: %v", err) + } + defer func() { _ = gz.Close() }() + tr := tar.NewReader(gz) + out := make(map[string][]byte) + for { + hdr, err := tr.Next() + if err != nil { + break + } + if hdr.Typeflag != tar.TypeReg { + continue + } + data, err := io.ReadAll(tr) + if err != nil { + t.Fatalf("reading tar entry %s: %v", hdr.Name, err) + } + out[hdr.Name] = data + } + return out +} + +func assertNoPartialInstall(t *testing.T, destRoot, id string) { + t.Helper() + pluginDir := filepath.Join(destRoot, id) + entries, err := os.ReadDir(pluginDir) + if errors.Is(err, fs.ErrNotExist) { + return + } + if err != nil { + t.Fatalf("ReadDir(%s) error: %v", pluginDir, err) + } + for _, e := range entries { + if !strings.HasPrefix(e.Name(), ".tmp-") { + t.Fatalf("found non-tmp entry %q after failed Install(), want no partial version dir", e.Name()) + } + } +} + +func TestSecureJoin_RejectsTraversalRelativePath(t *testing.T) { + root := t.TempDir() + + _, err := securejoin(root, "../evil.txt") + if err == nil { + t.Fatal("securejoin() expected error for traversal path, got nil") + } + if !errors.Is(err, ErrPathTraversal) { + t.Fatalf("securejoin() error = %v, want ErrPathTraversal", err) + } +} + +func TestSecureJoin_RejectsDeeplyNestedTraversal(t *testing.T) { + root := t.TempDir() + + _, err := securejoin(root, "server/../../evil.txt") + if err == nil { + t.Fatal("securejoin() expected error for nested traversal path, got nil") + } + if !errors.Is(err, ErrPathTraversal) { + t.Fatalf("securejoin() error = %v, want ErrPathTraversal", err) + } +} + +func TestSecureJoin_RejectsAbsolutePath(t *testing.T) { + root := t.TempDir() + + _, err := securejoin(root, "/etc/passwd") + if err == nil { + t.Fatal("securejoin() expected error for absolute path, got nil") + } + if !errors.Is(err, ErrPathTraversal) { + t.Fatalf("securejoin() error = %v, want ErrPathTraversal", err) + } +} + +func TestSecureJoin_AllowsCleanRelativePath(t *testing.T) { + root := t.TempDir() + + got, err := securejoin(root, filepath.Join("server", "plugin-linux-amd64")) + if err != nil { + t.Fatalf("securejoin() unexpected error: %v", err) + } + want := filepath.Join(root, "server", "plugin-linux-amd64") + if got != want { + t.Fatalf("securejoin() = %q, want %q", got, want) + } +} diff --git a/apps/backend/internal/plugins/pkgtar/pkgtartest/pkgtartest.go b/apps/backend/internal/plugins/pkgtar/pkgtartest/pkgtartest.go new file mode 100644 index 0000000000..8b3feffd65 --- /dev/null +++ b/apps/backend/internal/plugins/pkgtar/pkgtartest/pkgtartest.go @@ -0,0 +1,99 @@ +// Package pkgtartest builds valid kandev plugin tar.gz packages for tests +// and fixtures. It is a small, dependency-free builder deliberately kept +// separate from production pkgtar code so other packages (fixture +// generators, e2e helpers) can import it without pulling in *_test.go +// files. +package pkgtartest + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "sort" + "strings" +) + +// checksumsFileName mirrors pkgtar.checksumsFileName; duplicated here to +// avoid an import cycle (pkgtar's own tests import this package). +const checksumsFileName = "checksums.txt" + +// WritePackage writes a valid kandev plugin tar.gz package to w. files maps +// archive-relative path (e.g. "manifest.yaml", "server/plugin-linux-amd64") +// to its contents. WritePackage computes a checksums.txt covering every +// entry in files and appends it as the final archive entry; callers must +// not include "checksums.txt" in files themselves. +// +// Entries under "server/" are written with mode 0755 (executable); every +// other entry is written with mode 0644. This only affects the mode stored +// in the tar header — pkgtar.Install always chmods declared runtime +// executables to 0755 regardless of the archived mode. +func WritePackage(w io.Writer, files map[string][]byte) error { + if _, exists := files[checksumsFileName]; exists { + return fmt.Errorf("pkgtartest: files must not include %q; it is generated", checksumsFileName) + } + + gz := gzip.NewWriter(w) + tw := tar.NewWriter(gz) + + names := sortedNames(files) + for _, name := range names { + if err := writeEntry(tw, name, files[name]); err != nil { + return err + } + } + + checksums := buildChecksums(names, files) + if err := writeEntry(tw, checksumsFileName, checksums); err != nil { + return err + } + + if err := tw.Close(); err != nil { + return fmt.Errorf("pkgtartest: closing tar writer: %w", err) + } + if err := gz.Close(); err != nil { + return fmt.Errorf("pkgtartest: closing gzip writer: %w", err) + } + return nil +} + +func sortedNames(files map[string][]byte) []string { + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func buildChecksums(names []string, files map[string][]byte) []byte { + var buf bytes.Buffer + for _, name := range names { + sum := sha256.Sum256(files[name]) + fmt.Fprintf(&buf, "%s %s\n", hex.EncodeToString(sum[:]), name) + } + return buf.Bytes() +} + +func writeEntry(tw *tar.Writer, name string, data []byte) error { + mode := int64(0o644) + if strings.HasPrefix(name, "server/") { + mode = 0o755 + } + hdr := &tar.Header{ + Name: name, + Typeflag: tar.TypeReg, + Mode: mode, + Size: int64(len(data)), + } + if err := tw.WriteHeader(hdr); err != nil { + return fmt.Errorf("pkgtartest: writing header for %s: %w", name, err) + } + if _, err := tw.Write(data); err != nil { + return fmt.Errorf("pkgtartest: writing data for %s: %w", name, err) + } + return nil +} diff --git a/apps/backend/internal/plugins/provider.go b/apps/backend/internal/plugins/provider.go new file mode 100644 index 0000000000..63b0c745b1 --- /dev/null +++ b/apps/backend/internal/plugins/provider.go @@ -0,0 +1,74 @@ +package plugins + +import ( + "fmt" + "path/filepath" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/plugins/runtime" + "github.com/kandev/kandev/internal/plugins/state" + "github.com/kandev/kandev/internal/plugins/store" +) + +// pluginsSubdir is the directory name under the Kandev home dir plugins +// live under: records (".yml"/".config.yml"), extracted packages +// ("//..."), and per-plugin writable data +// ("/data", KANDEV_PLUGIN_DATA_DIR) all share this one root, per +// docs/plans/plugins/GRPC-CONTRACT.md §6. +const pluginsSubdir = "plugins" + +// Provide builds the plugin Service, following the repo's provider pattern +// (see apps/backend/AGENTS.md "Provider Pattern" and internal/jira/provider.go): +// +// - An FS-backed installation store rooted at /plugins. +// - The SQLite-backed plugin_state store (internal/plugins/state), built +// from dbPool and wired onto the Service (see StateStore()). +// - An in-memory Registry, loaded from the FS store so existing +// installations survive a backend restart. +// - A runtime.Manager rooted at the same plugins directory, wired with +// svc.handleStatusChange as its OnStatusChange callback so the +// supervision loop's health transitions drive Service's state machine. +// +// secrets is passed straight through to Service.RevealSecret — callers pass +// secretadapter.New(secretsStore) in production (see internal/backendapp +// initPluginsService for the equivalent pattern); tests can pass a fake. +// +// eventBus may be nil (tests, or during early boot before the bus is ready). +// +// Event delivery (internal/plugins/delivery) and spawning already-active +// plugins (Service.StartActivePlugins) are NOT started by Provide — see the +// "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) { + dir := filepath.Join(cfg.ResolvedHomeDir(), pluginsSubdir) + pluginStore := store.NewFSStore(dir) + pluginStore.SetLogger(log) + + stateStore, err := state.NewStore(dbPool) + if err != nil { + return nil, nil, fmt.Errorf("plugins: init state store: %w", err) + } + + registry := NewRegistry() + if err := registry.Load(pluginStore); err != nil { + return nil, nil, fmt.Errorf("plugins: load registry: %w", err) + } + + svc := NewService(pluginStore, registry, eventBus, log) + svc.SetState(stateStore) + svc.SetSecrets(secrets) + svc.SetPluginsDir(dir) + + rt := runtime.NewManager(dir, svc.handleStatusChange, log) + svc.SetRuntime(rt) + + cleanup := func() error { + rt.StopAll() + return nil + } + return svc, cleanup, nil +} diff --git a/apps/backend/internal/plugins/provider_test.go b/apps/backend/internal/plugins/provider_test.go new file mode 100644 index 0000000000..6bcedb2d8c --- /dev/null +++ b/apps/backend/internal/plugins/provider_test.go @@ -0,0 +1,128 @@ +package plugins + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/plugins/store" +) + +func newTestPool(t *testing.T) *db.Pool { + t.Helper() + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + t.Cleanup(func() { _ = conn.Close() }) + return db.NewPool(conn, conn) +} + +func TestProvideConstructsServiceUsingHomeDirPluginsSubdir(t *testing.T) { + homeDir := t.TempDir() + cfg := &config.Config{HomeDir: homeDir} + + svc, cleanup, err := Provide(cfg, newTestPool(t), newFakeSecretRevealer(), nil, testLogger(t)) + if err != nil { + t.Fatalf("Provide() unexpected error: %v", err) + } + t.Cleanup(func() { _ = cleanup() }) + + // Install through the REAL runtime.Manager Provide wires — testPackage's + // "server/plugin" is a fake shell script, not a real go-plugin binary, + // so the spawn/handshake genuinely fails here. That's fine: this test + // only asserts Provide() persists installed records under + // /plugins, which happens before activation is attempted. + rec, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-slack", "1.0.0", false)) + if err == nil { + t.Fatal("Install() expected a spawn error against the real runtime.Manager with a fake executable") + } + if rec == nil { + t.Fatalf("Install() expected a persisted record despite the spawn failure, err: %v", err) + } + + wantPath := filepath.Join(homeDir, "plugins", "kandev-plugin-slack.yml") + if _, err := os.Stat(wantPath); err != nil { + t.Fatalf("expected installed record file at %s: %v", wantPath, err) + } +} + +func TestProvideLoadsExistingInstallationsFromDisk(t *testing.T) { + homeDir := t.TempDir() + cfg := &config.Config{HomeDir: homeDir} + + // Pre-populate the plugins dir directly via the store, simulating a + // prior process's installation surviving a restart. + pluginsDir := filepath.Join(homeDir, "plugins") + preexisting := store.NewFSStore(pluginsDir) + if err := preexisting.Save(&store.Record{Manifest: *testManifest("kandev-plugin-jira"), Status: store.StatusDisabled}); err != nil { + t.Fatalf("seed save: %v", err) + } + + svc, cleanup, err := Provide(cfg, newTestPool(t), newFakeSecretRevealer(), nil, testLogger(t)) + if err != nil { + t.Fatalf("Provide() unexpected error: %v", err) + } + t.Cleanup(func() { _ = cleanup() }) + + got, err := svc.Get("kandev-plugin-jira") + if err != nil { + t.Fatalf("Get() unexpected error: %v", err) + } + if got.ID != "kandev-plugin-jira" { + t.Fatalf("Get() ID = %q, want %q", got.ID, "kandev-plugin-jira") + } +} + +func TestProvideWiresStateStore(t *testing.T) { + cfg := &config.Config{HomeDir: t.TempDir()} + + svc, cleanup, err := Provide(cfg, newTestPool(t), newFakeSecretRevealer(), nil, testLogger(t)) + if err != nil { + t.Fatalf("Provide() unexpected error: %v", err) + } + t.Cleanup(func() { _ = cleanup() }) + + if svc.StateStore() == nil { + t.Fatalf("StateStore() = nil, want a wired *state.Store") + } + + // Sanity-check it's actually usable, not just non-nil. + if err := svc.StateStore().Set(context.Background(), "kandev-plugin-slack", "instance", "", "k", []byte(`"v"`)); err != nil { + t.Fatalf("StateStore().Set() unexpected error: %v", err) + } +} + +func TestProvideWiresRuntimeManager(t *testing.T) { + cfg := &config.Config{HomeDir: t.TempDir()} + + svc, cleanup, err := Provide(cfg, newTestPool(t), newFakeSecretRevealer(), nil, testLogger(t)) + if err != nil { + t.Fatalf("Provide() unexpected error: %v", err) + } + t.Cleanup(func() { _ = cleanup() }) + + if svc.Runtime() == nil { + t.Fatalf("Runtime() = nil, want a wired runtime.Manager") + } +} + +func TestProvideCleanupDoesNotError(t *testing.T) { + cfg := &config.Config{HomeDir: t.TempDir()} + + _, cleanup, err := Provide(cfg, newTestPool(t), newFakeSecretRevealer(), nil, testLogger(t)) + if err != nil { + t.Fatalf("Provide() unexpected error: %v", err) + } + + if err := cleanup(); err != nil { + t.Fatalf("cleanup() unexpected error: %v", err) + } +} diff --git a/apps/backend/internal/plugins/registry.go b/apps/backend/internal/plugins/registry.go new file mode 100644 index 0000000000..ba82ca0831 --- /dev/null +++ b/apps/backend/internal/plugins/registry.go @@ -0,0 +1,120 @@ +package plugins + +import ( + "fmt" + "sort" + "sync" + + "github.com/kandev/kandev/internal/plugins/store" +) + +// Registry is an in-memory, mutex-guarded index of installed plugins, keyed +// by plugin id. It is loaded from the filesystem store at startup (Load) +// and kept in sync as Service mutates installations, so read paths (List, +// Get) never hit disk. +// +// Get and List return copies of the stored *store.Record so callers cannot +// mutate registry state by holding onto a returned pointer; all writes go +// through Add / Remove / SetStatus / SetRestartCount. +type Registry struct { + mu sync.RWMutex + byID map[string]*store.Record +} + +// NewRegistry returns an empty Registry. Call Load to populate it from a +// store.Store at startup. +func NewRegistry() *Registry { + return &Registry{byID: make(map[string]*store.Record)} +} + +// Load replaces the registry's contents with every record currently +// persisted in s. Intended to be called once at startup (see Provide). +func (r *Registry) Load(s store.Store) error { + records, err := s.List() + if err != nil { + return fmt.Errorf("load plugin registry: %w", err) + } + + byID := make(map[string]*store.Record, len(records)) + for _, rec := range records { + byID[rec.ID] = rec + } + + r.mu.Lock() + defer r.mu.Unlock() + r.byID = byID + return nil +} + +// Get returns a copy of the record for id, and whether it was found. +func (r *Registry) Get(id string) (*store.Record, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + rec, ok := r.byID[id] + if !ok { + return nil, false + } + clone := *rec + return &clone, true +} + +// List returns a copy of every registered record, sorted by id for +// deterministic output. +func (r *Registry) List() []*store.Record { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*store.Record, 0, len(r.byID)) + for _, rec := range r.byID { + clone := *rec + out = append(out, &clone) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// Add inserts or replaces the record for record.ID. +func (r *Registry) Add(record *store.Record) { + r.mu.Lock() + defer r.mu.Unlock() + clone := *record + r.byID[record.ID] = &clone +} + +// Remove deletes the record for id, if present. A no-op if id is unknown. +func (r *Registry) Remove(id string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.byID, id) +} + +// SetStatus updates the in-memory status for id and returns a copy of the +// updated record. ok is false if id is not registered. Callers wanting FSM +// validation (Service.SetStatus) must check that before calling this — this +// method performs the raw mutation only. +func (r *Registry) SetStatus(id string, status Status) (*store.Record, bool) { + r.mu.Lock() + defer r.mu.Unlock() + rec, ok := r.byID[id] + if !ok { + return nil, false + } + rec.Status = status + clone := *rec + return &clone, true +} + +// SetRestartCount updates the in-memory restart count for id and returns a +// copy of the updated record. ok is false if id is not registered. Called +// by Service after a runtime.Manager-driven restart, to persist +// store.Record.RestartCount best-effort. +func (r *Registry) SetRestartCount(id string, count int) (*store.Record, bool) { + r.mu.Lock() + defer r.mu.Unlock() + rec, ok := r.byID[id] + if !ok { + return nil, false + } + rec.RestartCount = count + clone := *rec + return &clone, true +} diff --git a/apps/backend/internal/plugins/registry_test.go b/apps/backend/internal/plugins/registry_test.go new file mode 100644 index 0000000000..45ac09e2cf --- /dev/null +++ b/apps/backend/internal/plugins/registry_test.go @@ -0,0 +1,151 @@ +package plugins + +import ( + "testing" + + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/store" +) + +// testManifest returns a minimal valid runtime-managed manifest for tests +// that only need a store.Record, not a full installed package (see +// service_test.go's testPackage for that). +func testManifest(id string) *manifest.Manifest { + return &manifest.Manifest{ + ID: id, + APIVersion: 1, + Version: "1.0.0", + DisplayName: "Test Plugin", + Runtime: manifest.Runtime{ + Type: "binary", + Executables: map[string]string{"linux-amd64": "server/plugin-linux-amd64"}, + }, + } +} + +func TestRegistryLoadPopulatesFromStore(t *testing.T) { + dir := t.TempDir() + fsStore := store.NewFSStore(dir) + if err := fsStore.Save(&store.Record{Manifest: *testManifest("kandev-plugin-slack"), Status: store.StatusRegistered}); err != nil { + t.Fatalf("Save: %v", err) + } + + reg := NewRegistry() + if err := reg.Load(fsStore); err != nil { + t.Fatalf("Load() unexpected error: %v", err) + } + + rec, ok := reg.Get("kandev-plugin-slack") + if !ok { + t.Fatalf("Get() expected record to be present after Load()") + } + if rec.ID != "kandev-plugin-slack" { + t.Fatalf("Get() ID = %q, want %q", rec.ID, "kandev-plugin-slack") + } +} + +func TestRegistryGetMissingReturnsNotOK(t *testing.T) { + reg := NewRegistry() + if _, ok := reg.Get("missing"); ok { + t.Fatalf("Get() expected ok = false for missing id") + } +} + +func TestRegistryAddThenListReturnsRecord(t *testing.T) { + reg := NewRegistry() + reg.Add(&store.Record{Manifest: *testManifest("kandev-plugin-slack"), Status: store.StatusRegistered}) + + list := reg.List() + if len(list) != 1 { + t.Fatalf("List() len = %d, want 1", len(list)) + } + if list[0].ID != "kandev-plugin-slack" { + t.Fatalf("List()[0].ID = %q, want %q", list[0].ID, "kandev-plugin-slack") + } +} + +func TestRegistryRemoveDeletesRecord(t *testing.T) { + reg := NewRegistry() + reg.Add(&store.Record{Manifest: *testManifest("kandev-plugin-slack"), Status: store.StatusRegistered}) + + reg.Remove("kandev-plugin-slack") + + if _, ok := reg.Get("kandev-plugin-slack"); ok { + t.Fatalf("Get() expected record to be removed") + } +} + +func TestRegistrySetStatusUpdatesRecord(t *testing.T) { + reg := NewRegistry() + reg.Add(&store.Record{Manifest: *testManifest("kandev-plugin-slack"), Status: store.StatusRegistered}) + + updated, ok := reg.SetStatus("kandev-plugin-slack", store.StatusActive) + if !ok { + t.Fatalf("SetStatus() expected ok = true") + } + if updated.Status != store.StatusActive { + t.Fatalf("SetStatus() returned Status = %q, want %q", updated.Status, store.StatusActive) + } + + rec, ok := reg.Get("kandev-plugin-slack") + if !ok { + t.Fatalf("Get() expected record present") + } + if rec.Status != store.StatusActive { + t.Fatalf("Get() Status = %q, want %q", rec.Status, store.StatusActive) + } +} + +func TestRegistrySetStatusMissingReturnsNotOK(t *testing.T) { + reg := NewRegistry() + if _, ok := reg.SetStatus("missing", store.StatusActive); ok { + t.Fatalf("SetStatus() expected ok = false for missing id") + } +} + +func TestRegistryGetReturnsIndependentCopy(t *testing.T) { + reg := NewRegistry() + reg.Add(&store.Record{Manifest: *testManifest("kandev-plugin-slack"), Status: store.StatusRegistered}) + + rec, ok := reg.Get("kandev-plugin-slack") + if !ok { + t.Fatalf("Get() expected ok = true") + } + rec.Status = store.StatusActive // mutate the returned copy + + fresh, ok := reg.Get("kandev-plugin-slack") + if !ok { + t.Fatalf("Get() expected ok = true") + } + if fresh.Status != store.StatusRegistered { + t.Fatalf("mutating a Get() result leaked into the registry: Status = %q, want %q", fresh.Status, store.StatusRegistered) + } +} + +func TestRegistrySetRestartCountUpdatesRecord(t *testing.T) { + reg := NewRegistry() + reg.Add(&store.Record{Manifest: *testManifest("kandev-plugin-slack"), Status: store.StatusActive}) + + updated, ok := reg.SetRestartCount("kandev-plugin-slack", 2) + if !ok { + t.Fatalf("SetRestartCount() expected ok = true") + } + if updated.RestartCount != 2 { + t.Fatalf("SetRestartCount() RestartCount = %d, want 2", updated.RestartCount) + } + + rec, ok := reg.Get("kandev-plugin-slack") + if !ok { + t.Fatalf("Get() expected record present") + } + if rec.RestartCount != 2 { + t.Fatalf("Get() RestartCount = %d, want 2", rec.RestartCount) + } +} + +func TestRegistrySetRestartCountMissingReturnsNotOK(t *testing.T) { + reg := NewRegistry() + if _, ok := reg.SetRestartCount("missing", 1); ok { + t.Fatalf("SetRestartCount() expected ok = false for missing id") + } +} diff --git a/apps/backend/internal/plugins/runtime/main_test.go b/apps/backend/internal/plugins/runtime/main_test.go new file mode 100644 index 0000000000..e062202b39 --- /dev/null +++ b/apps/backend/internal/plugins/runtime/main_test.go @@ -0,0 +1,68 @@ +package runtime + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "testing" + + "go.uber.org/goleak" +) + +// fixtureBinPath is the path to the real SDK-based plugin binary built by +// TestMain, used by the end-to-end tests in manager_test.go that exercise a +// real subprocess spawn/handshake/DeliverEvent/InvokeTool/crash-restart +// cycle. Empty when tests run with -short (no real subprocess tests run in +// that mode). +var fixtureBinPath string + +// TestMain builds testdata/fixtureplugin (a real pkg/pluginsdk-based plugin +// backend) once for the whole package's test run, then asserts no +// goroutines from this package (the supervision loops process.go spawns) +// outlive the test process. Building is skipped under -short, per +// apps/backend/AGENTS.md's guidance to reserve real subprocess execution +// for integration tests. +func TestMain(m *testing.M) { + flag.Parse() + if !testing.Short() { + binPath, err := buildFixturePlugin() + if err != nil { + fmt.Fprintln(os.Stderr, "runtime: building fixtureplugin:", err) + os.Exit(1) + } + fixtureBinPath = binPath + } + goleak.VerifyTestMain(m) +} + +// buildFixturePlugin compiles testdata/fixtureplugin into a temp binary and +// returns its path. The temp dir is intentionally not cleaned up here: +// goleak.VerifyTestMain calls os.Exit internally, which would skip a +// deferred cleanup anyway; the OS/CI temp-dir lifecycle handles it. +func buildFixturePlugin() (string, error) { + dir, err := os.MkdirTemp("", "plugin-runtime-fixture-") + if err != nil { + return "", fmt.Errorf("create temp dir: %w", err) + } + + binName := "fixtureplugin" + if goruntime.GOOS == "windows" { + binName += ".exe" + } + binPath := filepath.Join(dir, binName) + + pkgDir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("getwd: %w", err) + } + + cmd := exec.Command("go", "build", "-o", binPath, "./testdata/fixtureplugin") + cmd.Dir = pkgDir + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("go build fixtureplugin: %w: %s", err, out) + } + return binPath, nil +} diff --git a/apps/backend/internal/plugins/runtime/manager.go b/apps/backend/internal/plugins/runtime/manager.go new file mode 100644 index 0000000000..73f14fe11b --- /dev/null +++ b/apps/backend/internal/plugins/runtime/manager.go @@ -0,0 +1,427 @@ +// Package runtime spawns and supervises kandev plugin backends as +// hashicorp/go-plugin subprocesses, per §2/§3 of +// docs/plans/plugins/GRPC-CONTRACT.md. Manager owns one *process (see +// process.go) per running plugin: Start resolves the host-platform +// executable declared in the plugin's manifest, spawns it, and performs the +// go-plugin gRPC handshake; the returned *process then supervises the +// subprocess in the background (periodic health pings, crash detection, +// restart with backoff) until Stop is called. +package runtime + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "sync" + "time" + + hcplugin "github.com/hashicorp/go-plugin" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// pluginDataDirEnv is the environment variable kandev injects into every +// spawned plugin subprocess, per §2 of the frozen contract. +const pluginDataDirEnv = "KANDEV_PLUGIN_DATA_DIR" + +// errNotRunning is returned by Manager.Ping (via process.ping) when no +// process is currently tracked/live for the given plugin id. +func errNotRunning(id string) error { + return fmt.Errorf("plugins/runtime: plugin %q is not running", id) +} + +// Option configures a Manager at construction time. +type Option func(*Manager) + +// WithPingInterval overrides the supervision loop's health-check cadence +// (default 30s). Tests use a short interval instead of real sleeps. +func WithPingInterval(d time.Duration) Option { + return func(m *Manager) { m.pingInterval = d } +} + +// WithMaxConsecutiveFailures overrides how many consecutive failed pings +// trigger a restart (default 3). +func WithMaxConsecutiveFailures(n int) Option { + return func(m *Manager) { m.maxConsecutiveFailures = n } +} + +// WithRestartBackoff overrides the delay schedule between restart attempts +// (default 1s/2s/4s/8s/16s). len(delays) also bounds maxRestartAttempts +// unless WithMaxRestartAttempts is set separately. +func WithRestartBackoff(delays []time.Duration) Option { + return func(m *Manager) { m.restartBackoff = delays } +} + +// WithMaxRestartAttempts overrides how many restart attempts are made +// before giving up permanently (default 5). +func WithMaxRestartAttempts(n int) Option { + return func(m *Manager) { m.maxRestartAttempts = n } +} + +// defaultStartTimeout bounds how long a single spawn attempt (process start +// + go-plugin gRPC handshake) may take before hcplugin.Client.Client() gives +// up and returns an error. hashicorp/go-plugin defaults StartTimeout to a +// full minute when left unset; that default let a hung plugin binary block +// Service.activate (Enable/Install) for up to a minute per call. 30s is a +// generous bound for a well-behaved plugin's startup while still failing a +// hung binary fast with a clear error. +const defaultStartTimeout = 30 * time.Second + +// WithStartTimeout overrides how long a single spawn attempt (process start +// + go-plugin gRPC handshake) may take before it is aborted as failed +// (default 30s). Passed through to hcplugin.ClientConfig.StartTimeout on +// every spawn, so it also bounds each individual restart attempt. +func WithStartTimeout(d time.Duration) Option { + return func(m *Manager) { m.startTimeout = d } +} + +// Manager owns the set of currently-spawned plugin subprocesses. One +// Manager is constructed for the whole backend process (see +// internal/plugins.Provide); Start/Stop are called per plugin as it is +// enabled/disabled/installed/uninstalled. +type Manager struct { + pluginsDir string + log *logger.Logger + onStatusChange func(id string, healthy bool) + + pingInterval time.Duration + maxConsecutiveFailures int + restartBackoff []time.Duration + maxRestartAttempts int + startTimeout time.Duration + + mu sync.Mutex + processes map[string]*process + // starting tracks plugin ids currently mid-Start (spawnFuncFor resolved, + // process.start() spawning, not yet inserted into processes). Guards the + // TOCTOU window between the "already running" check and the map + // insertion: a concurrent Start for the same id sees the reservation and + // fails fast instead of racing a second spawn. Held only across the fast + // check-and-reserve/release bookkeeping in claimStart/releaseStart, never + // across the slow spawn itself, so concurrent Starts for *different* ids + // are never serialized against each other. + starting map[string]struct{} + // stopRequested tracks plugin ids Stop/StopAll was asked to stop while + // they were still mid-Start (present in starting, not yet in + // processes) — the Stop-during-Start orphan-process race: without this, + // Stop/StopAll only consult processes, so a Disable/Uninstall racing an + // in-flight spawn is a silent no-op, and the spawn then completes and + // inserts a live supervised process for a plugin whose record/files may + // already be gone. startProcess checks and consumes this flag + // (consumeStopRequested) right after a successful spawn, before + // inserting into processes; releaseStart always clears it afterward so + // it never leaks onto a later, unrelated Start for the same id. + stopRequested map[string]struct{} +} + +// NewManager returns a Manager rooted at pluginsDir (the same directory +// store.FSStore persists records under — KANDEV_PLUGIN_DATA_DIR for plugin +// id "x" is pluginsDir/x/data). onStatusChange is invoked from the +// supervision loop's own goroutine whenever a running plugin's health +// transitions (degraded -> unhealthy, or a restart recovers it); it must +// not block. May be nil (e.g. tests that don't care about transitions). +func NewManager(pluginsDir string, onStatusChange func(id string, healthy bool), log *logger.Logger, opts ...Option) *Manager { + m := &Manager{ + pluginsDir: pluginsDir, + log: log, + onStatusChange: onStatusChange, + pingInterval: defaultPingInterval, + maxConsecutiveFailures: defaultMaxConsecutiveFailures, + restartBackoff: defaultRestartBackoff(), + maxRestartAttempts: defaultMaxRestartAttempts, + startTimeout: defaultStartTimeout, + processes: make(map[string]*process), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// Start resolves the host-platform executable declared in rec's manifest +// under rec.InstallPath, spawns it as a go-plugin gRPC subprocess (AutoMTLS, +// KANDEV_PLUGIN_DATA_DIR set, cwd = rec.InstallPath), performs the +// handshake, and — on success — begins background supervision. hostFactory +// builds the Go-native Host implementation bound to this plugin's record; +// it is called once, synchronously, before the subprocess is spawned. +// +// Returns an error without registering anything if rec is not +// runtime-managed, a process is already tracked for rec.ID, the host +// platform has no declared executable, or the initial spawn/handshake +// fails. ctx is checked for early cancellation before spawning; the +// subprocess handshake itself is not context-aware (hashicorp/go-plugin's +// Client() call does not accept a context) — it is instead bounded by +// m.startTimeout (default 30s, see WithStartTimeout), passed through as +// hcplugin.ClientConfig.StartTimeout, so a hung plugin binary still fails +// fast rather than blocking the caller for go-plugin's own 1-minute default. +func (m *Manager) Start(ctx context.Context, rec *store.Record, hostFactory func(pluginID string) pluginsdk.Host) error { + if err := ctx.Err(); err != nil { + return err + } + if !rec.IsManaged() { + return fmt.Errorf("plugins/runtime: plugin %q is not runtime-managed", rec.ID) + } + + recCopy := rec.Manifest + spawnFn, err := m.spawnFuncFor(rec.ID, &recCopy, rec.InstallPath, hostFactory) + if err != nil { + return err + } + return m.startProcess(rec.ID, spawnFn) +} + +// startProcess serializes start-up for a single plugin id and performs the +// actual spawn+supervise: claimStart reserves id (rejecting a concurrent +// Start for the same id, and clearing out any exhausted/gave-up previous +// entry so a fresh Start can respawn) before the slow spawn runs, closing +// the TOCTOU window between the "already running" check and the map +// insertion that let two concurrent Start calls for the same id both spawn +// a subprocess. claimStart/releaseStart only hold m.mu for fast bookkeeping +// — the spawn itself (p.start()) runs unlocked, so concurrent Starts for +// different ids are never serialized against each other. +func (m *Manager) startProcess(id string, spawnFn func() (spawnedProcess, error)) error { + if err := m.claimStart(id); err != nil { + return err + } + defer m.releaseStart(id) + + p := newProcess(id, m.log, spawnFn, m.onStatusChange) + p.pingInterval = m.pingInterval + p.maxConsecutiveFails = m.maxConsecutiveFailures + p.restartBackoff = m.restartBackoff + p.maxRestartAttempts = m.maxRestartAttempts + + if err := p.start(); err != nil { + return fmt.Errorf("plugins/runtime: start plugin %q: %w", id, err) + } + + // A Stop/StopAll racing this Start (see stopRequested's doc comment) + // only had a chance to record the request while id was still in + // m.starting — which it still is here, since releaseStart hasn't run + // yet. Check and consume it before ever inserting into m.processes, so + // the caller that asked to stop id is never silently ignored. + if m.consumeStopRequested(id) { + p.stop() + return fmt.Errorf("plugins/runtime: plugin %q was stopped while starting, aborting", id) + } + + m.mu.Lock() + m.processes[id] = p + m.mu.Unlock() + return nil +} + +// consumeStopRequested reports whether Stop/StopAll requested id be +// stopped while it was still starting, clearing the flag either way so it +// never leaks onto a later Start for the same id. +func (m *Manager) consumeStopRequested(id string) bool { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.stopRequested[id]; ok { + delete(m.stopRequested, id) + return true + } + return false +} + +// claimStart atomically checks that id has no live process and no Start +// already in flight, then reserves id in m.starting so a concurrent Start +// for the same id fails fast instead of racing a second spawn. If the +// tracked entry for id has already permanently given up (exhausted its +// restart attempts — current==nil, gaveUp==true), it is dropped so a fresh +// Start can respawn cleanly rather than being rejected as "already +// running" against a dead entry. p.stop() on a gave-up process is a fast, +// safe no-op (its supervision loop has already exited), so this is safe to +// do while holding m.mu. +func (m *Manager) claimStart(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, inFlight := m.starting[id]; inFlight { + return fmt.Errorf("plugins/runtime: plugin %q is already starting", id) + } + if p, exists := m.processes[id]; exists { + if !p.isGaveUp() { + return fmt.Errorf("plugins/runtime: plugin %q is already running", id) + } + delete(m.processes, id) + p.stop() + } + if m.starting == nil { + m.starting = make(map[string]struct{}) + } + m.starting[id] = struct{}{} + return nil +} + +// releaseStart clears id's reservation in m.starting, whether startProcess +// succeeded or failed. Also clears any stopRequested entry for id: if +// startProcess's own consumeStopRequested call didn't already consume one +// (e.g. Stop raced in just after m.processes[id] was set, so it took the +// normal already-running stop path instead), it must not leak onto a +// later, unrelated Start for the same id. +func (m *Manager) releaseStart(id string) { + m.mu.Lock() + delete(m.starting, id) + delete(m.stopRequested, id) + m.mu.Unlock() +} + +// Stop kills and stops supervising the process for id, if any. A no-op if +// id is not currently running AND not currently starting. If id is +// currently mid-Start (spawnFuncFor resolved, spawning, not yet inserted +// into processes — see the starting/stopRequested doc comments), the +// request is recorded so startProcess aborts and kills the just-spawned +// process itself once the spawn completes, instead of silently leaving an +// orphan process registered. +func (m *Manager) Stop(id string) { + m.mu.Lock() + p, ok := m.processes[id] + if ok { + delete(m.processes, id) + } + if _, starting := m.starting[id]; starting { + if m.stopRequested == nil { + m.stopRequested = make(map[string]struct{}) + } + m.stopRequested[id] = struct{}{} + } + m.mu.Unlock() + if !ok { + return + } + p.stop() +} + +// StopAll stops every currently-running or currently-starting plugin. +// Intended for graceful backend shutdown (addCleanup). +func (m *Manager) StopAll() { + m.mu.Lock() + ids := make(map[string]struct{}, len(m.processes)+len(m.starting)) + for id := range m.processes { + ids[id] = struct{}{} + } + for id := range m.starting { + ids[id] = struct{}{} + } + m.mu.Unlock() + for id := range ids { + m.Stop(id) + } +} + +// Get returns the live RemotePlugin for id, for calling DeliverEvent/ +// InvokeTool/HandleWebhook, and whether one is currently available (false +// if the plugin was never started, was stopped, or is mid-restart). +func (m *Manager) Get(id string) (*pluginsdk.RemotePlugin, bool) { + m.mu.Lock() + p, ok := m.processes[id] + m.mu.Unlock() + if !ok { + return nil, false + } + return p.remote() +} + +// Ping issues an on-demand health check against id's current process. +func (m *Manager) Ping(id string) error { + m.mu.Lock() + p, ok := m.processes[id] + m.mu.Unlock() + if !ok { + return errNotRunning(id) + } + return p.ping() +} + +// Running reports whether id currently has a live process. +func (m *Manager) Running(id string) bool { + _, ok := m.Get(id) + return ok +} + +// RestartCount returns how many times id's process has been automatically +// restarted since it was started, for Service to persist best-effort onto +// store.Record.RestartCount. Returns 0 if id is not tracked. +func (m *Manager) RestartCount(id string) int { + m.mu.Lock() + p, ok := m.processes[id] + m.mu.Unlock() + if !ok { + return 0 + } + return p.restartCount() +} + +// spawnFuncFor builds the production spawnFn for a plugin: resolve the host +// platform's executable, prepare its data dir, and construct a fresh +// hcplugin.Client wired to hostFactory(id)'s Host implementation on every +// call (so a restart gets a fresh client/broker rather than reusing a dead +// one). +func (m *Manager) spawnFuncFor(id string, mf *manifest.Manifest, installPath string, hostFactory func(string) pluginsdk.Host) (func() (spawnedProcess, error), error) { + execRelPath, ok := mf.ExecutableFor(goruntime.GOOS, goruntime.GOARCH) + if !ok { + return nil, fmt.Errorf("plugins/runtime: plugin %q has no executable for %s-%s", id, goruntime.GOOS, goruntime.GOARCH) + } + execPath := filepath.Join(installPath, execRelPath) + dataDir := filepath.Join(m.pluginsDir, id, "data") + startTimeout := m.startTimeout + + return func() (spawnedProcess, error) { + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return nil, fmt.Errorf("plugins/runtime: create data dir for %q: %w", id, err) + } + + cmd := exec.Command(execPath) //nolint:gosec // execPath is resolved from a verified, installed package manifest + cmd.Dir = installPath + cmd.Env = append(os.Environ(), pluginDataDirEnv+"="+dataDir) + + client := hcplugin.NewClient(&hcplugin.ClientConfig{ + HandshakeConfig: pluginsdk.Handshake, + Plugins: map[string]hcplugin.Plugin{pluginsdk.PluginMapKey: &pluginsdk.GRPCPlugin{Host: hostFactory(id)}}, + AllowedProtocols: []hcplugin.Protocol{hcplugin.ProtocolGRPC}, + AutoMTLS: true, + Cmd: cmd, + StartTimeout: startTimeout, + }) + + rpcClient, err := client.Client() + if err != nil { + client.Kill() + return nil, fmt.Errorf("plugins/runtime: connect to plugin %q: %w", id, err) + } + raw, err := rpcClient.Dispense(pluginsdk.PluginMapKey) + if err != nil { + client.Kill() + return nil, fmt.Errorf("plugins/runtime: dispense plugin %q: %w", id, err) + } + remote, ok := raw.(*pluginsdk.RemotePlugin) + if !ok { + client.Kill() + return nil, fmt.Errorf("plugins/runtime: plugin %q dispensed %T, want *pluginsdk.RemotePlugin", id, raw) + } + return &hcProcess{client: client, proto: rpcClient, remote: remote}, nil + }, nil +} + +// hcProcess adapts a real *hcplugin.Client (plus its dispensed +// ClientProtocol/RemotePlugin) to the spawnedProcess interface process.go's +// supervision loop depends on. +type hcProcess struct { + client *hcplugin.Client + proto hcplugin.ClientProtocol + remote *pluginsdk.RemotePlugin +} + +func (h *hcProcess) Ping() error { return h.proto.Ping() } +func (h *hcProcess) Exited() bool { return h.client.Exited() } +func (h *hcProcess) Kill() { h.client.Kill() } +func (h *hcProcess) Remote() *pluginsdk.RemotePlugin { return h.remote } + +var _ spawnedProcess = (*hcProcess)(nil) diff --git a/apps/backend/internal/plugins/runtime/manager_test.go b/apps/backend/internal/plugins/runtime/manager_test.go new file mode 100644 index 0000000000..591cdd17cf --- /dev/null +++ b/apps/backend/internal/plugins/runtime/manager_test.go @@ -0,0 +1,451 @@ +package runtime + +import ( + "context" + "errors" + "os" + "path/filepath" + goruntime "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// fakeHost is a minimal in-memory pluginsdk.Host for end-to-end tests: it +// only implements SetState (with a channel so tests can synchronize on +// delivery without a sleep), and errors on everything else. +type fakeHost struct { + mu sync.Mutex + states map[string]map[string]any + setCh chan struct{} +} + +func newFakeHost() *fakeHost { + return &fakeHost{states: map[string]map[string]any{}, setCh: make(chan struct{}, 16)} +} + +func (h *fakeHost) GetState(context.Context, string, string, string) (map[string]any, bool, error) { + return nil, false, nil +} + +func (h *fakeHost) SetState(_ context.Context, scope, scopeID, key string, value map[string]any) error { + h.mu.Lock() + h.states[scope+"|"+scopeID+"|"+key] = value + h.mu.Unlock() + select { + case h.setCh <- struct{}{}: + default: + } + return nil +} + +func (h *fakeHost) DeleteState(context.Context, string, string, string) error { return nil } +func (h *fakeHost) ListState(context.Context, string, string) ([]pluginsdk.StateEntry, error) { + return nil, 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 } + +func (h *fakeHost) get(scope, scopeID, key string) (map[string]any, bool) { + h.mu.Lock() + defer h.mu.Unlock() + v, ok := h.states[scope+"|"+scopeID+"|"+key] + return v, ok +} + +// buildFixtureRecord copies the real fixture plugin binary into a fresh +// InstallPath and returns a *store.Record ready for Manager.Start, +// skipping the test if -short suppressed the fixture build. +func buildFixtureRecord(t *testing.T, id string) *store.Record { + t.Helper() + if fixtureBinPath == "" { + t.Skip("fixture plugin binary not built (test run with -short)") + } + + installPath := t.TempDir() + platformKey := goruntime.GOOS + "-" + goruntime.GOARCH + relExec := filepath.Join("server", "plugin-"+platformKey) + destExec := filepath.Join(installPath, relExec) + if err := os.MkdirAll(filepath.Dir(destExec), 0o755); err != nil { + t.Fatalf("mkdir server dir: %v", err) + } + copyFile(t, fixtureBinPath, destExec, 0o755) + + return &store.Record{ + Manifest: manifest.Manifest{ + ID: id, + APIVersion: 1, + Version: "1.0.0", + Runtime: manifest.Runtime{ + Type: "binary", + Executables: map[string]string{platformKey: relExec}, + }, + }, + Status: store.StatusRegistered, + InstallPath: installPath, + } +} + +func copyFile(t *testing.T, src, dst string, mode os.FileMode) { + t.Helper() + data, err := os.ReadFile(src) + if err != nil { + t.Fatalf("read %s: %v", src, err) + } + if err := os.WriteFile(dst, data, mode); err != nil { + t.Fatalf("write %s: %v", dst, err) + } +} + +func TestManager_StartDeliverEventInvokeToolStop(t *testing.T) { + rec := buildFixtureRecord(t, "kandev-fixture-plugin") + pluginsDir := t.TempDir() + host := newFakeHost() + + m := NewManager(pluginsDir, nil, testLogger(t)) + t.Cleanup(m.StopAll) + + ctx := context.Background() + if err := m.Start(ctx, rec, func(string) pluginsdk.Host { return host }); err != nil { + t.Fatalf("Start() unexpected error: %v", err) + } + if !m.Running(rec.ID) { + t.Fatal("Running() = false right after a successful Start()") + } + + remote, ok := m.Get(rec.ID) + if !ok { + t.Fatal("Get() ok = false right after a successful Start()") + } + + t.Run("InvokeTool echoes input over the real subprocess", func(t *testing.T) { + resp, err := remote.InvokeTool(ctx, &pluginsdk.ToolRequest{ToolName: "echo", Input: map[string]any{"x": float64(1)}}) + if err != nil { + t.Fatalf("InvokeTool() unexpected error: %v", err) + } + if resp.Output["x"] != float64(1) { + t.Fatalf("InvokeTool().Output = %v, want echoed input", resp.Output) + } + }) + + t.Run("DeliverEvent reaches the plugin, which calls back into Host.SetState", func(t *testing.T) { + err := remote.DeliverEvent(ctx, &pluginsdk.Event{EventID: "e1", EventType: "task.created"}) + if err != nil { + t.Fatalf("DeliverEvent() unexpected error: %v", err) + } + + select { + case <-host.setCh: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the plugin's Host.SetState callback") + } + + value, found := host.get("instance", "", "last_event") + if !found { + t.Fatal("fakeHost never recorded the delivered event") + } + if value["event_type"] != "task.created" { + t.Fatalf("recorded event_type = %v, want %q", value["event_type"], "task.created") + } + }) + + t.Run("Ping succeeds against the live subprocess", func(t *testing.T) { + if err := m.Ping(rec.ID); err != nil { + t.Fatalf("Ping() unexpected error: %v", err) + } + }) + + m.Stop(rec.ID) + if m.Running(rec.ID) { + t.Fatal("Running() = true after Stop()") + } +} + +func TestManager_CrashTriggersAutomaticRestart(t *testing.T) { + rec := buildFixtureRecord(t, "kandev-fixture-plugin-crash") + pluginsDir := t.TempDir() + host := newFakeHost() + + var mu sync.Mutex + var transitions []bool + onStatusChange := func(_ string, healthy bool) { + mu.Lock() + transitions = append(transitions, healthy) + mu.Unlock() + } + + m := NewManager(pluginsDir, onStatusChange, testLogger(t), + WithPingInterval(20*time.Millisecond), + WithMaxConsecutiveFailures(1), + WithRestartBackoff([]time.Duration{10 * time.Millisecond}), + WithMaxRestartAttempts(3), + ) + t.Cleanup(m.StopAll) + + ctx := context.Background() + if err := m.Start(ctx, rec, func(string) pluginsdk.Host { return host }); err != nil { + t.Fatalf("Start() unexpected error: %v", err) + } + + remote, ok := m.Get(rec.ID) + if !ok { + t.Fatal("Get() ok = false right after Start()") + } + // Invoking the "crash" tool exits the subprocess immediately; the RPC + // call itself is expected to error (connection dropped mid-call) or + // hang up cleanly - either way we only assert on the recovery below. + _, _ = remote.InvokeTool(ctx, &pluginsdk.ToolRequest{ToolName: "crash"}) + + require.Eventually(t, func() bool { + mu.Lock() + defer mu.Unlock() + return len(transitions) >= 2 && !transitions[0] && transitions[1] + }, 10*time.Second, 20*time.Millisecond, + "onStatusChange should observe degraded (false) then recovered (true) after the crash") +} + +// TestManager_ConcurrentStartOnlySpawnsOnce pins the fix for the TOCTOU +// double-spawn race: two Start calls for the same plugin id racing the +// "already running" check must not both reach spawnFn. The first goroutine +// is deliberately paused *inside* its (slow) spawn — the exact window the +// old code released the manager lock across — before the second goroutine's +// startProcess call is allowed to race in. +func TestManager_ConcurrentStartOnlySpawnsOnce(t *testing.T) { + m := NewManager(t.TempDir(), nil, testLogger(t)) + t.Cleanup(m.StopAll) + + var spawnCalls int32 + firstEntered := make(chan struct{}) + release := make(chan struct{}) + var closeOnce sync.Once + + spawnFn := func() (spawnedProcess, error) { + if atomic.AddInt32(&spawnCalls, 1) == 1 { + closeOnce.Do(func() { close(firstEntered) }) + <-release // hold the first spawn open so the second Start can race in + } + return &fakeSpawnedProcess{}, nil + } + + var wg sync.WaitGroup + errs := make([]error, 2) + wg.Add(1) + go func() { + defer wg.Done() + errs[0] = m.startProcess("race-plugin", spawnFn) + }() + + <-firstEntered // the first Start is now mid-spawn, before it has registered anything + + errs[1] = m.startProcess("race-plugin", spawnFn) + close(release) + wg.Wait() + + if got := atomic.LoadInt32(&spawnCalls); got != 1 { + t.Fatalf("spawnFn called %d times for a concurrent Start of the same id, want exactly 1 (no double-spawn)", got) + } + if errs[0] != nil { + t.Fatalf("first startProcess() unexpected error: %v", errs[0]) + } + if errs[1] == nil { + t.Fatal("second concurrent startProcess() for the same id succeeded, want an error (already starting)") + } + if !m.Running("race-plugin") { + t.Fatal("Running() = false after the winning Start() completed") + } +} + +// TestManager_StopDuringStartAbortsAndLeavesNoOrphanProcess pins the fix for +// the Stop-during-Start orphan race: Stop previously only consulted +// m.processes, so a Disable/Uninstall racing an in-flight Start was a +// silent no-op — the spawn then completed and inserted a live supervised +// process for a plugin whose record/files may already be gone by the time +// the spawn finished. claimStart's "starting" reservation now doubles as +// the hook Stop uses to request an abort: startProcess checks it right +// after the (slow) spawn succeeds and, if set, kills the freshly spawned +// process and never inserts it into m.processes. +func TestManager_StopDuringStartAbortsAndLeavesNoOrphanProcess(t *testing.T) { + m := NewManager(t.TempDir(), nil, testLogger(t)) + t.Cleanup(m.StopAll) + + entered := make(chan struct{}) + release := make(chan struct{}) + var closeOnce sync.Once + spawned := &fakeSpawnedProcess{} + + spawnFn := func() (spawnedProcess, error) { + closeOnce.Do(func() { close(entered) }) + <-release // hold the spawn open until the test has called Stop + return spawned, nil + } + + errCh := make(chan error, 1) + go func() { + errCh <- m.startProcess("racing-plugin", spawnFn) + }() + + <-entered // the spawn is in flight, before startProcess has registered anything + + m.Stop("racing-plugin") // races the in-flight Start: must not be a no-op + close(release) // let the spawn finish + + err := <-errCh + if err == nil { + t.Fatal("startProcess() expected an error when Stop() raced an in-flight Start, got nil") + } + if m.Running("racing-plugin") { + t.Fatal("Running() = true after Stop() raced an in-flight Start; an orphan process was left registered") + } + if !spawned.isKilled() { + t.Fatal("the spawned process was never killed after Stop() raced an in-flight Start (orphan subprocess)") + } +} + +// TestManager_StopAllDuringStartAbortsAndLeavesNoOrphanProcess is the same +// race as TestManager_StopDuringStartAbortsAndLeavesNoOrphanProcess, but +// through StopAll (graceful shutdown), which previously only iterated +// m.processes too. +func TestManager_StopAllDuringStartAbortsAndLeavesNoOrphanProcess(t *testing.T) { + m := NewManager(t.TempDir(), nil, testLogger(t)) + t.Cleanup(m.StopAll) + + entered := make(chan struct{}) + release := make(chan struct{}) + var closeOnce sync.Once + spawned := &fakeSpawnedProcess{} + + spawnFn := func() (spawnedProcess, error) { + closeOnce.Do(func() { close(entered) }) + <-release + return spawned, nil + } + + errCh := make(chan error, 1) + go func() { + errCh <- m.startProcess("racing-plugin-2", spawnFn) + }() + + <-entered + + m.StopAll() + close(release) + + err := <-errCh + if err == nil { + t.Fatal("startProcess() expected an error when StopAll() raced an in-flight Start, got nil") + } + if m.Running("racing-plugin-2") { + t.Fatal("Running() = true after StopAll() raced an in-flight Start; an orphan process was left registered") + } + if !spawned.isKilled() { + t.Fatal("the spawned process was never killed after StopAll() raced an in-flight Start (orphan subprocess)") + } +} + +// TestManager_StartAfterExhaustionRespawns pins the fix that lets a fresh +// Start cleanly replace an entry whose restart attempts were exhausted +// (gaveUp==true, current==nil): before the fix, Start saw the stale map +// entry and always errored "already running" instead of respawning. +func TestManager_StartAfterExhaustionRespawns(t *testing.T) { + m := NewManager(t.TempDir(), nil, testLogger(t), + WithPingInterval(5*time.Millisecond), + WithMaxConsecutiveFailures(1), + WithRestartBackoff([]time.Duration{0}), + WithMaxRestartAttempts(1), + ) + t.Cleanup(m.StopAll) + + var mu sync.Mutex + firstSpawnDone := false + spawnFn := func() (spawnedProcess, error) { + mu.Lock() + defer mu.Unlock() + if !firstSpawnDone { + firstSpawnDone = true + return &fakeSpawnedProcess{exited: true}, nil // dies immediately, forcing an exhausting restart loop + } + return nil, errors.New("respawn always fails") // every restart attempt fails too + } + + if err := m.startProcess("exhaust-plugin", spawnFn); err != nil { + t.Fatalf("startProcess() unexpected error: %v", err) + } + + // Wait for the supervision loop to actually finish giving up (gaveUp), + // not just for current to go nil — current is cleared at the start of + // handleFailureAndRestart, before the (failing) respawn attempt(s) run, + // so polling only Running()==false races the loop's own completion. + require.Eventually(t, func() bool { + m.mu.Lock() + p, ok := m.processes["exhaust-plugin"] + m.mu.Unlock() + return ok && p.isGaveUp() + }, 2*time.Second, 5*time.Millisecond, + "plugin's process should have given up once restart attempts are exhausted") + + healthy := &fakeSpawnedProcess{} + respawnCalls := 0 + freshSpawnFn := func() (spawnedProcess, error) { + respawnCalls++ + return healthy, nil + } + if err := m.startProcess("exhaust-plugin", freshSpawnFn); err != nil { + t.Fatalf("startProcess() after exhaustion unexpected error: %v", err) + } + if respawnCalls != 1 { + t.Fatalf("respawn spawnFn called %d times, want 1", respawnCalls) + } + if !m.Running("exhaust-plugin") { + t.Fatal("Running() = false after a fresh Start() following exhaustion") + } +} + +// TestManager_DefaultStartTimeoutIs30s pins the fix for bounded startup: +// Manager.Start previously spawned via a hcplugin.Client with no configured +// StartTimeout, defaulting to go-plugin's own 1-minute handshake timeout, so +// a hung plugin binary could block Enable/Install for up to a minute. A +// fresh Manager must default to a much shorter, explicit bound. +func TestManager_DefaultStartTimeoutIs30s(t *testing.T) { + m := NewManager(t.TempDir(), nil, testLogger(t)) + if m.startTimeout != 30*time.Second { + t.Fatalf("default startTimeout = %v, want 30s", m.startTimeout) + } +} + +// TestManager_WithStartTimeoutOverridesDefault pins the escape hatch for +// callers (and this test file) that need a different bound. +func TestManager_WithStartTimeoutOverridesDefault(t *testing.T) { + m := NewManager(t.TempDir(), nil, testLogger(t), WithStartTimeout(5*time.Second)) + if m.startTimeout != 5*time.Second { + t.Fatalf("startTimeout = %v, want 5s (WithStartTimeout override)", m.startTimeout) + } +} + +// TestManager_StartFailsFastOnHandshakeTimeout proves the configured +// startTimeout actually reaches the underlying hcplugin.Client: an +// unreasonably short timeout must make Start() return an error quickly +// instead of ever completing the handshake, even against a real, working +// fixture binary. +func TestManager_StartFailsFastOnHandshakeTimeout(t *testing.T) { + rec := buildFixtureRecord(t, "kandev-fixture-plugin-timeout") + m := NewManager(t.TempDir(), nil, testLogger(t), WithStartTimeout(1*time.Nanosecond)) + t.Cleanup(m.StopAll) + + started := time.Now() + err := m.Start(context.Background(), rec, func(string) pluginsdk.Host { return newFakeHost() }) + elapsed := time.Since(started) + + if err == nil { + t.Fatal("Start() with a 1ns startTimeout unexpectedly succeeded") + } + if elapsed > 10*time.Second { + t.Fatalf("Start() took %v to fail, want well under go-plugin's 1-minute default handshake timeout", elapsed) + } +} diff --git a/apps/backend/internal/plugins/runtime/process.go b/apps/backend/internal/plugins/runtime/process.go new file mode 100644 index 0000000000..dde02f4cf2 --- /dev/null +++ b/apps/backend/internal/plugins/runtime/process.go @@ -0,0 +1,309 @@ +package runtime + +import ( + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// Default supervision tuning, per the task's frozen semantics: ping every +// 30s, 3 consecutive failures (or a detected process exit) triggers a +// restart, backed off 1s/2s/4s/8s/16s across up to 5 attempts before giving +// up. +const ( + defaultPingInterval = 30 * time.Second + defaultMaxConsecutiveFailures = 3 + defaultMaxRestartAttempts = 5 +) + +// defaultRestartBackoff returns a fresh slice (callers may hold onto and +// mutate the one an Option sets, so Manager must not share a single +// package-level slice across instances). +func defaultRestartBackoff() []time.Duration { + return []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second} +} + +// spawnedProcess is the minimal surface the supervision loop needs from a +// running plugin subprocess. hcProcess (manager.go) implements this over a +// real *hcplugin.Client; tests inject fakes to unit-test supervision +// decisions without spawning a real process. +type spawnedProcess interface { + // Ping checks that the plugin's control connection is still healthy. + Ping() error + // Exited reports whether the subprocess has already exited. + Exited() bool + // Kill terminates the subprocess if still running. Idempotent. + Kill() + // Remote returns the Go-native client for calling the plugin's RPCs. + Remote() *pluginsdk.RemotePlugin +} + +// process supervises one spawned plugin: periodic health pings, crash +// detection, and restart-with-backoff, up to maxRestartAttempts before +// giving up permanently (until Manager.Start is called again). Lifecycle is +// owned by Manager (start/stop), per apps/backend/AGENTS.md's +// goroutine-ownership convention: start registers the loop on wg, stop +// closes stopCh and waits for it to drain. +type process struct { + id string + log *logger.Logger + + spawnFn func() (spawnedProcess, error) + onStatusChange func(id string, healthy bool) + + pingInterval time.Duration + maxConsecutiveFails int + restartBackoff []time.Duration + maxRestartAttempts int + + // sleepFn blocks for d or until stopCh fires, returning false if + // stopped first. Overridable in tests so supervision-decision tests + // never perform a real sleep. + sleepFn func(stopCh <-chan struct{}, d time.Duration) bool + + // onTick, if set, runs once per supervision-loop iteration (after that + // iteration's outcome has been fully handled), letting tests wait + // deterministically for N cycles instead of racing the goroutine with + // a sleep. + onTick func() + + mu sync.Mutex + current spawnedProcess + failures int + restarts int + gaveUp bool + + stopCh chan struct{} + wg sync.WaitGroup +} + +// newProcess builds a process with default tuning. Callers may override +// pingInterval/maxConsecutiveFails/restartBackoff/maxRestartAttempts/ +// sleepFn/onTick before calling start. +func newProcess(id string, log *logger.Logger, spawnFn func() (spawnedProcess, error), onStatusChange func(string, bool)) *process { + return &process{ + id: id, + log: log, + spawnFn: spawnFn, + onStatusChange: onStatusChange, + pingInterval: defaultPingInterval, + maxConsecutiveFails: defaultMaxConsecutiveFailures, + restartBackoff: defaultRestartBackoff(), + maxRestartAttempts: defaultMaxRestartAttempts, + sleepFn: sleepOrStop, + stopCh: make(chan struct{}), + } +} + +// sleepOrStop is the production sleepFn: blocks for d (or returns +// immediately if d<=0) unless stopCh fires first, per the select-based +// backoff convention in apps/backend/AGENTS.md ("never time.Sleep in a +// retry/backoff loop"). +func sleepOrStop(stopCh <-chan struct{}, d time.Duration) bool { + if d <= 0 { + select { + case <-stopCh: + return false + default: + return true + } + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return true + case <-stopCh: + return false + } +} + +// start spawns the process for the first time and launches the supervision +// loop. The initial spawn error is returned synchronously so a bad +// executable path/handshake failure fails Manager.Start fast rather than +// being discovered on the first supervision tick. +func (p *process) start() error { + proc, err := p.spawnFn() + if err != nil { + return err + } + p.mu.Lock() + p.current = proc + p.mu.Unlock() + + p.wg.Add(1) + go p.run() + return nil +} + +// stop signals the supervision loop to exit, waits for it to drain, and +// kills any still-running process. Idempotent-safe to call at most once per +// process (Manager.Stop removes the process from its map before calling +// this, so a second Stop(id) for the same id is a no-op at the Manager +// level). +func (p *process) stop() { + close(p.stopCh) + p.wg.Wait() + p.mu.Lock() + if p.current != nil { + p.current.Kill() + p.current = nil + } + p.mu.Unlock() +} + +// remote returns the current live RemotePlugin, if any. +func (p *process) remote() (*pluginsdk.RemotePlugin, bool) { + p.mu.Lock() + defer p.mu.Unlock() + if p.current == nil { + return nil, false + } + return p.current.Remote(), true +} + +// ping issues an on-demand health ping against the current process. +func (p *process) ping() error { + p.mu.Lock() + cur := p.current + p.mu.Unlock() + if cur == nil { + return errNotRunning(p.id) + } + return cur.Ping() +} + +// restartCount returns how many times this process has been successfully +// restarted since it was started. +func (p *process) restartCount() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.restarts +} + +// isGaveUp reports whether this process has permanently exhausted its +// restart attempts (its supervision loop has already exited and current is +// nil). Manager.claimStart uses this to distinguish a genuinely live entry +// from a dead one that a fresh Start should be allowed to replace. +func (p *process) isGaveUp() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.gaveUp +} + +// run is the supervision loop: on every tick (pingInterval, or immediately +// if stopped) it health-checks the current process, driving restart on +// failure. It returns either when stopCh fires or the process permanently +// gives up after exhausting restart attempts. +func (p *process) run() { + defer p.wg.Done() + for { + if !p.sleepFn(p.stopCh, p.pingInterval) { + return + } + gaveUp := p.tick() + if p.onTick != nil { + p.onTick() + } + if gaveUp { + return + } + } +} + +// tick performs one supervision check. Returns true if the process just +// permanently gave up (restart attempts exhausted), signalling run to stop +// looping. +func (p *process) tick() bool { + p.mu.Lock() + cur := p.current + p.mu.Unlock() + if cur == nil { + return false + } + + if cur.Exited() { + p.log.Warn("plugin process exited unexpectedly", zap.String("plugin_id", p.id)) + return p.handleFailureAndRestart() + } + + if err := cur.Ping(); err != nil { + p.mu.Lock() + p.failures++ + reachedThreshold := p.failures >= p.maxConsecutiveFails + p.mu.Unlock() + if reachedThreshold { + p.log.Warn("plugin health check failed repeatedly", + zap.String("plugin_id", p.id), zap.Error(err)) + return p.handleFailureAndRestart() + } + return false + } + + p.mu.Lock() + hadFailures := p.failures > 0 + p.failures = 0 + p.mu.Unlock() + if hadFailures { + p.notify(true) + } + return false +} + +// handleFailureAndRestart marks the plugin unhealthy, kills whatever is +// left of the old process, and attempts to respawn it with backoff up to +// maxRestartAttempts times. Returns true if every attempt failed (the +// process has given up permanently). +func (p *process) handleFailureAndRestart() bool { + p.notify(false) + + p.mu.Lock() + if p.current != nil { + p.current.Kill() + p.current = nil + } + p.failures = 0 + p.mu.Unlock() + + for attempt := 0; attempt < p.maxRestartAttempts; attempt++ { + delay := time.Duration(0) + if attempt < len(p.restartBackoff) { + delay = p.restartBackoff[attempt] + } + if !p.sleepFn(p.stopCh, delay) { + return false // stopping, not giving up + } + + proc, err := p.spawnFn() + if err != nil { + p.log.Warn("plugin restart attempt failed", + zap.String("plugin_id", p.id), zap.Int("attempt", attempt+1), zap.Error(err)) + continue + } + + p.mu.Lock() + p.current = proc + p.restarts++ + p.mu.Unlock() + p.notify(true) + return false + } + + p.log.Error("plugin exhausted restart attempts, giving up", + zap.String("plugin_id", p.id), zap.Int("max_attempts", p.maxRestartAttempts)) + p.mu.Lock() + p.gaveUp = true + p.mu.Unlock() + return true +} + +// notify invokes onStatusChange, if set. +func (p *process) notify(healthy bool) { + if p.onStatusChange != nil { + p.onStatusChange(p.id, healthy) + } +} diff --git a/apps/backend/internal/plugins/runtime/process_test.go b/apps/backend/internal/plugins/runtime/process_test.go new file mode 100644 index 0000000000..3e2fc787e5 --- /dev/null +++ b/apps/backend/internal/plugins/runtime/process_test.go @@ -0,0 +1,252 @@ +package runtime + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// fakeSpawnedProcess is a controllable spawnedProcess for unit-testing +// process's supervision decisions without a real subprocess. +type fakeSpawnedProcess struct { + mu sync.Mutex + pingErr error + exited bool + killed bool +} + +func (f *fakeSpawnedProcess) Ping() error { + f.mu.Lock() + defer f.mu.Unlock() + return f.pingErr +} + +func (f *fakeSpawnedProcess) Exited() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.exited +} + +func (f *fakeSpawnedProcess) Kill() { + f.mu.Lock() + defer f.mu.Unlock() + f.killed = true +} + +func (f *fakeSpawnedProcess) Remote() *pluginsdk.RemotePlugin { return nil } + +func (f *fakeSpawnedProcess) isKilled() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.killed +} + +// statusRecorder records every (id, healthy) pair passed to +// onStatusChange, for assertion. +type statusRecorder struct { + mu sync.Mutex + calls []bool +} + +func (r *statusRecorder) record(_ string, healthy bool) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, healthy) +} + +func (r *statusRecorder) snapshot() []bool { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]bool, len(r.calls)) + copy(out, r.calls) + return out +} + +// noSleep is a sleepFn that never actually blocks: it returns true +// immediately unless stopCh is already closed, in which case it returns +// false. Used by every test in this file so supervision-decision logic +// runs with zero real waiting. +func noSleep(stopCh <-chan struct{}, _ time.Duration) bool { + select { + case <-stopCh: + return false + default: + return true + } +} + +func testLogger(t *testing.T) *logger.Logger { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) + if err != nil { + t.Fatalf("new logger: %v", err) + } + return log +} + +func newTestProcess(t *testing.T, spawnFn func() (spawnedProcess, error), rec *statusRecorder) *process { + t.Helper() + p := newProcess("test-plugin", testLogger(t), spawnFn, rec.record) + p.sleepFn = noSleep + p.maxConsecutiveFails = 3 + p.restartBackoff = []time.Duration{0, 0, 0, 0, 0} + p.maxRestartAttempts = 5 + return p +} + +func TestProcess_Tick_HealthyPingIsANoOp(t *testing.T) { + rec := &statusRecorder{} + fake := &fakeSpawnedProcess{} + p := newTestProcess(t, nil, rec) + p.current = fake + + if gaveUp := p.tick(); gaveUp { + t.Fatal("tick() = gave up, want false for a healthy ping") + } + if fake.isKilled() { + t.Fatal("tick() killed the process on a healthy ping") + } + if len(rec.snapshot()) != 0 { + t.Fatalf("onStatusChange calls = %v, want none for a healthy ping", rec.snapshot()) + } +} + +func TestProcess_Tick_FailureBelowThresholdDoesNotRestart(t *testing.T) { + rec := &statusRecorder{} + fake := &fakeSpawnedProcess{pingErr: errors.New("boom")} + p := newTestProcess(t, nil, rec) + p.current = fake + p.maxConsecutiveFails = 3 + + p.tick() + if gaveUp := p.tick(); gaveUp { + t.Fatal("tick() = gave up, want false before reaching the failure threshold") + } + + if fake.isKilled() { + t.Fatal("tick() killed the process before reaching the failure threshold") + } + if len(rec.snapshot()) != 0 { + t.Fatalf("onStatusChange calls = %v, want none below the failure threshold", rec.snapshot()) + } + if p.failures != 2 { + t.Fatalf("p.failures = %d, want 2", p.failures) + } +} + +func TestProcess_Tick_ThresholdReachedRestartsAndNotifiesUnhealthyThenHealthy(t *testing.T) { + rec := &statusRecorder{} + failing := &fakeSpawnedProcess{pingErr: errors.New("boom")} + healthy := &fakeSpawnedProcess{} + spawnCalls := 0 + spawnFn := func() (spawnedProcess, error) { + spawnCalls++ + return healthy, nil + } + + p := newTestProcess(t, spawnFn, rec) + p.current = failing + p.maxConsecutiveFails = 1 + + if gaveUp := p.tick(); gaveUp { + t.Fatal("tick() = gave up, want false: the restart should have succeeded") + } + + if !failing.isKilled() { + t.Fatal("tick() did not kill the failing process before restarting") + } + if spawnCalls != 1 { + t.Fatalf("spawnFn called %d times, want 1", spawnCalls) + } + if _, ok := p.remote(); !ok { + t.Fatal("remote() ok = false after a successful restart, want true") + } + if p.restartCount() != 1 { + t.Fatalf("p.restartCount() = %d, want 1", p.restartCount()) + } + if want := []bool{false, true}; !equalBoolSlices(rec.snapshot(), want) { + t.Fatalf("onStatusChange calls = %v, want %v", rec.snapshot(), want) + } +} + +func TestProcess_Tick_ExitedTriggersImmediateRestartRegardlessOfFailureCount(t *testing.T) { + rec := &statusRecorder{} + exited := &fakeSpawnedProcess{exited: true} + healthy := &fakeSpawnedProcess{} + spawnFn := func() (spawnedProcess, error) { return healthy, nil } + + p := newTestProcess(t, spawnFn, rec) + p.current = exited + p.maxConsecutiveFails = 3 // would not be reached by ping failures alone + + if gaveUp := p.tick(); gaveUp { + t.Fatal("tick() = gave up, want false: the restart should have succeeded") + } + if want := []bool{false, true}; !equalBoolSlices(rec.snapshot(), want) { + t.Fatalf("onStatusChange calls = %v, want %v (immediate restart on exit)", rec.snapshot(), want) + } +} + +func TestProcess_HandleFailureAndRestart_GivesUpAfterMaxAttempts(t *testing.T) { + rec := &statusRecorder{} + spawnCalls := 0 + spawnFn := func() (spawnedProcess, error) { + spawnCalls++ + return nil, errors.New("spawn failed") + } + + p := newTestProcess(t, spawnFn, rec) + p.current = &fakeSpawnedProcess{} + p.maxRestartAttempts = 3 + p.restartBackoff = []time.Duration{0, 0, 0} + + if gaveUp := p.handleFailureAndRestart(); !gaveUp { + t.Fatal("handleFailureAndRestart() = false, want true after exhausting every attempt") + } + if spawnCalls != 3 { + t.Fatalf("spawnFn called %d times, want 3 (maxRestartAttempts)", spawnCalls) + } + if want := []bool{false}; !equalBoolSlices(rec.snapshot(), want) { + t.Fatalf("onStatusChange calls = %v, want %v (only the initial degradation, no false recovery)", rec.snapshot(), want) + } + if _, ok := p.remote(); ok { + t.Fatal("remote() ok = true after giving up, want false") + } +} + +func TestProcess_HandleFailureAndRestart_StopDuringBackoffSkipsSpawn(t *testing.T) { + rec := &statusRecorder{} + spawnCalls := 0 + spawnFn := func() (spawnedProcess, error) { + spawnCalls++ + return &fakeSpawnedProcess{}, nil + } + + p := newTestProcess(t, spawnFn, rec) + p.current = &fakeSpawnedProcess{} + close(p.stopCh) // simulate Stop() racing the backoff wait + p.sleepFn = sleepOrStop + + if gaveUp := p.handleFailureAndRestart(); gaveUp { + t.Fatal("handleFailureAndRestart() = true, want false: stopping is not the same as giving up") + } + if spawnCalls != 0 { + t.Fatalf("spawnFn called %d times, want 0: stop should short-circuit before any spawn attempt", spawnCalls) + } +} + +func equalBoolSlices(a, b []bool) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/apps/backend/internal/plugins/runtime/testdata/fixtureplugin/main.go b/apps/backend/internal/plugins/runtime/testdata/fixtureplugin/main.go new file mode 100644 index 0000000000..2fa75610c0 --- /dev/null +++ b/apps/backend/internal/plugins/runtime/testdata/fixtureplugin/main.go @@ -0,0 +1,53 @@ +// Command fixtureplugin is a minimal kandev plugin backend built from +// pkg/pluginsdk, used only by internal/plugins/runtime's tests to exercise +// a real go-plugin subprocess spawn/handshake/DeliverEvent/InvokeTool/ +// crash-restart cycle end to end. It is built at test time (see +// internal/plugins/runtime/manager_test.go's TestMain) into a temp binary; +// it is never shipped. +package main + +import ( + "context" + "os" + + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// fixturePlugin embeds UnimplementedPlugin (for Host injection via +// SetHost/Host) and overrides every RPC with test-observable behavior: +// - OnEvent records the last delivered event into Host state, so an +// out-of-process test can observe delivery by reading that state back +// through its own fake Host implementation. +// - InvokeTool echoes its input back as output, except tool name "crash" +// which exits the process immediately (to exercise crash detection + +// restart), and "ping-host" which round-trips a GetState call. +// - HandleWebhook echoes the request back as a response body. +type fixturePlugin struct { + pluginsdk.UnimplementedPlugin +} + +func (p *fixturePlugin) OnEvent(ctx context.Context, e *pluginsdk.Event) error { + host := p.Host() + if host == nil { + return nil + } + return host.SetState(ctx, "instance", "", "last_event", map[string]any{ + "event_type": e.EventType, + "event_id": e.EventID, + }) +} + +func (p *fixturePlugin) InvokeTool(_ context.Context, req *pluginsdk.ToolRequest) (*pluginsdk.ToolResponse, error) { + if req.ToolName == "crash" { + os.Exit(1) + } + return &pluginsdk.ToolResponse{Output: req.Input}, nil +} + +func (p *fixturePlugin) HandleWebhook(_ context.Context, req *pluginsdk.WebhookRequest) (*pluginsdk.WebhookResponse, error) { + return &pluginsdk.WebhookResponse{Status: 200, Body: req.Body}, nil +} + +func main() { + pluginsdk.Serve(&fixturePlugin{}) +} diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go new file mode 100644 index 0000000000..2fb4f88db4 --- /dev/null +++ b/apps/backend/internal/plugins/service.go @@ -0,0 +1,703 @@ +package plugins + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/pkgtar" + "github.com/kandev/kandev/internal/plugins/state" + "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// maxDownloadSize caps the response body InstallFromURL will read, per the +// task's build instructions (100MB cap). +const maxDownloadSize = 100 << 20 + +// downloadTimeout bounds how long InstallFromURL waits for the whole +// download. +const downloadTimeout = 60 * time.Second + +// Service is the core plugin service: install/uninstall, the in-memory +// Registry, the lifecycle state machine, and the runtime.Manager wiring +// that spawns/supervises each plugin's subprocess. +// +// # Extension points +// +// Event delivery (internal/plugins/delivery) is wired in by backendapp +// after Provide, following the same post-construction "SetX" pattern +// internal/jira/service.go uses for SetTaskDeleter / SetRepositoryLookup +// (avoids an import cycle between this package and its siblings): +// +// - SetDeliverer(d Deliverer) attaches the event-delivery subsystem. +// Install, Uninstall, Enable, Disable, and any successful SetStatus +// call notify the attached Deliverer via Refresh() so it can +// re-subscribe to the event bus based on current registry state. +// - StateStore() exposes the already-constructed *state.Store so the +// HTTP layer doesn't need a second NewStore(pool) call. +// - Registry() and EventBus() are exposed for any other read-only wiring +// (e.g. proxies checking a plugin's manifest/capabilities without +// going through Service's error-wrapping Get). +type Service struct { + mu sync.Mutex + + // syncMu serializes Sync/bootScan calls (service_sync.go) so concurrent + // operator clicks — or a boot scan racing an operator-triggered sync — + // cannot double-install the same dropped tarball or dir sideload. + syncMu sync.Mutex + + // lifecycleLocks serializes Enable/Disable/Install/InstallFromURL/ + // Uninstall/UpdateConfig per plugin id, so two near-simultaneous + // lifecycle requests for the same id (e.g. two Enable clicks) cannot + // both pass an idempotency check built on a stale read and race each + // other's status-machine transition. Different ids stay fully + // concurrent. Never taken by handleStatusChange (the runtime.Manager + // supervision-goroutine callback) — that path only touches s.mu — so + // holding a lifecycleLocks entry while calling into PluginRuntime + // cannot deadlock against it. + lifecycleLocks *keyedMutex + + pluginsDir string + store store.Store + registry *Registry + state *state.Store + eventBus bus.EventBus + log *logger.Logger + + deliverer Deliverer + runtime PluginRuntime + secrets SecretRevealer + + // kandevVersion is the currently running kandev build version, used to + // enforce a package's manifest.min_kandev_version at Install (see + // SetKandevVersion / checkMinKandevVersion). Empty (the default) means + // no enforcement — no caller currently wires this in production; see + // SetKandevVersion's doc comment. + kandevVersion string + + httpClient *http.Client +} + +// NewService wires a Service from its already-constructed dependencies. +// Provide is the usual entry point in production; NewService is exposed +// directly for tests that want a fake store.Store/PluginRuntime. +func NewService(pluginStore store.Store, registry *Registry, eventBus bus.EventBus, log *logger.Logger) *Service { + return &Service{ + store: pluginStore, + registry: registry, + eventBus: eventBus, + log: log, + httpClient: &http.Client{}, + lifecycleLocks: newKeyedMutex(), + } +} + +// keyedMutex hands out a *sync.Mutex per key, creating it on first use and +// keeping it around for the process lifetime (the plugin id keyspace is +// small and long-lived, so there is nothing to garbage-collect). Mirrors the +// parentMutex pattern in internal/task/service/handoff_service.go. +type keyedMutex struct { + mu sync.Mutex + locks map[string]*sync.Mutex +} + +func newKeyedMutex() *keyedMutex { + return &keyedMutex{locks: make(map[string]*sync.Mutex)} +} + +// lockFor returns the mutex for key, creating it if this is the first call +// for that key. Callers must Lock/Unlock the returned mutex themselves. +func (k *keyedMutex) lockFor(key string) *sync.Mutex { + k.mu.Lock() + defer k.mu.Unlock() + m, ok := k.locks[key] + if !ok { + m = &sync.Mutex{} + k.locks[key] = m + } + return m +} + +// SetDeliverer attaches the event-delivery subsystem. See the "Extension +// points" doc comment on Service. Safe to call at most once during startup +// wiring; not safe to call concurrently with Install/SetStatus/Uninstall. +func (s *Service) SetDeliverer(d Deliverer) { + s.mu.Lock() + defer s.mu.Unlock() + s.deliverer = d +} + +// Deliverer returns the currently attached event-delivery subsystem, or nil +// if none has been attached yet. +func (s *Service) Deliverer() Deliverer { + s.mu.Lock() + defer s.mu.Unlock() + return s.deliverer +} + +// SetState wires the already-constructed plugin_state store. Provide calls +// this; also exposed for tests (in this package and others, e.g. +// internal/backendapp) that build a Service without going through Provide. +func (s *Service) SetState(st *state.Store) { + s.state = st +} + +// StateStore returns the plugin_state store Provide constructed, for the +// Host RPC implementation (host.go) and any HTTP wiring that needs it +// without re-initializing the schema. +func (s *Service) StateStore() *state.Store { + return s.state +} + +// SetSecrets wires the secret revealer Provide was constructed with. +func (s *Service) SetSecrets(v SecretRevealer) { + s.secrets = v +} + +// SetKandevVersion wires the currently running kandev build version, +// enabling Install to enforce a package's manifest.min_kandev_version +// (checkMinKandevVersion): a package requiring a newer kandev is rejected +// rather than installed and left to fail confusingly at spawn time. Not +// currently called by Provide — the running build version needs to be +// threaded down from internal/backendapp's ldflags-injected Version, which +// is outside this package; until a caller wires it, min_kandev_version +// remains parsed and stored but unenforced (the pre-existing behavior). +func (s *Service) SetKandevVersion(v string) { + s.kandevVersion = v +} + +// SetRuntime wires the runtime.Manager Provide constructed. +func (s *Service) SetRuntime(rt PluginRuntime) { + s.runtime = rt +} + +// Runtime returns the runtime manager Service spawns/supervises plugin +// processes through, for boot-time wiring (spawning every active plugin) +// and the HTTP layer (webhook/tool invocation). +func (s *Service) Runtime() PluginRuntime { + return s.runtime +} + +// Shutdown stops every currently-running plugin process. Callers (e.g. +// backendapp's startPluginsSubsystems) register this with addCleanup for +// graceful backend shutdown. +func (s *Service) Shutdown() { + if s.runtime != nil { + s.runtime.StopAll() + } +} + +// SetPluginsDir wires the root directory pkgtar.Install/pkgtar.Remove +// operate under (the same directory store.FSStore persists records in). +func (s *Service) SetPluginsDir(dir string) { + s.pluginsDir = dir +} + +// RevealSecret resolves the cleartext value of the secret reference ref via +// the shared secret vault. Returns an error if no vault was wired (e.g. a +// test Service constructed via NewService directly) or if ref does not +// resolve. +func (s *Service) RevealSecret(ctx context.Context, ref string) (string, error) { + if s.secrets == nil { + return "", errors.New("plugins: secret vault not configured") + } + return s.secrets.Reveal(ctx, ref) +} + +// ActiveUIPlugins returns every StatusActive plugin record that declares a +// native UI bundle (ui.bundle), used to populate the boot payload's Plugins +// list. +func (s *Service) ActiveUIPlugins() []store.Record { + var out []store.Record + for _, rec := range s.List() { + if rec.Status == StatusActive && rec.UI.Bundle != "" { + out = append(out, *rec) + } + } + return out +} + +// Registry returns the underlying in-memory Registry. +func (s *Service) Registry() *Registry { + return s.registry +} + +// EventBus returns the event bus Service was constructed with (may be nil +// in tests). +func (s *Service) EventBus() bus.EventBus { + return s.eventBus +} + +// hostForPlugin builds the Host implementation bound to pluginID, gated by +// that plugin's currently-registered capabilities. Passed to +// PluginRuntime.Start as the hostFactory; the runtime manager calls it +// again on every restart, so a config/capability change takes effect on +// the plugin's next spawn. +func (s *Service) hostForPlugin(pluginID string) pluginsdk.Host { + rec, err := s.Get(pluginID) + if err != nil { + rec = &store.Record{} // every capability check below denies; should not happen in practice + } + return &pluginHost{ + pluginID: pluginID, + capabilities: rec.Capabilities, + state: s.state, + secrets: s.secrets, + bus: s.eventBus, + } +} + +// notifyDeliverer calls Refresh on the attached Deliverer, if any. Must be +// called without s.mu held (Deliverer implementations may call back into +// Service). +func (s *Service) notifyDeliverer() { + s.mu.Lock() + d := s.deliverer + s.mu.Unlock() + if d != nil { + d.Refresh() + } +} + +// List returns every installed plugin, sorted by id. +func (s *Service) List() []*store.Record { + return s.registry.List() +} + +// Get returns the record for id, or store.ErrNotFound. +func (s *Service) Get(id string) (*store.Record, error) { + rec, ok := s.registry.Get(id) + if !ok { + return nil, store.ErrNotFound + } + return rec, nil +} + +// UpdateConfig replaces the operator-editable config for id. +func (s *Service) UpdateConfig(id string, config map[string]any) error { + lock := s.lifecycleLocks.lockFor(id) + lock.Lock() + defer lock.Unlock() + + if _, err := s.Get(id); err != nil { + return err + } + return s.store.SetConfig(id, config) +} + +// Install verifies and extracts r (a tar.gz plugin package) via pkgtar into +// the plugins directory, persists a fresh store.Record (status +// "registered"), adds it to the in-memory registry, and attempts to spawn +// and activate it. A pkgtar error (e.g. pkgtar.ErrVersionExists) is +// returned unchanged so callers can map it to the right HTTP status. If the +// package is valid but the initial spawn fails, the record is still +// persisted (status "error") and returned alongside the spawn error, so an +// operator can fix the issue and retry via Enable. +// +// Installing a new version of a plugin id that is currently active/running +// stops the old process first (activate's own "already running" idempotency +// check would otherwise skip spawning entirely, leaving the live subprocess +// running the OLD version's binary even though the record/install_path now +// point at the new one). If persisting the fresh record then fails, +// rollbackFailedInstall removes only the just-extracted version directory +// (every other installed version, and the plugin's writable data directory, +// survive) and restarts the previous version's process, so a failed upgrade +// attempt never destroys a previously working install. +func (s *Service) Install(ctx context.Context, r io.Reader) (*store.Record, error) { + result, err := pkgtar.Install(r, s.pluginsDir) + if err != nil { + return nil, err + } + if err := s.checkMinKandevVersion(result.Manifest.MinKandevVersion); err != nil { + _ = os.RemoveAll(result.InstallPath) + return nil, err + } + + // The plugin id is only known once pkgtar.Install has parsed the + // package's manifest, so the per-plugin lock is acquired here rather + // than at the very top of the function — this still covers + // InstallFromURL, which calls through to Install. It serializes the + // rest of this method (the record/registry/activate mutation) against + // any other Enable/Disable/Install/Uninstall/UpdateConfig call for the + // same id. + lock := s.lifecycleLocks.lockFor(result.Manifest.ID) + lock.Lock() + defer lock.Unlock() + + oldRec, hadOldRec := s.registry.Get(result.Manifest.ID) + wasRunning := s.runtime != nil && s.runtime.Running(result.Manifest.ID) + if wasRunning { + s.runtime.Stop(result.Manifest.ID) + } + + rec := &store.Record{ + Manifest: *result.Manifest, + Status: StatusRegistered, + InstallPath: result.InstallPath, + Signed: result.Signed, + InstalledAt: time.Now().UTC(), + } + if err := s.store.Save(rec); err != nil { + s.rollbackFailedInstall(result.InstallPath, oldRec, hadOldRec && wasRunning) + return nil, fmt.Errorf("plugins: persist installed record: %w", err) + } + s.registry.Add(rec) + + activateErr := s.activate(rec) + s.notifyDeliverer() + + installed, getErr := s.Get(rec.ID) + if getErr != nil { + return rec, activateErr + } + return installed, activateErr +} + +// checkMinKandevVersion rejects a package whose manifest declares a +// min_kandev_version newer than the currently running kandev build +// (manifest.CompareVersions). A no-op (nil error) when either side is +// unset: minVersion == "" (the manifest doesn't declare one, the common +// case today) or s.kandevVersion == "" (no running version wired via +// SetKandevVersion). +func (s *Service) checkMinKandevVersion(minVersion string) error { + if minVersion == "" || s.kandevVersion == "" { + return nil + } + if manifest.CompareVersions(s.kandevVersion, minVersion) < 0 { + return fmt.Errorf("plugins: requires kandev >= %s, running %s", minVersion, s.kandevVersion) + } + return nil +} + +// rollbackFailedInstall cleans up after a store.Save failure partway +// through Install: it removes only freshInstallPath (the version directory +// pkgtar.Install just extracted), never the whole destRoot// tree — +// other installed versions and the plugin's writable data directory +// (destRoot//data) must survive. If restartOld is true (an existing +// record was running and got stopped to make way for this install), +// oldRec's process is best-effort restarted so the failed upgrade attempt +// doesn't also take down the previously working version; a restart failure +// is logged, not returned, since Install is already returning the original +// Save error. +func (s *Service) rollbackFailedInstall(freshInstallPath string, oldRec *store.Record, restartOld bool) { + if err := os.RemoveAll(freshInstallPath); err != nil { + s.log.Warn("plugins: failed to remove extracted package after a persist failure", + zap.String("install_path", freshInstallPath), zap.Error(err)) + } + if !restartOld || s.runtime == nil || oldRec == nil { + return + } + startCtx, cancel := context.WithTimeout(context.Background(), activateStartTimeout) + defer cancel() + if err := s.runtime.Start(startCtx, oldRec, s.hostForPlugin); err != nil { + s.log.Warn("plugins: failed to restart previous version after a failed upgrade", + zap.String("plugin_id", oldRec.ID), zap.Error(err)) + } +} + +// InstallFromURL downloads url (capped at maxDownloadSize, bounded by +// downloadTimeout) and installs it via Install. url is operator-provided +// (an admin installing a plugin from a URL), so this does not attempt full +// SSRF elimination, but validateInstallURL rejects non-http(s) schemes and +// URLs with no host before any request is built. +func (s *Service) InstallFromURL(ctx context.Context, url string) (*store.Record, error) { + if err := validateInstallURL(url); err != nil { + return nil, fmt.Errorf("plugins: %w", err) + } + + reqCtx, cancel := context.WithTimeout(ctx, downloadTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("plugins: build download request: %w", err) + } + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("plugins: download package: %w", err) + } + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("plugins: download package: server responded %d", resp.StatusCode) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadSize+1)) + if err != nil { + return nil, fmt.Errorf("plugins: read package: %w", err) + } + if int64(len(data)) > maxDownloadSize { + return nil, fmt.Errorf("plugins: package exceeds max download size of %d bytes", maxDownloadSize) + } + + return s.Install(ctx, bytes.NewReader(data)) +} + +// validateInstallURL is the sink-level guard InstallFromURL applies before +// building any outbound request: raw must parse as a URL with an http or +// https scheme and a non-empty host. It rejects file://, gopher://, and +// other schemes that would let an operator-supplied string reach something +// other than a plain HTTP(S) fetch. This narrows, but does not eliminate, +// the residual SSRF surface inherent to letting an operator point the +// installer at an arbitrary http(s) URL (including internal hosts). +func validateInstallURL(raw string) error { + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid install URL: %w", err) + } + switch parsed.Scheme { + case "http", "https": + default: + return fmt.Errorf("invalid install URL: unsupported scheme %q (must be http or https)", parsed.Scheme) + } + if parsed.Hostname() == "" { + return errors.New("invalid install URL: missing host") + } + return nil +} + +// Uninstall stops id's process (if running), 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 { + lock := s.lifecycleLocks.lockFor(id) + lock.Lock() + defer lock.Unlock() + + if _, err := s.Get(id); err != nil { + return err + } + if s.runtime != nil { + s.runtime.Stop(id) + } + if err := pkgtar.Remove(s.pluginsDir, id); err != nil { + return fmt.Errorf("plugins: remove installed package: %w", err) + } + if err := s.store.Delete(id); err != nil { + return err + } + s.registry.Remove(id) + s.deletePluginState(id) + s.notifyDeliverer() + return nil +} + +// deletePluginState best-effort removes every plugin_state row for id. A +// nil state store (e.g. a Service constructed without SetState in tests, or +// before backendapp finishes wiring) is a silent no-op. +func (s *Service) deletePluginState(id string) { + if s.state == nil { + return + } + if err := s.state.DeleteAll(context.Background(), id); err != nil { + s.log.Warn("plugins: failed to delete plugin_state on uninstall", zap.String("plugin_id", id), zap.Error(err)) + } +} + +// Enable transitions id to StatusActive, spawning its process first if it +// is not already running. Idempotent: a no-op (nil error) if id is already +// active. +func (s *Service) Enable(id string) error { + lock := s.lifecycleLocks.lockFor(id) + lock.Lock() + defer lock.Unlock() + + rec, err := s.Get(id) + if err != nil { + return err + } + if rec.Status == StatusActive { + return nil + } + if err := s.activate(rec); err != nil { + return err + } + s.notifyDeliverer() + return nil +} + +// Disable stops id's process (if running) and transitions it to +// StatusDisabled. Idempotent: a no-op (nil error) if id is already +// disabled. +func (s *Service) Disable(id string) error { + lock := s.lifecycleLocks.lockFor(id) + lock.Lock() + defer lock.Unlock() + + rec, err := s.Get(id) + if err != nil { + return err + } + if rec.Status == StatusDisabled { + return nil + } + if s.runtime != nil { + s.runtime.Stop(id) + } + if err := s.SetStatus(id, StatusDisabled); err != nil { + return err + } + s.notifyDeliverer() + return nil +} + +// activateStartTimeout bounds the context activate hands to runtime.Start, +// so a hung plugin binary cannot block Enable/Install indefinitely. The +// runtime.Manager itself also enforces a startTimeout on the underlying +// go-plugin handshake (the actual blocking call is not context-aware); this +// context bound is defense-in-depth and gives Start a chance to short-circuit +// on ctx.Err() before ever spawning. +const activateStartTimeout = 30 * time.Second + +// activate spawns rec's process (if not already running) and transitions it +// to StatusActive. If the spawn fails, it best-effort transitions the +// record to StatusError (ignoring an invalid-transition failure, e.g. from +// "disabled") and returns the spawn error. +func (s *Service) activate(rec *store.Record) error { + if s.runtime != nil && !s.runtime.Running(rec.ID) { + ctx, cancel := context.WithTimeout(context.Background(), activateStartTimeout) + defer cancel() + if err := s.runtime.Start(ctx, rec, s.hostForPlugin); err != nil { + _ = s.SetStatus(rec.ID, StatusError) + return fmt.Errorf("plugins: start %q: %w", rec.ID, err) + } + } + return s.SetStatus(rec.ID, StatusActive) +} + +// SetStatus applies a single-hop status transition for id, enforcing the +// state machine (allowedTransitions in types.go). On success the change is +// persisted to the store and applied to the in-memory registry. Returns +// *ErrInvalidTransition without mutating anything if the transition is not +// legal, and store.ErrNotFound if id is not installed. Callers that need +// the attached Deliverer notified (most of them) call notifyDeliverer +// separately — SetStatus itself does not, since activate/Disable call it +// both for the runtime spawn/stop and the status transition, and only want +// a single Refresh for the whole operation. +func (s *Service) SetStatus(id string, status Status) error { + s.mu.Lock() + + rec, ok := s.registry.Get(id) + if !ok { + s.mu.Unlock() + return store.ErrNotFound + } + if !canTransition(rec.Status, status) { + s.mu.Unlock() + return &ErrInvalidTransition{ID: id, From: rec.Status, To: status} + } + + updated, ok := s.registry.SetStatus(id, status) + if !ok { + s.mu.Unlock() + return store.ErrNotFound + } + if err := s.store.Save(updated); err != nil { + // Roll back the in-memory change so registry and disk stay in sync. + s.registry.SetStatus(id, rec.Status) + s.mu.Unlock() + return err + } + s.mu.Unlock() + return nil +} + +// handleStatusChange is the runtime.Manager OnStatusChange callback (see +// Provide, where it is bound as a Manager constructor argument): invoked +// from the supervision loop's own goroutine whenever a running plugin's +// health transitions. healthy=false drives active -> error; healthy=true +// drives error -> active plus a Deliverer.Flush (the buffered-event +// recovery replay). Restart count is persisted best-effort afterward. +func (s *Service) handleStatusChange(id string, healthy bool) { + newStatus := StatusError + if healthy { + newStatus = StatusActive + } + if err := s.SetStatus(id, newStatus); err != nil { + s.log.Warn("plugins: health transition failed", + zap.String("plugin_id", id), zap.Bool("healthy", healthy), zap.Error(err)) + } else { + s.notifyDeliverer() + if healthy { + if d := s.Deliverer(); d != nil { + d.Flush(id) + } + } + } + s.recordRestartCount(id) +} + +// recordRestartCount best-effort persists the runtime manager's current +// restart count for id onto its store.Record. +func (s *Service) recordRestartCount(id string) { + if s.runtime == nil { + return + } + updated, ok := s.registry.SetRestartCount(id, s.runtime.RestartCount(id)) + if !ok { + return + } + if err := s.store.Save(updated); err != nil { + s.log.Warn("plugins: persist restart count failed", zap.String("plugin_id", id), zap.Error(err)) + } +} + +// StartActivePlugins runs the conservative boot filesystem scan (dir +// sideloads registered disabled, missing-install detection — see +// service_sync.go's bootScan) and then spawns every currently-StatusActive, +// runtime-managed plugin's process. Called once at boot (backendapp's +// startPluginsSubsystems) so plugins that were active before a restart +// resume running. A spawn failure is logged and the plugin transitions to +// StatusError rather than aborting the rest of the boot sequence. +func (s *Service) StartActivePlugins(ctx context.Context) { + s.logBootScanResult(s.bootScan(ctx)) + + if s.runtime == nil { + return + } + for _, rec := range s.List() { + if rec.Status != StatusActive || !rec.IsManaged() || s.runtime.Running(rec.ID) { + continue + } + if err := s.runtime.Start(ctx, rec, s.hostForPlugin); err != nil { + s.log.Warn("plugins: failed to spawn active plugin at boot", + zap.String("plugin_id", rec.ID), zap.Error(err)) + _ = s.SetStatus(rec.ID, StatusError) + } + } +} + +// logBootScanResult logs what the boot filesystem scan found, if anything — +// a silent no-op scan (the common case) logs nothing. +func (s *Service) logBootScanResult(result *SyncResult) { + if result == nil || (len(result.Added) == 0 && len(result.Missing) == 0 && len(result.Errors) == 0) { + return + } + s.log.Info("plugins: boot filesystem scan found changes", + zap.Strings("sideloaded", result.Added), + zap.Strings("missing", result.Missing), + zap.Int("errors", len(result.Errors))) + for _, e := range result.Errors { + s.log.Warn("plugins: boot scan error", zap.String("path", e.Path), zap.String("reason", e.Reason)) + } +} diff --git a/apps/backend/internal/plugins/service_install_test.go b/apps/backend/internal/plugins/service_install_test.go new file mode 100644 index 0000000000..b1c51c0cc4 --- /dev/null +++ b/apps/backend/internal/plugins/service_install_test.go @@ -0,0 +1,321 @@ +package plugins + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + goruntime "runtime" + "sync" + "testing" + + "github.com/kandev/kandev/internal/plugins/pkgtar" + "github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest" + "github.com/kandev/kandev/internal/plugins/store" +) + +func TestServiceInstallActivatesOnSuccessfulSpawn(t *testing.T) { + svc, _, rt := newTestService(t) + + rec := installTestPlugin(t, svc, "kandev-plugin-slack") + if rec.Status != StatusActive { + t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive) + } + if !rt.Running("kandev-plugin-slack") { + t.Fatal("Install() did not spawn the plugin via the runtime manager") + } + + got, err := svc.Get("kandev-plugin-slack") + if err != nil { + t.Fatalf("Get() unexpected error: %v", err) + } + if got.InstallPath == "" { + t.Fatal("Get().InstallPath is empty after Install()") + } +} + +func TestServiceInstallDuplicateVersionReturnsErrVersionExists(t *testing.T) { + svc, _, _ := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + + _, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-slack", "1.0.0", false)) + if !errors.Is(err, pkgtar.ErrVersionExists) { + t.Fatalf("Install() duplicate error = %v, want pkgtar.ErrVersionExists", err) + } +} + +func TestServiceInstallSpawnFailureLeavesRecordInErrorStatus(t *testing.T) { + svc, fsStore, rt := newTestService(t) + rt.setStartErr("kandev-plugin-slack", errors.New("spawn failed")) + + rec, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-slack", "1.0.0", false)) + if err == nil { + t.Fatal("Install() expected an error when the spawn fails") + } + if rec == nil { + t.Fatal("Install() expected a non-nil record even when the spawn fails (package was extracted)") + } + if rec.Status != StatusError { + t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusError) + } + + onDisk, getErr := fsStore.Get("kandev-plugin-slack") + if getErr != nil { + t.Fatalf("store.Get(): %v", getErr) + } + if onDisk.Status != StatusError { + t.Fatalf("store.Get().Status = %q, want %q", onDisk.Status, StatusError) + } +} + +// TestServiceInstallOverActivePluginRestartsWithNewVersion pins the fix for +// installing a new version over an already-active plugin: activate's +// "already running" short-circuit previously skipped spawning entirely, so +// the live subprocess kept running the OLD version's binary even though the +// record/install_path now pointed at the new one. Install must stop the old +// process and start the new install_path so the running process matches the +// installed version. +func TestServiceInstallOverActivePluginRestartsWithNewVersion(t *testing.T) { + svc, _, rt := newTestService(t) + rec1 := installTestPlugin(t, svc, "kandev-plugin-slack") // v1.0.0, active + running + if !rt.Running("kandev-plugin-slack") { + t.Fatal("sanity check: plugin not running after first install") + } + + rec2, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-slack", "1.1.0", false)) + if err != nil { + t.Fatalf("second Install() unexpected error: %v", err) + } + if rec2.Version != "1.1.0" { + t.Fatalf("rec2.Version = %q, want %q", rec2.Version, "1.1.0") + } + if rec2.InstallPath == rec1.InstallPath { + t.Fatal("rec2.InstallPath == rec1.InstallPath, want the new version's own install dir") + } + if !rt.stopped("kandev-plugin-slack") { + t.Fatal("Install() over an active plugin did not stop the old runtime process") + } + if got := rt.startCallCount("kandev-plugin-slack"); got != 2 { + t.Fatalf("runtime Start called %d times, want 2 (initial install + restart on reinstall)", got) + } + if !rt.Running("kandev-plugin-slack") { + t.Fatal("plugin not running after reinstalling over an already-active version") + } + if rec2.Status != StatusActive { + t.Fatalf("rec2.Status = %q, want %q", rec2.Status, StatusActive) + } +} + +// failingSaveStore wraps a real store.Store, letting a test arm exactly one +// Save call to fail with a simulated error — used to exercise Install's +// cleanup-on-persist-failure path without a real disk-full/permission +// simulation. +type failingSaveStore struct { + store.Store + mu sync.Mutex + failNext bool +} + +func (s *failingSaveStore) Save(rec *store.Record) error { + s.mu.Lock() + fail := s.failNext + s.failNext = false + s.mu.Unlock() + if fail { + return errors.New("simulated save failure") + } + return s.Store.Save(rec) +} + +func (s *failingSaveStore) armFailNextSave() { + s.mu.Lock() + s.failNext = true + s.mu.Unlock() +} + +// TestServiceInstallUpgradeSaveFailurePreservesOldVersionAndData pins the +// fix for a failed upgrade destroying every installed version plus the +// plugin's writable data directory: cleanup on a store.Save failure must +// remove only the freshly extracted version directory, and must restart the +// previous version's process (already stopped to make way for the new +// spawn) rather than leaving the plugin down. +func TestServiceInstallUpgradeSaveFailurePreservesOldVersionAndData(t *testing.T) { + dir := t.TempDir() + fsStore := store.NewFSStore(dir) + failing := &failingSaveStore{Store: fsStore} + reg := NewRegistry() + svc := NewService(failing, reg, nil, testLogger(t)) + svc.SetPluginsDir(dir) + rt := newFakeRuntime() + svc.SetRuntime(rt) + + rec1 := installTestPlugin(t, svc, "kandev-plugin-slack") // v1.0.0, active + running + if !rt.Running("kandev-plugin-slack") { + t.Fatal("sanity check: plugin not running after first install") + } + + // Simulate the old version's writable data directory holding user data. + dataDir := filepath.Join(dir, "kandev-plugin-slack", "data") + if err := os.MkdirAll(dataDir, 0o755); err != nil { + t.Fatalf("MkdirAll(dataDir): %v", err) + } + marker := filepath.Join(dataDir, "marker.txt") + if err := os.WriteFile(marker, []byte("keep me"), 0o644); err != nil { + t.Fatalf("WriteFile(marker): %v", err) + } + + failing.armFailNextSave() + _, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-slack", "1.1.0", false)) + if err == nil { + t.Fatal("Install() expected an error from the simulated Save failure") + } + + if _, statErr := os.Stat(rec1.InstallPath); statErr != nil { + t.Fatalf("old version dir %q was removed by cleanup: %v", rec1.InstallPath, statErr) + } + if _, statErr := os.Stat(marker); statErr != nil { + t.Fatalf("plugin data dir marker was removed by cleanup: %v", statErr) + } + newVersionDir := filepath.Join(dir, "kandev-plugin-slack", "1.1.0") + if _, statErr := os.Stat(newVersionDir); !os.IsNotExist(statErr) { + t.Fatalf("new version dir %q still exists after the Save failure: stat err = %v", newVersionDir, statErr) + } + if !rt.Running("kandev-plugin-slack") { + t.Fatal("the previous version's process was not restarted after the failed upgrade") + } + + onDisk, getErr := fsStore.Get("kandev-plugin-slack") + if getErr != nil { + t.Fatalf("store.Get(): %v", getErr) + } + if onDisk.Version != "1.0.0" { + t.Fatalf("store record Version = %q, want %q (the old version's record must survive unchanged)", onDisk.Version, "1.0.0") + } +} + +// minKandevVersionPackage builds a valid, runtime-managed plugin tar.gz +// declaring min_kandev_version, for TestServiceInstall_MinKandevVersion*. +func minKandevVersionPackage(t *testing.T, id, version, minKandevVersion string) *bytes.Buffer { + t.Helper() + platformKey := goruntime.GOOS + "-" + goruntime.GOARCH + manifestYAML := fmt.Sprintf(` +id: %s +api_version: 1 +version: %s +display_name: Test Plugin +min_kandev_version: %q +runtime: + type: binary + executables: + %s: server/plugin +`, id, version, minKandevVersion, 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 +} + +// TestServiceInstall_RejectsPackageNewerThanRunningKandevVersion pins +// enforcement of manifest.min_kandev_version: once a running kandev version +// is wired via SetKandevVersion, a package that requires a newer kandev +// must be rejected (and its extracted package directory cleaned up) rather +// than installed and left to fail confusingly at spawn time. +func TestServiceInstall_RejectsPackageNewerThanRunningKandevVersion(t *testing.T) { + svc, dir, _, _ := newTestServiceWithDir(t) + svc.SetKandevVersion("1.0.0") + + _, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", "2.0.0")) + if err == nil { + t.Fatal("Install() expected an error for a package requiring a newer kandev version") + } + if _, statErr := os.Stat(filepath.Join(dir, "kandev-plugin-slack", "1.0.0")); !os.IsNotExist(statErr) { + t.Fatalf("Install() left the extracted package on disk after rejecting it: stat err = %v", statErr) + } + if _, getErr := svc.Get("kandev-plugin-slack"); getErr == nil { + t.Fatal("Install() registered a record for a package it rejected on min_kandev_version") + } +} + +// TestServiceInstall_AllowsPackageAtOrBelowRunningKandevVersion pins the +// non-blocking side of the same check. +func TestServiceInstall_AllowsPackageAtOrBelowRunningKandevVersion(t *testing.T) { + svc, _, _ := newTestService(t) + svc.SetKandevVersion("2.0.0") + + rec, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", "1.5.0")) + if err != nil { + t.Fatalf("Install() unexpected error: %v", err) + } + if rec.Status != StatusActive { + t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive) + } +} + +// TestServiceInstall_NoEnforcementWithoutKandevVersionWired pins the +// no-op default: a Service that never called SetKandevVersion (e.g. every +// other test in this file, and any caller that hasn't wired it yet) must +// keep installing packages that declare min_kandev_version exactly as +// before. +func TestServiceInstall_NoEnforcementWithoutKandevVersionWired(t *testing.T) { + svc, _, _ := newTestService(t) + + rec, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", "999.0.0")) + if err != nil { + t.Fatalf("Install() unexpected error: %v", err) + } + if rec.Status != StatusActive { + t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive) + } +} + +func TestValidateInstallURL_AcceptsHTTPAndHTTPS(t *testing.T) { + for _, raw := range []string{ + "https://example.com/plugin.tar.gz", + "http://example.com/plugin.tar.gz", + } { + if err := validateInstallURL(raw); err != nil { + t.Fatalf("validateInstallURL(%q) unexpected error: %v", raw, err) + } + } +} + +func TestValidateInstallURL_RejectsNonHTTPScheme(t *testing.T) { + for _, raw := range []string{ + "file:///etc/passwd", + "gopher://example.com/plugin", + "ftp://example.com/plugin.tar.gz", + } { + if err := validateInstallURL(raw); err == nil { + t.Fatalf("validateInstallURL(%q) expected error, got nil", raw) + } + } +} + +func TestValidateInstallURL_RejectsEmptyHost(t *testing.T) { + if err := validateInstallURL("https:///plugin.tar.gz"); err == nil { + t.Fatal("validateInstallURL() expected error for empty host, got nil") + } +} + +func TestValidateInstallURL_RejectsMalformedURL(t *testing.T) { + if err := validateInstallURL("://not-a-url"); err == nil { + t.Fatal("validateInstallURL() expected error for malformed URL, got nil") + } +} + +func TestServiceInstallFromURL_RejectsNonHTTPSchemeBeforeAnyRequest(t *testing.T) { + svc, _, _ := newTestService(t) + + _, err := svc.InstallFromURL(context.Background(), "file:///etc/passwd") + if err == nil { + t.Fatal("InstallFromURL() expected error for file:// scheme, got nil") + } +} diff --git a/apps/backend/internal/plugins/service_sync.go b/apps/backend/internal/plugins/service_sync.go new file mode 100644 index 0000000000..72af28dc32 --- /dev/null +++ b/apps/backend/internal/plugins/service_sync.go @@ -0,0 +1,308 @@ +package plugins + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/store" +) + +// tarballSuffix identifies a dropped plugin package file directly under the +// plugins directory (see scanTarballs). +const tarballSuffix = ".tar.gz" + +// Sync scans the plugins directory for filesystem-level changes an operator +// made outside the install/enable/disable/uninstall API — directory +// sideloads and dropped tarballs — and reconciles the registry with what +// actually exists on disk, per docs/specs/plugins/spec.md ("Filesystem +// sideloading & sync"): +// +// 1. Dir sideloads: an extracted //manifest.yaml with no +// existing {id}.yml record is registered StatusDisabled (never +// auto-spawned — sideloads are unverified). +// 2. Dropped tarballs: any *.tar.gz file sitting directly in the plugins +// directory is run through the normal verified install pipeline +// (Service.Install); its file is deleted on success. +// 3. Missing installs: any registered record whose InstallPath no longer +// exists on disk is stopped (if running) and marked StatusError. +// +// Individual item failures never abort the rest of the scan; they are +// collected into SyncResult.Errors. Concurrent Sync calls are serialized by +// syncMu so they cannot race each other into a double install. +func (s *Service) Sync(ctx context.Context) (*SyncResult, error) { + return s.sync(ctx, true), nil +} + +// bootScan runs the conservative subset of Sync at startup: dir sideloads +// (registered disabled, so nothing new is ever auto-spawned) and +// missing-install detection. Dropped tarballs are intentionally left alone +// at boot — installing and spawning an unverified binary as a side effect +// of starting up is out of scope; an operator triggers that via Sync (the +// UI's Sync button or POST /api/plugins/sync). +func (s *Service) bootScan(ctx context.Context) *SyncResult { + return s.sync(ctx, false) +} + +// sync is Sync's and bootScan's shared implementation, parameterized on +// whether dropped tarballs are installed this run. +func (s *Service) sync(ctx context.Context, includeTarballs bool) *SyncResult { + s.syncMu.Lock() + defer s.syncMu.Unlock() + + result := &SyncResult{} + if s.pluginsDir == "" { + return result + } + + added, sideloadErrs := s.scanDirSideloads() + result.Added = added + result.Errors = append(result.Errors, sideloadErrs...) + + if includeTarballs { + installed, tarballErrs := s.scanTarballs(ctx) + result.Installed = installed + result.Errors = append(result.Errors, tarballErrs...) + } + + result.Missing = s.scanMissingInstalls() + + s.notifyDeliverer() + return result +} + +// scanDirSideloads finds every ///manifest.yaml +// tree with no existing {id}.yml record and registers it StatusDisabled. +// When more than one unregistered version dir exists for the same id, the +// lexically greatest version is chosen and the rest are reported as skipped +// via the returned errs. +func (s *Service) scanDirSideloads() (added []string, errs []SyncError) { + entries, err := os.ReadDir(s.pluginsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, []SyncError{{Path: s.pluginsDir, Reason: err.Error()}} + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + id := entry.Name() + if _, ok := s.registry.Get(id); ok { + continue // already registered, not a sideload candidate + } + + version, skipped, err := s.chooseSideloadVersion(id) + if err != nil { + errs = append(errs, SyncError{Path: filepath.Join(s.pluginsDir, id), Reason: err.Error()}) + continue + } + if version == "" { + continue // no manifest.yaml found under any version dir + } + for _, skip := range skipped { + errs = append(errs, SyncError{ + Path: filepath.Join(s.pluginsDir, id, skip), + Reason: fmt.Sprintf("skipped: version %q already selected for unregistered plugin %q", version, id), + }) + } + + if err := s.registerSideload(id, version); err != nil { + errs = append(errs, SyncError{Path: filepath.Join(s.pluginsDir, id, version), Reason: err.Error()}) + continue + } + added = append(added, id) + } + return added, errs +} + +// chooseSideloadVersion lists id's version directories under pluginsDir and +// returns the semver-greatest one (manifest.CompareVersions) containing a +// manifest.yaml, plus every other candidate (to be reported as skipped by +// the caller). +func (s *Service) chooseSideloadVersion(id string) (version string, skipped []string, err error) { + versionEntries, err := os.ReadDir(filepath.Join(s.pluginsDir, id)) + if err != nil { + if os.IsNotExist(err) { + return "", nil, nil + } + return "", nil, err + } + + var candidates []string + for _, v := range versionEntries { + if !v.IsDir() { + continue + } + manifestPath := filepath.Join(s.pluginsDir, id, v.Name(), manifestFileName) + if _, statErr := os.Stat(manifestPath); statErr != nil { + continue + } + candidates = append(candidates, v.Name()) + } + if len(candidates) == 0 { + return "", nil, nil + } + sort.Slice(candidates, func(i, j int) bool { + return manifest.CompareVersions(candidates[i], candidates[j]) < 0 + }) + chosen := candidates[len(candidates)-1] + return chosen, candidates[:len(candidates)-1], nil +} + +// manifestFileName mirrors pkgtar's own constant (kept local — pkgtar does +// not export it, and duplicating a single string literal here avoids +// exporting it just for this one read-only check). +const manifestFileName = "manifest.yaml" + +// registerSideload parses+validates ///manifest.yaml, +// requires it to be runtime-managed with a matching id, and persists a +// fresh StatusDisabled record for it. +func (s *Service) registerSideload(id, version string) error { + versionDir := filepath.Join(s.pluginsDir, id, version) + data, err := os.ReadFile(filepath.Join(versionDir, manifestFileName)) + if err != nil { + return fmt.Errorf("read %s: %w", manifestFileName, err) + } + m, err := manifest.Parse(data) + if err != nil { + return fmt.Errorf("parse %s: %w", manifestFileName, err) + } + if err := m.Validate(); err != nil { + return fmt.Errorf("invalid %s: %w", manifestFileName, err) + } + if !m.IsManaged() { + return fmt.Errorf("%s is not runtime-managed (runtime.type must be \"binary\")", manifestFileName) + } + if m.ID != id { + return fmt.Errorf("manifest id %q does not match directory id %q", m.ID, id) + } + + rec := &store.Record{ + Manifest: *m, + Status: StatusDisabled, + InstallPath: versionDir, + Signed: false, + InstalledAt: time.Now().UTC(), + } + if err := s.store.Save(rec); err != nil { + return fmt.Errorf("persist sideloaded record: %w", err) + } + s.registry.Add(rec) + return nil +} + +// scanTarballs installs every *.tar.gz file sitting directly under +// pluginsDir via the normal verified install pipeline (Service.Install), +// deleting each on success. A package that fails pkgtar's verify/validate +// pipeline is left in place and reported via errs. +func (s *Service) scanTarballs(ctx context.Context) (installed []string, errs []SyncError) { + entries, err := os.ReadDir(s.pluginsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, []SyncError{{Path: s.pluginsDir, Reason: err.Error()}} + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), tarballSuffix) { + continue + } + path := filepath.Join(s.pluginsDir, entry.Name()) + id, err := s.installTarball(ctx, path) + if err != nil { + errs = append(errs, SyncError{Path: path, Reason: err.Error()}) + continue + } + installed = append(installed, id) + } + return installed, errs +} + +// installTarball opens path and installs it via Service.Install. A +// non-nil rec (even alongside a non-nil activation-only err — see Install's +// doc comment) means the package itself was valid and extracted, so the +// tarball is deleted; a nil rec means pkgtar rejected the package, so the +// file is left in place for the operator to fix. +func (s *Service) installTarball(ctx context.Context, path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("open dropped package: %w", err) + } + defer func() { _ = f.Close() }() + + rec, installErr := s.Install(ctx, f) + if rec == nil { + return "", installErr + } + if err := os.Remove(path); err != nil { + s.log.Warn("plugins: sync failed to remove installed tarball", + zap.String("path", path), zap.Error(err)) + } + return rec.ID, nil +} + +// scanMissingInstalls transitions every registered record whose +// InstallPath no longer exists on disk to StatusError (stopping its +// process first, if running), and returns their ids. +func (s *Service) scanMissingInstalls() []string { + var missing []string + for _, rec := range s.registry.List() { + if rec.InstallPath == "" { + continue + } + if _, err := os.Stat(rec.InstallPath); err == nil { + continue + } + if s.runtime != nil && s.runtime.Running(rec.ID) { + s.runtime.Stop(rec.ID) + } + s.markMissing(rec.ID) + missing = append(missing, rec.ID) + } + return missing +} + +// markMissing transitions id to StatusError via the normal FSM transition, +// falling back to a direct (transition-check-bypassing) status write if the +// transition is not a legal single-hop edge — e.g. the record is already +// StatusError, which SetStatus rejects as a same-status "transition". +func (s *Service) markMissing(id string) { + err := s.SetStatus(id, StatusError) + if err == nil { + s.notifyDeliverer() + return + } + var invalidErr *ErrInvalidTransition + if !errors.As(err, &invalidErr) { + s.log.Warn("plugins: sync failed to mark missing install as error", + zap.String("plugin_id", id), zap.Error(err)) + return + } + if err := s.setStatusUnchecked(id, StatusError); err != nil { + s.log.Warn("plugins: sync failed to force missing install to error", + zap.String("plugin_id", id), zap.Error(err)) + } +} + +// setStatusUnchecked directly writes status onto id's record in both the +// registry and the store, bypassing SetStatus's FSM transition check. Only +// used by markMissing's fallback path above. +func (s *Service) setStatusUnchecked(id string, status Status) error { + updated, ok := s.registry.SetStatus(id, status) + if !ok { + return store.ErrNotFound + } + return s.store.Save(updated) +} diff --git a/apps/backend/internal/plugins/service_sync_test.go b/apps/backend/internal/plugins/service_sync_test.go new file mode 100644 index 0000000000..b83acda53c --- /dev/null +++ b/apps/backend/internal/plugins/service_sync_test.go @@ -0,0 +1,319 @@ +package plugins + +import ( + "context" + "os" + "path/filepath" + goruntime "runtime" + "testing" + "time" +) + +// writeManifestOnly writes just a manifest.yaml (a "dir sideload": a +// version directory dropped straight onto disk, with no {id}.yml record and +// none of the other package files pkgtar.Install would extract). +func writeManifestOnly(t *testing.T, pluginsDir, id, version string) string { + t.Helper() + versionDir := filepath.Join(pluginsDir, id, version) + if err := os.MkdirAll(versionDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + manifestYAML := "id: " + id + "\napi_version: 1\nversion: \"" + version + "\"\ndisplay_name: Sideloaded\n" + + "runtime:\n type: binary\n executables:\n " + goruntime.GOOS + "-" + goruntime.GOARCH + ": server/plugin\n" + if err := os.WriteFile(filepath.Join(versionDir, "manifest.yaml"), []byte(manifestYAML), 0o644); err != nil { + t.Fatalf("WriteFile manifest.yaml: %v", err) + } + return versionDir +} + +// dropTarball writes a valid plugin tar.gz package directly under +// pluginsDir (simulating an operator copying a package file onto disk +// instead of using the install UI/API). +func dropTarball(t *testing.T, pluginsDir, id, filename string) string { + t.Helper() + dest := filepath.Join(pluginsDir, filename) + data := testPackage(t, id, "1.0.0", false).Bytes() + if err := os.WriteFile(dest, data, 0o644); err != nil { + t.Fatalf("WriteFile %s: %v", filename, err) + } + return dest +} + +func TestServiceSync_RegistersDirSideloadAsDisabled(t *testing.T) { + svc, dir, _, rt := newTestServiceWithDir(t) + versionDir := writeManifestOnly(t, dir, "kandev-plugin-side", "1.0.0") + + result, err := svc.Sync(context.Background()) + if err != nil { + t.Fatalf("Sync() unexpected error: %v", err) + } + if len(result.Added) != 1 || result.Added[0] != "kandev-plugin-side" { + t.Fatalf("Sync().Added = %v, want [kandev-plugin-side]", result.Added) + } + + rec, err := svc.Get("kandev-plugin-side") + if err != nil { + t.Fatalf("Get(): %v", err) + } + if rec.Status != StatusDisabled { + t.Fatalf("sideloaded record Status = %q, want %q", rec.Status, StatusDisabled) + } + if rec.InstallPath != versionDir { + t.Fatalf("sideloaded record InstallPath = %q, want %q", rec.InstallPath, versionDir) + } + if rec.Signed { + t.Fatal("sideloaded record Signed = true, want false (unverified sideload)") + } + if rt.Running("kandev-plugin-side") { + t.Fatal("Sync() must never spawn a disabled sideload") + } +} + +func TestServiceSync_ManifestIDMismatchIsAnError(t *testing.T) { + svc, dir, _, _ := newTestServiceWithDir(t) + writeManifestOnly(t, dir, "wrong-id-inside", "1.0.0") + // Rename the directory so the on-disk id ("dir-id") does not match the + // manifest's own id ("wrong-id-inside"). + if err := os.Rename(filepath.Join(dir, "wrong-id-inside"), filepath.Join(dir, "dir-id")); err != nil { + t.Fatalf("os.Rename: %v", err) + } + + result, err := svc.Sync(context.Background()) + if err != nil { + t.Fatalf("Sync() unexpected error: %v", err) + } + if len(result.Added) != 0 { + t.Fatalf("Sync().Added = %v, want none (mismatched manifest id)", result.Added) + } + if len(result.Errors) != 1 { + t.Fatalf("Sync().Errors = %v, want exactly one entry", result.Errors) + } + if _, err := svc.Get("dir-id"); err == nil { + t.Fatal("Get(\"dir-id\") succeeded, want not found (mismatched manifest must not be registered)") + } +} + +func TestServiceSync_MultipleVersionDirsPicksGreatestAndSkipsOthers(t *testing.T) { + svc, dir, _, _ := newTestServiceWithDir(t) + writeManifestOnly(t, dir, "kandev-plugin-multi", "1.0.0") + writeManifestOnly(t, dir, "kandev-plugin-multi", "2.0.0") + + result, err := svc.Sync(context.Background()) + if err != nil { + t.Fatalf("Sync() unexpected error: %v", err) + } + if len(result.Added) != 1 || result.Added[0] != "kandev-plugin-multi" { + t.Fatalf("Sync().Added = %v, want [kandev-plugin-multi]", result.Added) + } + if len(result.Errors) != 1 { + t.Fatalf("Sync().Errors = %v, want exactly one skip entry for the 1.0.0 dir", result.Errors) + } + + rec, err := svc.Get("kandev-plugin-multi") + if err != nil { + t.Fatalf("Get(): %v", err) + } + if rec.Version != "2.0.0" { + t.Fatalf("registered Version = %q, want %q (semver-greatest)", rec.Version, "2.0.0") + } +} + +// TestServiceSync_MultipleVersionDirsPicksSemverGreatestNotLexical pins the +// fix for a plain lexical sort picking the wrong "latest" version: "9.0.0" +// sorts after "10.0.0" as a string, but 10.0.0 is the actually-newer +// semver. +func TestServiceSync_MultipleVersionDirsPicksSemverGreatestNotLexical(t *testing.T) { + svc, dir, _, _ := newTestServiceWithDir(t) + writeManifestOnly(t, dir, "kandev-plugin-semver", "9.0.0") + writeManifestOnly(t, dir, "kandev-plugin-semver", "10.0.0") + + result, err := svc.Sync(context.Background()) + if err != nil { + t.Fatalf("Sync() unexpected error: %v", err) + } + if len(result.Added) != 1 || result.Added[0] != "kandev-plugin-semver" { + t.Fatalf("Sync().Added = %v, want [kandev-plugin-semver]", result.Added) + } + + rec, err := svc.Get("kandev-plugin-semver") + if err != nil { + t.Fatalf("Get(): %v", err) + } + if rec.Version != "10.0.0" { + t.Fatalf("registered Version = %q, want %q (semver-greatest, not lexically greatest)", rec.Version, "10.0.0") + } +} + +func TestServiceSync_TarballInstallSucceedsAndDeletesFile(t *testing.T) { + svc, dir, _, rt := newTestServiceWithDir(t) + tarPath := dropTarball(t, dir, "kandev-plugin-drop", "kandev-plugin-drop-1.0.0.tar.gz") + + result, err := svc.Sync(context.Background()) + if err != nil { + t.Fatalf("Sync() unexpected error: %v", err) + } + if len(result.Installed) != 1 || result.Installed[0] != "kandev-plugin-drop" { + t.Fatalf("Sync().Installed = %v, want [kandev-plugin-drop]", result.Installed) + } + if !rt.Running("kandev-plugin-drop") { + t.Fatal("Sync() did not spawn the successfully installed tarball plugin") + } + rec, err := svc.Get("kandev-plugin-drop") + if err != nil { + t.Fatalf("Get(): %v", err) + } + if rec.Status != StatusActive { + t.Fatalf("installed-from-tarball record Status = %q, want %q", rec.Status, StatusActive) + } + if _, statErr := os.Stat(tarPath); !os.IsNotExist(statErr) { + t.Fatalf("dropped tarball still exists after successful install, stat err = %v", statErr) + } +} + +func TestServiceSync_TarballValidationFailureLeavesFileAndAddsError(t *testing.T) { + svc, dir, _, _ := newTestServiceWithDir(t) + junkPath := filepath.Join(dir, "junk.tar.gz") + if err := os.WriteFile(junkPath, []byte("not a real gzip archive"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + result, err := svc.Sync(context.Background()) + if err != nil { + t.Fatalf("Sync() unexpected error: %v", err) + } + if len(result.Installed) != 0 { + t.Fatalf("Sync().Installed = %v, want none", result.Installed) + } + if len(result.Errors) != 1 || result.Errors[0].Path != junkPath { + t.Fatalf("Sync().Errors = %v, want one entry for %q", result.Errors, junkPath) + } + if _, statErr := os.Stat(junkPath); statErr != nil { + t.Fatalf("corrupt tarball was removed, want it left in place: %v", statErr) + } +} + +func TestServiceSync_MissingInstallPathSetsErrorAndStopsRuntime(t *testing.T) { + svc, _, rt := newTestService(t) + rec := installTestPlugin(t, svc, "kandev-plugin-vanished") + if err := os.RemoveAll(rec.InstallPath); err != nil { + t.Fatalf("RemoveAll(InstallPath): %v", err) + } + + result, err := svc.Sync(context.Background()) + if err != nil { + t.Fatalf("Sync() unexpected error: %v", err) + } + if len(result.Missing) != 1 || result.Missing[0] != "kandev-plugin-vanished" { + t.Fatalf("Sync().Missing = %v, want [kandev-plugin-vanished]", result.Missing) + } + if !rt.stopped("kandev-plugin-vanished") { + t.Fatal("Sync() did not stop the runtime process for a plugin with a missing install path") + } + got, err := svc.Get("kandev-plugin-vanished") + if err != nil { + t.Fatalf("Get(): %v", err) + } + if got.Status != StatusError { + t.Fatalf("Status after missing-install detection = %q, want %q", got.Status, StatusError) + } +} + +// TestServiceSync_MissingInstallAlreadyErrorIsIdempotent proves the "direct +// status write if transition invalid" fallback: once a plugin is already +// StatusError, canTransition(error, error) is false (SetStatus rejects +// same-status "transitions"), so Sync must not itself fail or panic — +// running it again still reports the plugin as missing. +func TestServiceSync_MissingInstallAlreadyErrorIsIdempotent(t *testing.T) { + svc, _, _ := newTestService(t) + rec := installTestPlugin(t, svc, "kandev-plugin-vanished") + if err := os.RemoveAll(rec.InstallPath); err != nil { + t.Fatalf("RemoveAll(InstallPath): %v", err) + } + + if _, err := svc.Sync(context.Background()); err != nil { + t.Fatalf("first Sync() unexpected error: %v", err) + } + + result, err := svc.Sync(context.Background()) + if err != nil { + t.Fatalf("second Sync() unexpected error: %v", err) + } + if len(result.Missing) != 1 || result.Missing[0] != "kandev-plugin-vanished" { + t.Fatalf("second Sync().Missing = %v, want [kandev-plugin-vanished]", result.Missing) + } + got, err := svc.Get("kandev-plugin-vanished") + if err != nil { + t.Fatalf("Get(): %v", err) + } + if got.Status != StatusError { + t.Fatalf("Status after second Sync() = %q, want %q", got.Status, StatusError) + } +} + +// TestServiceSync_ConcurrentCallsAreSerialized proves Sync is guarded by a +// mutex: a second concurrent Sync call must not proceed until the first one +// has fully returned. Pauses the first call mid-Install (inside the fake +// runtime's Start), starts the competing call, proves it is blocked out, +// then releases the first call and confirms the second only completes +// afterward. +func TestServiceSync_ConcurrentCallsAreSerialized(t *testing.T) { + svc, dir, _, rt := newTestServiceWithDir(t) + dropTarball(t, dir, "kandev-plugin-first", "kandev-plugin-first-1.0.0.tar.gz") + + started, release := rt.blockNextStart() + + firstDone := make(chan *SyncResult, 1) + go func() { + result, err := svc.Sync(context.Background()) + if err != nil { + t.Errorf("first Sync() unexpected error: %v", err) + } + firstDone <- result + }() + + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("first Sync() never reached the blocked runtime Start call") + } + + secondDone := make(chan *SyncResult, 1) + go func() { + result, err := svc.Sync(context.Background()) + if err != nil { + t.Errorf("second Sync() unexpected error: %v", err) + } + secondDone <- result + }() + + // The second call must still be blocked on the sync mutex: give it a + // bounded window to (incorrectly) complete, and fail if it does. + select { + case <-secondDone: + t.Fatal("second Sync() completed while the first was still in flight — Sync is not mutex-guarded") + case <-time.After(200 * time.Millisecond): + } + + release() + + var firstResult, secondResult *SyncResult + select { + case firstResult = <-firstDone: + case <-time.After(5 * time.Second): + t.Fatal("first Sync() never returned after release") + } + select { + case secondResult = <-secondDone: + case <-time.After(5 * time.Second): + t.Fatal("second Sync() never returned after the first released the mutex") + } + + if len(firstResult.Installed) != 1 || firstResult.Installed[0] != "kandev-plugin-first" { + t.Fatalf("first Sync().Installed = %v, want [kandev-plugin-first]", firstResult.Installed) + } + // The tarball is already gone by the time the second call scans, so it + // must not be double-installed. + if len(secondResult.Installed) != 0 { + t.Fatalf("second Sync().Installed = %v, want none (tarball already consumed)", secondResult.Installed) + } +} diff --git a/apps/backend/internal/plugins/service_test.go b/apps/backend/internal/plugins/service_test.go new file mode 100644 index 0000000000..fd741ab461 --- /dev/null +++ b/apps/backend/internal/plugins/service_test.go @@ -0,0 +1,747 @@ +package plugins + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + goruntime "runtime" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest" + "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +func testLogger(t *testing.T) *logger.Logger { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) + if err != nil { + t.Fatalf("new logger: %v", err) + } + return log +} + +// fakeRuntime is a controllable PluginRuntime for tests: Start/Stop just +// flip an in-memory "running" flag (recording every call) instead of +// spawning a real subprocess, so Service's install/activate/status-machine +// logic can be tested without internal/plugins/runtime's real go-plugin +// machinery. +type fakeRuntime struct { + mu sync.Mutex + + running map[string]bool + startErr map[string]error + restartCounts map[string]int + + startCalls []string + stopCalls []string + lastStartCtx context.Context + + // blockStarted/blockProceed, when set via blockNextStart, make the very + // next Start call signal blockStarted and then wait on blockProceed + // before continuing — used by concurrency tests to pause a caller + // mid-Start and prove a competing caller is blocked out. + blockStarted chan struct{} + blockProceed chan struct{} +} + +func newFakeRuntime() *fakeRuntime { + return &fakeRuntime{ + running: map[string]bool{}, + startErr: map[string]error{}, + restartCounts: map[string]int{}, + } +} + +// blockNextStart arms a one-shot block on the next Start call: Start closes +// the returned started channel once it is entered, then waits until the +// returned release func is called before returning. +func (r *fakeRuntime) blockNextStart() (started <-chan struct{}, release func()) { + s := make(chan struct{}) + p := make(chan struct{}) + r.mu.Lock() + r.blockStarted = s + r.blockProceed = p + r.mu.Unlock() + return s, func() { close(p) } +} + +func (r *fakeRuntime) Start(ctx context.Context, rec *store.Record, hostFactory func(string) pluginsdk.Host) error { + r.mu.Lock() + r.startCalls = append(r.startCalls, rec.ID) + r.lastStartCtx = ctx + started, proceed := r.blockStarted, r.blockProceed + r.blockStarted, r.blockProceed = nil, nil + r.mu.Unlock() + + if started != nil { + close(started) + <-proceed + } + + r.mu.Lock() + defer r.mu.Unlock() + if err := r.startErr[rec.ID]; err != nil { + return err + } + _ = hostFactory(rec.ID) // exercise the factory, mirroring the real manager + r.running[rec.ID] = true + return nil +} + +func (r *fakeRuntime) Stop(id string) { + r.mu.Lock() + defer r.mu.Unlock() + r.stopCalls = append(r.stopCalls, id) + r.running[id] = false +} + +func (r *fakeRuntime) Get(id string) (*pluginsdk.RemotePlugin, bool) { + r.mu.Lock() + defer r.mu.Unlock() + if !r.running[id] { + return nil, false + } + return nil, true +} + +func (r *fakeRuntime) Ping(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + if !r.running[id] { + return fmt.Errorf("not running") + } + return nil +} + +func (r *fakeRuntime) Running(id string) bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.running[id] +} + +func (r *fakeRuntime) RestartCount(id string) int { + r.mu.Lock() + defer r.mu.Unlock() + return r.restartCounts[id] +} + +func (r *fakeRuntime) StopAll() { + r.mu.Lock() + defer r.mu.Unlock() + for id := range r.running { + r.running[id] = false + } +} + +func (r *fakeRuntime) setStartErr(id string, err error) { + r.mu.Lock() + defer r.mu.Unlock() + r.startErr[id] = err +} + +func (r *fakeRuntime) startCallCount(id string) int { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + for _, got := range r.startCalls { + if got == id { + n++ + } + } + return n +} + +func (r *fakeRuntime) startCtx() context.Context { + r.mu.Lock() + defer r.mu.Unlock() + return r.lastStartCtx +} + +func (r *fakeRuntime) stopped(id string) bool { + r.mu.Lock() + defer r.mu.Unlock() + for _, got := range r.stopCalls { + if got == id { + return true + } + } + return false +} + +// fakeSecretRevealer is a minimal in-memory SecretRevealer for tests. +type fakeSecretRevealer struct { + mu sync.Mutex + values map[string]string +} + +func newFakeSecretRevealer() *fakeSecretRevealer { + return &fakeSecretRevealer{values: map[string]string{}} +} + +func (v *fakeSecretRevealer) set(ref, value string) { + v.mu.Lock() + defer v.mu.Unlock() + v.values[ref] = value +} + +func (v *fakeSecretRevealer) Reveal(_ context.Context, ref string) (string, error) { + v.mu.Lock() + defer v.mu.Unlock() + value, ok := v.values[ref] + if !ok { + return "", store.ErrNotFound + } + return value, nil +} + +// 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. +func testPackage(t *testing.T, id, version string, withUIBundle bool) *bytes.Buffer { + t.Helper() + platformKey := goruntime.GOOS + "-" + goruntime.GOARCH + manifestYAML := fmt.Sprintf(` +id: %s +api_version: 1 +version: %s +display_name: Test Plugin +capabilities: + events: ["task.*"] + state: true + secrets: true +runtime: + type: binary + executables: + %s: server/plugin +`, id, version, platformKey) + if withUIBundle { + manifestYAML += "ui:\n bundle: \"/ui/bundle.js\"\n styles: [\"/ui/style.css\"]\n" + } + + var buf bytes.Buffer + files := map[string][]byte{ + "manifest.yaml": []byte(manifestYAML), + "server/plugin": []byte("#!/bin/sh\necho fake\n"), + } + if withUIBundle { + files["ui/bundle.js"] = []byte("export default {};") + files["ui/style.css"] = []byte("body{}") + } + if err := pkgtartest.WritePackage(&buf, files); err != nil { + t.Fatalf("WritePackage: %v", err) + } + return &buf +} + +// newTestService wires a Service against a real FSStore rooted at a temp +// plugins dir, a fresh Registry, and a fakeRuntime — mirroring what Provide +// does, minus the real runtime.Manager. +func newTestService(t *testing.T) (*Service, *store.FSStore, *fakeRuntime) { + t.Helper() + svc, _, fsStore, rt := newTestServiceWithDir(t) + return svc, fsStore, rt +} + +func newTestServiceWithDir(t *testing.T) (*Service, string, *store.FSStore, *fakeRuntime) { + t.Helper() + dir := t.TempDir() + fsStore := store.NewFSStore(dir) + reg := NewRegistry() + svc := NewService(fsStore, reg, nil, testLogger(t)) + svc.SetPluginsDir(dir) + rt := newFakeRuntime() + svc.SetRuntime(rt) + return svc, dir, fsStore, rt +} + +func installTestPlugin(t *testing.T, svc *Service, id string) *store.Record { + t.Helper() + rec, err := svc.Install(context.Background(), testPackage(t, id, "1.0.0", false)) + if err != nil { + t.Fatalf("Install(%q): %v", id, err) + } + return rec +} + +func TestServiceListReturnsInstalledPlugins(t *testing.T) { + svc, _, _ := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + installTestPlugin(t, svc, "kandev-plugin-jira") + + list := svc.List() + if len(list) != 2 { + t.Fatalf("List() len = %d, want 2", len(list)) + } +} + +func TestServiceGetMissingReturnsNotFound(t *testing.T) { + svc, _, _ := newTestService(t) + if _, err := svc.Get("missing"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("Get() error = %v, want store.ErrNotFound", err) + } +} + +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 { + t.Fatalf("UpdateConfig() unexpected error: %v", err) + } + + cfg, err := fsStore.GetConfig("kandev-plugin-slack") + if err != nil { + t.Fatalf("GetConfig() unexpected error: %v", err) + } + if cfg["default_channel"] != "#dev" { + t.Fatalf("GetConfig() default_channel = %v, want %q", cfg["default_channel"], "#dev") + } +} + +func TestServiceUpdateConfigMissingReturnsNotFound(t *testing.T) { + svc, _, _ := newTestService(t) + err := svc.UpdateConfig("missing", map[string]any{"a": "b"}) + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("UpdateConfig() error = %v, want store.ErrNotFound", err) + } +} + +func TestServiceUninstallStopsRuntimeRemovesPackageAndRecord(t *testing.T) { + svc, fsStore, rt := newTestService(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 { + t.Fatalf("Uninstall() unexpected error: %v", err) + } + + if !rt.stopped("kandev-plugin-slack") { + t.Fatal("Uninstall() did not stop the runtime process") + } + if _, err := svc.Get("kandev-plugin-slack"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("Get() after Uninstall() error = %v, want store.ErrNotFound", err) + } + if _, err := fsStore.Get("kandev-plugin-slack"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("store.Get() after Uninstall() error = %v, want store.ErrNotFound", err) + } + if _, err := os.Stat(installDir); !os.IsNotExist(err) { + t.Fatalf("expected the extracted package dir to be removed, stat err = %v", err) + } +} + +// TestServiceUninstallDeletesPluginState pins the fix for the plugin_state +// leak on Uninstall: removing a plugin previously deleted its file tree and +// registry record but never its plugin_state rows, so a reinstalled (or +// id-reused) plugin silently inherited stale state from a previous install. +func TestServiceUninstallDeletesPluginState(t *testing.T) { + svc, _, _ := newTestService(t) + svc.SetState(newTestStateStore(t)) + installTestPlugin(t, svc, "kandev-plugin-slack") + + ctx := context.Background() + if err := svc.StateStore().Set(ctx, "kandev-plugin-slack", "instance", "", "install_id", json.RawMessage(`"abc"`)); err != nil { + t.Fatalf("seed plugin_state: %v", err) + } + + if err := svc.Uninstall("kandev-plugin-slack"); err != nil { + t.Fatalf("Uninstall() unexpected error: %v", err) + } + + entries, err := svc.StateStore().List(ctx, "kandev-plugin-slack", "instance", "") + if err != nil { + t.Fatalf("List() after Uninstall(): %v", err) + } + if len(entries) != 0 { + t.Fatalf("plugin_state entries after Uninstall() = %d, want 0 (state should be deleted)", len(entries)) + } +} + +func TestServiceUninstallMissingReturnsNotFound(t *testing.T) { + svc, _, _ := newTestService(t) + if err := svc.Uninstall("missing"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("Uninstall() error = %v, want store.ErrNotFound", err) + } +} + +func TestServiceSetStatusInvalidTransitionRejected(t *testing.T) { + svc, _, _ := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") // already active + + // active -> registered is not a legal single-hop edge. + err := svc.SetStatus("kandev-plugin-slack", StatusRegistered) + var invalidErr *ErrInvalidTransition + if !errors.As(err, &invalidErr) { + t.Fatalf("SetStatus() error = %v, want *ErrInvalidTransition", err) + } +} + +func TestServiceSetStatusIntoUninstalledRejected(t *testing.T) { + svc, _, _ := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + + err := svc.SetStatus("kandev-plugin-slack", StatusUninstalled) + var invalidErr *ErrInvalidTransition + if !errors.As(err, &invalidErr) { + t.Fatalf("SetStatus() error = %v, want *ErrInvalidTransition (use Uninstall instead)", err) + } +} + +func TestServiceEnableIsIdempotentWhenAlreadyActive(t *testing.T) { + svc, _, rt := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + + if err := svc.Enable("kandev-plugin-slack"); err != nil { + t.Fatalf("Enable() on an already-active plugin: %v", err) + } + if got := rt.startCallCount("kandev-plugin-slack"); got != 1 { + t.Fatalf("runtime Start called %d times, want 1 (Enable on an active plugin must be a no-op)", got) + } +} + +func TestServiceDisableFromActiveStopsRuntime(t *testing.T) { + svc, _, rt := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + + if err := svc.Disable("kandev-plugin-slack"); err != nil { + t.Fatalf("Disable() unexpected error: %v", err) + } + + got, _ := svc.Get("kandev-plugin-slack") + if got.Status != StatusDisabled { + t.Fatalf("Get() Status = %q, want %q", got.Status, StatusDisabled) + } + if !rt.stopped("kandev-plugin-slack") { + t.Fatal("Disable() did not stop the runtime process") + } +} + +func TestServiceDisableIsIdempotentWhenAlreadyDisabled(t *testing.T) { + svc, _, _ := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + if err := svc.Disable("kandev-plugin-slack"); err != nil { + t.Fatalf("first Disable(): %v", err) + } + + if err := svc.Disable("kandev-plugin-slack"); err != nil { + t.Fatalf("second Disable() expected no error (idempotent), got %v", err) + } +} + +func TestServiceDisabledCanReEnableAndRespawns(t *testing.T) { + svc, _, rt := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + if err := svc.Disable("kandev-plugin-slack"); err != nil { + t.Fatalf("Disable(): %v", err) + } + + if err := svc.Enable("kandev-plugin-slack"); err != nil { + t.Fatalf("re-Enable() unexpected error: %v", err) + } + got, _ := svc.Get("kandev-plugin-slack") + if got.Status != StatusActive { + t.Fatalf("Get() Status = %q, want %q", got.Status, StatusActive) + } + if want := 2; rt.startCallCount("kandev-plugin-slack") != want { + t.Fatalf("runtime Start called %d times, want %d (install + re-enable)", rt.startCallCount("kandev-plugin-slack"), want) + } +} + +// TestServiceEnable_ConcurrentCallsForSameID_OnlyOneActivationNoError proves +// Enable is guarded by a per-plugin lock: two near-simultaneous Enable calls +// for the same disabled plugin id must not both pass the StatusActive +// idempotency check and race into activate. Without the lock, the second +// call's SetStatus(Active) races the first, and the loser observes +// *ErrInvalidTransition ("active -> active") even though the plugin is +// correctly active by the time both calls return. +func TestServiceEnable_ConcurrentCallsForSameID_OnlyOneActivationNoError(t *testing.T) { + svc, _, rt := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + if err := svc.Disable("kandev-plugin-slack"); err != nil { + t.Fatalf("Disable(): %v", err) + } + + started, release := rt.blockNextStart() + + firstErr := make(chan error, 1) + go func() { + firstErr <- svc.Enable("kandev-plugin-slack") + }() + + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("first Enable() never reached the blocked runtime Start call") + } + + secondErr := make(chan error, 1) + go func() { + secondErr <- svc.Enable("kandev-plugin-slack") + }() + + // The second Enable() must still be blocked behind the per-plugin lock: + // give it a bounded window to (incorrectly) complete, and fail if it does. + select { + case err := <-secondErr: + t.Fatalf("second Enable() completed (err=%v) while the first was still starting — Enable is not per-plugin lock-guarded", err) + case <-time.After(200 * time.Millisecond): + } + + release() + + select { + case err := <-firstErr: + if err != nil { + t.Fatalf("first Enable() unexpected error: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("first Enable() never returned after release") + } + select { + case err := <-secondErr: + if err != nil { + t.Fatalf("second Enable() unexpected error (must be a no-op observing the now-active status): %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("second Enable() never returned after the first released the lock") + } + + // Install's initial activate() already called Start once; this Enable + // (after the earlier Disable) must add exactly one more — never two — + // proving the second concurrent Enable() did not also spawn. + if got, want := rt.startCallCount("kandev-plugin-slack"), 2; got != want { + t.Fatalf("runtime Start called %d times for kandev-plugin-slack, want exactly %d (no double activation)", got, want) + } + rec, err := svc.Get("kandev-plugin-slack") + if err != nil { + t.Fatalf("Get() unexpected error: %v", err) + } + if rec.Status != StatusActive { + t.Fatalf("Status = %q, want %q", rec.Status, StatusActive) + } +} + +// TestServiceActivatePassesABoundedStartContext pins the fix for bounded +// startup: activate previously passed context.Background() straight through +// to runtime.Start, so a hung plugin binary could block Enable/Install +// indefinitely (up to go-plugin's own default). activate must instead pass a +// context with a deadline, so a caller can observe/rely on the bound even if +// a future runtime.Manager becomes context-aware for the handshake itself. +func TestServiceActivatePassesABoundedStartContext(t *testing.T) { + svc, _, rt := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + + ctx := rt.startCtx() + if ctx == nil { + t.Fatal("runtime.Start() was never called") + } + if _, ok := ctx.Deadline(); !ok { + t.Fatal("activate() passed a context with no deadline to runtime.Start(), want a bounded context.WithTimeout") + } +} + +func TestServiceHandleStatusChangeUnhealthyTransitionsToErrorAndRefreshesDeliverer(t *testing.T) { + svc, _, _ := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + deliverer := &fakeDeliverer{} + svc.SetDeliverer(deliverer) + + svc.handleStatusChange("kandev-plugin-slack", false) + + got, _ := svc.Get("kandev-plugin-slack") + if got.Status != StatusError { + t.Fatalf("Status after unhealthy transition = %q, want %q", got.Status, StatusError) + } + if deliverer.refreshCount != 1 { + t.Fatalf("Refresh() call count = %d, want 1", deliverer.refreshCount) + } + if len(deliverer.flushedIDs) != 0 { + t.Fatalf("Flush() should not be called on a degrade transition, got %v", deliverer.flushedIDs) + } +} + +func TestServiceHandleStatusChangeHealthyRecoversAndFlushesDeliverer(t *testing.T) { + svc, _, _ := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + svc.handleStatusChange("kandev-plugin-slack", false) // degrade first + deliverer := &fakeDeliverer{} + svc.SetDeliverer(deliverer) + + svc.handleStatusChange("kandev-plugin-slack", true) + + got, _ := svc.Get("kandev-plugin-slack") + if got.Status != StatusActive { + t.Fatalf("Status after recovery = %q, want %q", got.Status, StatusActive) + } + if len(deliverer.flushedIDs) != 1 || deliverer.flushedIDs[0] != "kandev-plugin-slack" { + t.Fatalf("Flush() calls = %v, want [kandev-plugin-slack]", deliverer.flushedIDs) + } +} + +func TestServiceHandleStatusChangePersistsRestartCountBestEffort(t *testing.T) { + svc, fsStore, rt := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + rt.mu.Lock() + rt.restartCounts["kandev-plugin-slack"] = 3 + rt.mu.Unlock() + + svc.handleStatusChange("kandev-plugin-slack", false) + + got, _ := svc.Get("kandev-plugin-slack") + if got.RestartCount != 3 { + t.Fatalf("Get().RestartCount = %d, want 3", got.RestartCount) + } + onDisk, err := fsStore.Get("kandev-plugin-slack") + if err != nil { + t.Fatalf("store.Get(): %v", err) + } + if onDisk.RestartCount != 3 { + t.Fatalf("store.Get().RestartCount = %d, want 3", onDisk.RestartCount) + } +} + +// fakeDeliverer records Refresh/Flush calls so tests can assert the +// Service -> Deliverer attach-point contract without depending on the real +// delivery package. +type fakeDeliverer struct { + refreshCount int + flushedIDs []string +} + +func (f *fakeDeliverer) Refresh() { f.refreshCount++ } +func (f *fakeDeliverer) Flush(pluginID string) { f.flushedIDs = append(f.flushedIDs, pluginID) } + +func TestServiceInstallNotifiesDelivererRefresh(t *testing.T) { + svc, _, _ := newTestService(t) + deliverer := &fakeDeliverer{} + svc.SetDeliverer(deliverer) + + installTestPlugin(t, svc, "kandev-plugin-slack") + + if deliverer.refreshCount != 1 { + t.Fatalf("Refresh() call count = %d, want 1", deliverer.refreshCount) + } +} + +func TestServiceUninstallNotifiesDelivererRefresh(t *testing.T) { + svc, _, _ := newTestService(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + deliverer := &fakeDeliverer{} + svc.SetDeliverer(deliverer) + + if err := svc.Uninstall("kandev-plugin-slack"); err != nil { + t.Fatalf("Uninstall(): %v", err) + } + + if deliverer.refreshCount != 1 { + t.Fatalf("Refresh() call count after Uninstall() = %d, want 1", deliverer.refreshCount) + } +} + +func TestServiceWithoutDelivererDoesNotPanic(t *testing.T) { + svc, _, _ := newTestService(t) + // No SetDeliverer call — Install/SetStatus/Uninstall must tolerate a + // nil deliverer (delivery not wired yet, e.g. in unit tests or before + // backendapp attaches it). + installTestPlugin(t, svc, "kandev-plugin-slack") + if err := svc.Disable("kandev-plugin-slack"); err != nil { + t.Fatalf("Disable(): %v", err) + } + if err := svc.Uninstall("kandev-plugin-slack"); err != nil { + t.Fatalf("Uninstall(): %v", err) + } +} + +func TestServiceDelivererAccessorReturnsAttached(t *testing.T) { + svc, _, _ := newTestService(t) + if svc.Deliverer() != nil { + t.Fatalf("Deliverer() = %v, want nil before SetDeliverer", svc.Deliverer()) + } + deliverer := &fakeDeliverer{} + svc.SetDeliverer(deliverer) + if svc.Deliverer() != Deliverer(deliverer) { + t.Fatalf("Deliverer() did not return the attached deliverer") + } +} + +func TestServiceRegistryAccessorReturnsSameRegistry(t *testing.T) { + dir := t.TempDir() + fsStore := store.NewFSStore(dir) + reg := NewRegistry() + svc := NewService(fsStore, reg, nil, testLogger(t)) + + if svc.Registry() != reg { + t.Fatalf("Registry() did not return the injected registry instance") + } +} + +func TestServiceRevealSecretWithoutVaultReturnsError(t *testing.T) { + svc, _, _ := newTestService(t) + if _, err := svc.RevealSecret(context.Background(), "some-ref"); err == nil { + t.Fatal("RevealSecret() expected error when no vault configured, got nil") + } +} + +func TestServiceRevealSecretResolvesThroughVault(t *testing.T) { + svc, _, _ := newTestService(t) + vault := newFakeSecretRevealer() + vault.set("my-secret-ref", "s3cr3t") + svc.SetSecrets(vault) + + got, err := svc.RevealSecret(context.Background(), "my-secret-ref") + if err != nil { + t.Fatalf("RevealSecret() unexpected error: %v", err) + } + if got != "s3cr3t" { + t.Fatalf("RevealSecret() = %q, want %q", got, "s3cr3t") + } +} + +func TestServiceActiveUIPluginsFiltersByStatusAndBundle(t *testing.T) { + svc, _, _ := newTestService(t) + + if _, err := svc.Install(context.Background(), testPackage(t, "kandev-plugin-with-ui", "1.0.0", true)); err != nil { + t.Fatalf("Install(with bundle): %v", err) + } + + // Active but no bundle declared — must be excluded. + installTestPlugin(t, svc, "kandev-plugin-no-ui") + + active := svc.ActiveUIPlugins() + if len(active) != 1 { + t.Fatalf("ActiveUIPlugins() len = %d, want 1: %+v", len(active), active) + } + if active[0].ID != "kandev-plugin-with-ui" { + t.Fatalf("ActiveUIPlugins()[0].ID = %q, want %q", active[0].ID, "kandev-plugin-with-ui") + } +} + +func TestServiceStartActivePluginsSpawnsOnlyActiveManagedNotAlreadyRunning(t *testing.T) { + svc, dir, fsStore, _ := newTestServiceWithDir(t) + installTestPlugin(t, svc, "kandev-plugin-slack") + + // Simulate a fresh boot: the registry is reloaded from disk (status + // "active" persisted from the previous run), but the runtime manager + // has no live process yet. + reg2 := NewRegistry() + if err := reg2.Load(fsStore); err != nil { + t.Fatalf("Load(): %v", err) + } + svc2 := NewService(fsStore, reg2, nil, testLogger(t)) + svc2.SetPluginsDir(dir) + rt2 := newFakeRuntime() + svc2.SetRuntime(rt2) + + svc2.StartActivePlugins(context.Background()) + + if !rt2.Running("kandev-plugin-slack") { + t.Fatal("StartActivePlugins() did not spawn the active plugin") + } +} diff --git a/apps/backend/internal/plugins/state/store.go b/apps/backend/internal/plugins/state/store.go new file mode 100644 index 0000000000..ea93145291 --- /dev/null +++ b/apps/backend/internal/plugins/state/store.go @@ -0,0 +1,152 @@ +// Package state provides a plugin-scoped key/value store backed by SQLite. +// Plugins read and write arbitrary JSON values under a (scope, scope_id, key) +// tuple, always filtered by plugin_id so a plugin can never read or write +// another plugin's state (spec: docs/specs/plugins/spec.md, "plugin_state"). +package state + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +// StateEntry is a single row returned by List. +type StateEntry struct { + Key string `db:"state_key" json:"key"` + Value json.RawMessage `db:"value_json" json:"value"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +// Store persists plugin-scoped KV state in the plugin_state table. +type Store struct { + db *sqlx.DB + ro *sqlx.DB +} + +// NewStore creates a Store and initializes the plugin_state schema if needed. +func NewStore(pool *db.Pool) (*Store, error) { + s := &Store{db: pool.Writer(), ro: pool.Reader()} + if err := s.initSchema(); err != nil { + return nil, fmt.Errorf("plugin state schema init: %w", err) + } + return s, nil +} + +// initSchema creates the plugin_state table. scope_id is declared NOT NULL +// with an empty-string default (the instance-scope sentinel) rather than a +// nullable column as the spec's illustrative DDL shows. SQLite's UNIQUE +// index treats every NULL as distinct, so a nullable scope_id would let +// ON CONFLICT(plugin_id, scope, scope_id, state_key) silently miss conflicts +// between rows that are both "no scope" (e.g. two instance-scope Set calls +// for the same key), producing duplicate rows instead of an upsert. Empty +// string is unambiguous under ordinary equality/uniqueness and matches this +// codebase's existing convention for optional scope-like columns (see +// base_schema.go: executor_id, repository_id, declared TEXT DEFAULT empty). +func (s *Store) initSchema() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS plugin_state ( + id TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'instance', + scope_id TEXT NOT NULL DEFAULT '', + state_key TEXT NOT NULL, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (plugin_id, scope, scope_id, state_key) + ); + `) + return err +} + +// Get returns the value stored for the given plugin/scope/scopeID/key. +// scopeID is "" for instance-scoped state. found is false if no row exists. +func (s *Store) Get(ctx context.Context, pluginID, scope, scopeID, key string) (json.RawMessage, bool, error) { + var raw string + err := s.ro.QueryRowContext(ctx, s.ro.Rebind(` + SELECT value_json FROM plugin_state + WHERE plugin_id = ? AND scope = ? AND scope_id = ? AND state_key = ? + `), pluginID, scope, scopeID, key).Scan(&raw) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, false, nil + } + return nil, false, err + } + return json.RawMessage(raw), true, nil +} + +// Set upserts the value for the given plugin/scope/scopeID/key, setting +// updated_at to the current time in RFC3339 UTC. +func (s *Store) Set(ctx context.Context, pluginID, scope, scopeID, key string, value json.RawMessage) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + INSERT INTO plugin_state (id, plugin_id, scope, scope_id, state_key, value_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(plugin_id, scope, scope_id, state_key) + DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at + `), uuid.New().String(), pluginID, scope, scopeID, key, string(value), now) + return err +} + +// Delete removes the row for the given plugin/scope/scopeID/key. It is not +// an error if no matching row exists. +func (s *Store) Delete(ctx context.Context, pluginID, scope, scopeID, key string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + DELETE FROM plugin_state + WHERE plugin_id = ? AND scope = ? AND scope_id = ? AND state_key = ? + `), pluginID, scope, scopeID, key) + return err +} + +// DeleteAll removes every row for pluginID, across every scope and +// scope_id. Called by Service.Uninstall so a plugin's entire plugin_state +// footprint is removed alongside its extracted package and registry +// record — otherwise a reinstalled (or id-reused) plugin would silently +// inherit stale state from a previous install. Not an error if pluginID has +// no stored state. +func (s *Store) DeleteAll(ctx context.Context, pluginID string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + DELETE FROM plugin_state WHERE plugin_id = ? + `), pluginID) + return err +} + +// List returns all state entries for the given plugin/scope/scopeID. +func (s *Store) List(ctx context.Context, pluginID, scope, scopeID string) ([]StateEntry, error) { + rows, err := s.ro.QueryContext(ctx, s.ro.Rebind(` + SELECT state_key, value_json, updated_at FROM plugin_state + WHERE plugin_id = ? AND scope = ? AND scope_id = ? + ORDER BY state_key + `), pluginID, scope, scopeID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var entries []StateEntry + for rows.Next() { + var ( + key, raw, updatedAtStr string + ) + if err := rows.Scan(&key, &raw, &updatedAtStr); err != nil { + return nil, err + } + updatedAt, err := time.Parse(time.RFC3339, updatedAtStr) + if err != nil { + return nil, fmt.Errorf("parse updated_at for key %q: %w", key, err) + } + entries = append(entries, StateEntry{Key: key, Value: json.RawMessage(raw), UpdatedAt: updatedAt}) + } + if err := rows.Err(); err != nil { + return nil, err + } + return entries, nil +} diff --git a/apps/backend/internal/plugins/state/store_test.go b/apps/backend/internal/plugins/state/store_test.go new file mode 100644 index 0000000000..dc2e4498a2 --- /dev/null +++ b/apps/backend/internal/plugins/state/store_test.go @@ -0,0 +1,333 @@ +package state + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/db" +) + +func TestStoreSetThenGetReturnsStoredValue(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + value := json.RawMessage(`{"synced":true}`) + if err := store.Set(ctx, "kandev-plugin-jira", "task", "task_xyz", "sync_status", value); err != nil { + t.Fatalf("set: %v", err) + } + + got, found, err := store.Get(ctx, "kandev-plugin-jira", "task", "task_xyz", "sync_status") + if err != nil { + t.Fatalf("get: %v", err) + } + if !found { + t.Fatalf("expected found = true") + } + if string(got) != string(value) { + t.Fatalf("got %q, want %q", got, value) + } +} + +func TestStoreGetMissingReturnsNotFound(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + got, found, err := store.Get(ctx, "kandev-plugin-jira", "task", "task_xyz", "missing_key") + if err != nil { + t.Fatalf("get: %v", err) + } + if found || got != nil { + t.Fatalf("got (%q, %v), want (nil, false)", got, found) + } +} + +// TestStoreSetUpsertsOnRepeatedWrite pins the UNIQUE(plugin_id, scope, +// scope_id, state_key) upsert contract: a second Set for the same tuple must +// update the existing row in place, not create a duplicate. +func TestStoreSetUpsertsOnRepeatedWrite(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.Set(ctx, "kandev-plugin-jira", "task", "task_xyz", "sync_status", json.RawMessage(`"PROJ-1"`)); err != nil { + t.Fatalf("first set: %v", err) + } + if err := store.Set(ctx, "kandev-plugin-jira", "task", "task_xyz", "sync_status", json.RawMessage(`"PROJ-2"`)); err != nil { + t.Fatalf("second set: %v", err) + } + + got, found, err := store.Get(ctx, "kandev-plugin-jira", "task", "task_xyz", "sync_status") + if err != nil { + t.Fatalf("get: %v", err) + } + if !found { + t.Fatalf("expected found = true") + } + if string(got) != `"PROJ-2"` { + t.Fatalf("got %q, want %q (upsert should overwrite)", got, `"PROJ-2"`) + } + + entries, err := store.List(ctx, "kandev-plugin-jira", "task", "task_xyz") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected exactly 1 entry after repeated Set, got %d: %+v", len(entries), entries) + } +} + +// TestStoreInstanceScopeUpsertsWithEmptyScopeID pins scope_id NULL handling +// for instance-scoped state (scope_id == ""). SQLite's UNIQUE index treats +// each NULL as distinct, so a naive INSERT ... ON CONFLICT with a literal +// NULL scope_id would silently insert a duplicate row on the second Set +// instead of updating. The store must normalize "" consistently so repeated +// writes at instance scope still upsert. +func TestStoreInstanceScopeUpsertsWithEmptyScopeID(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.Set(ctx, "kandev-plugin-jira", "instance", "", "install_id", json.RawMessage(`"abc"`)); err != nil { + t.Fatalf("first set: %v", err) + } + if err := store.Set(ctx, "kandev-plugin-jira", "instance", "", "install_id", json.RawMessage(`"def"`)); err != nil { + t.Fatalf("second set: %v", err) + } + + got, found, err := store.Get(ctx, "kandev-plugin-jira", "instance", "", "install_id") + if err != nil { + t.Fatalf("get: %v", err) + } + if !found || string(got) != `"def"` { + t.Fatalf("got (%q, %v), want (\"def\", true)", got, found) + } + + entries, err := store.List(ctx, "kandev-plugin-jira", "instance", "") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected exactly 1 instance-scoped entry, got %d: %+v", len(entries), entries) + } +} + +func TestStoreDeleteRemovesEntry(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.Set(ctx, "kandev-plugin-jira", "task", "task_xyz", "sync_status", json.RawMessage(`"PROJ-1"`)); err != nil { + t.Fatalf("set: %v", err) + } + if err := store.Delete(ctx, "kandev-plugin-jira", "task", "task_xyz", "sync_status"); err != nil { + t.Fatalf("delete: %v", err) + } + + _, found, err := store.Get(ctx, "kandev-plugin-jira", "task", "task_xyz", "sync_status") + if err != nil { + t.Fatalf("get after delete: %v", err) + } + if found { + t.Fatalf("expected not found after delete") + } +} + +// TestStoreDeleteAllRemovesEveryRowForPlugin pins the Uninstall cleanup +// contract (docs/specs/plugins/spec.md "plugin_state"): a plugin's entire +// state footprint — across every scope and scope_id — must be removable in +// one call, so a reinstalled or id-reused plugin never inherits stale state. +func TestStoreDeleteAllRemovesEveryRowForPlugin(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.Set(ctx, "kandev-plugin-jira", "instance", "", "install_id", json.RawMessage(`"abc"`)); err != nil { + t.Fatalf("set instance-scoped: %v", err) + } + if err := store.Set(ctx, "kandev-plugin-jira", "task", "task_1", "sync_status", json.RawMessage(`"PROJ-1"`)); err != nil { + t.Fatalf("set task_1-scoped: %v", err) + } + if err := store.Set(ctx, "kandev-plugin-jira", "task", "task_2", "sync_status", json.RawMessage(`"PROJ-2"`)); err != nil { + t.Fatalf("set task_2-scoped: %v", err) + } + + if err := store.DeleteAll(ctx, "kandev-plugin-jira"); err != nil { + t.Fatalf("DeleteAll: %v", err) + } + + if entries, err := store.List(ctx, "kandev-plugin-jira", "instance", ""); err != nil || len(entries) != 0 { + t.Fatalf("instance-scoped entries after DeleteAll = %v (err=%v), want none", entries, err) + } + if entries, err := store.List(ctx, "kandev-plugin-jira", "task", "task_1"); err != nil || len(entries) != 0 { + t.Fatalf("task_1-scoped entries after DeleteAll = %v (err=%v), want none", entries, err) + } + if entries, err := store.List(ctx, "kandev-plugin-jira", "task", "task_2"); err != nil || len(entries) != 0 { + t.Fatalf("task_2-scoped entries after DeleteAll = %v (err=%v), want none", entries, err) + } +} + +// TestStoreDeleteAllDoesNotTouchOtherPlugins pins the plugin-isolation +// invariant: DeleteAll for one plugin must never remove another plugin's +// state. +func TestStoreDeleteAllDoesNotTouchOtherPlugins(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.Set(ctx, "kandev-plugin-jira", "task", "task_1", "sync_status", json.RawMessage(`"PROJ-1"`)); err != nil { + t.Fatalf("set jira: %v", err) + } + if err := store.Set(ctx, "kandev-plugin-slack", "task", "task_1", "channel", json.RawMessage(`"#dev"`)); err != nil { + t.Fatalf("set slack: %v", err) + } + + if err := store.DeleteAll(ctx, "kandev-plugin-jira"); err != nil { + t.Fatalf("DeleteAll: %v", err) + } + + entries, err := store.List(ctx, "kandev-plugin-slack", "task", "task_1") + if err != nil { + t.Fatalf("list slack: %v", err) + } + if len(entries) != 1 { + t.Fatalf("kandev-plugin-slack entries after deleting kandev-plugin-jira = %d, want 1 (untouched)", len(entries)) + } +} + +// TestStoreDeleteAllMissingPluginIsNotAnError pins that DeleteAll on a +// plugin with no stored state is a safe no-op. +func TestStoreDeleteAllMissingPluginIsNotAnError(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.DeleteAll(ctx, "kandev-plugin-never-had-state"); err != nil { + t.Fatalf("DeleteAll on a plugin with no state: %v", err) + } +} + +func TestStoreDeleteMissingIsNotAnError(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.Delete(ctx, "kandev-plugin-jira", "task", "task_xyz", "never_set"); err != nil { + t.Fatalf("delete missing: %v", err) + } +} + +func TestStoreListReturnsOnlyMatchingScope(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.Set(ctx, "kandev-plugin-jira", "task", "task_1", "a", json.RawMessage(`1`)); err != nil { + t.Fatalf("set task_1/a: %v", err) + } + if err := store.Set(ctx, "kandev-plugin-jira", "task", "task_1", "b", json.RawMessage(`2`)); err != nil { + t.Fatalf("set task_1/b: %v", err) + } + if err := store.Set(ctx, "kandev-plugin-jira", "task", "task_2", "a", json.RawMessage(`3`)); err != nil { + t.Fatalf("set task_2/a: %v", err) + } + + entries, err := store.List(ctx, "kandev-plugin-jira", "task", "task_1") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries for task_1, got %d: %+v", len(entries), entries) + } + keys := map[string]bool{} + for _, e := range entries { + keys[e.Key] = true + if e.UpdatedAt.IsZero() { + t.Errorf("entry %q has zero UpdatedAt", e.Key) + } + } + if !keys["a"] || !keys["b"] { + t.Fatalf("expected keys a and b, got %+v", entries) + } +} + +// TestStorePluginsCannotReadEachOthersState pins the spec invariant that +// plugin state is always filtered by plugin_id (docs/specs/plugins/spec.md +// "Plugins cannot read others' state"). +func TestStorePluginsCannotReadEachOthersState(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.Set(ctx, "kandev-plugin-jira", "task", "task_xyz", "sync_status", json.RawMessage(`"PROJ-1"`)); err != nil { + t.Fatalf("set: %v", err) + } + + _, found, err := store.Get(ctx, "kandev-plugin-slack", "task", "task_xyz", "sync_status") + if err != nil { + t.Fatalf("get from other plugin: %v", err) + } + if found { + t.Fatalf("expected another plugin's state to be invisible") + } + + entries, err := store.List(ctx, "kandev-plugin-slack", "task", "task_xyz") + if err != nil { + t.Fatalf("list from other plugin: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected no entries visible to a different plugin, got %+v", entries) + } +} + +func TestStoreSetStampsUpdatedAtAsRFC3339UTC(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + before := time.Now().UTC() + if err := store.Set(ctx, "kandev-plugin-jira", "task", "task_xyz", "sync_status", json.RawMessage(`"PROJ-1"`)); err != nil { + t.Fatalf("set: %v", err) + } + after := time.Now().UTC() + + entries, err := store.List(ctx, "kandev-plugin-jira", "task", "task_xyz") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + got := entries[0].UpdatedAt + if got.Location() != time.UTC { + t.Errorf("expected UpdatedAt location UTC, got %v", got.Location()) + } + if got.Before(before.Add(-time.Second)) || got.After(after.Add(time.Second)) { + t.Errorf("UpdatedAt %v not within expected window [%v, %v]", got, before, after) + } +} + +func TestStoreInitSchemaIsIdempotent(t *testing.T) { + conn := newSQLite(t) + if _, err := NewStore(db.NewPool(conn, conn)); err != nil { + t.Fatalf("first NewStore: %v", err) + } + if _, err := NewStore(db.NewPool(conn, conn)); err != nil { + t.Fatalf("second NewStore (re-init on existing schema): %v", err) + } +} + +func newTestStore(t *testing.T) *Store { + t.Helper() + conn := newSQLite(t) + store, err := NewStore(db.NewPool(conn, conn)) + if err != nil { + t.Fatalf("new store: %v", err) + } + return store +} + +func newSQLite(t *testing.T) *sqlx.DB { + t.Helper() + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + t.Cleanup(func() { _ = conn.Close() }) + return conn +} diff --git a/apps/backend/internal/plugins/store/fs_store.go b/apps/backend/internal/plugins/store/fs_store.go new file mode 100644 index 0000000000..6b059c244c --- /dev/null +++ b/apps/backend/internal/plugins/store/fs_store.go @@ -0,0 +1,294 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + + "go.uber.org/zap" + "gopkg.in/yaml.v3" + + "github.com/kandev/kandev/internal/common/logger" +) + +// idPattern mirrors internal/plugins/manifest's plugin id pattern: +// lowercase alphanumerics, dots, underscores, and hyphens, starting with a +// lowercase alphanumeric. manifest.Validate already enforces this shape +// upstream during install; this is the sink-level guard every FSStore +// method re-applies to id before building a filesystem path from it, so a +// malformed id (e.g. containing "..", "/", or "\") can never reach disk +// I/O regardless of how it got here. +var idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + +// dotConfigSuffix is the suffix a plugin id must never end with: id+".yml" +// would then collide with the ".config.yml" naming convention +// isRecordFile uses to distinguish a plugin's own record from another +// plugin's operator config, silently hiding the record from List() (see +// isRecordFile). +const dotConfigSuffix = ".config" + +// safePluginID rejects any id that is not a single, clean path segment +// matching idPattern, or that ends in ".config" (see dotConfigSuffix). It is +// the guard called by every FSStore method before recordPath/configPath +// build a path from id. +func safePluginID(id string) error { + if !idPattern.MatchString(id) { + return fmt.Errorf("plugins: invalid plugin id %q", id) + } + if strings.HasSuffix(id, dotConfigSuffix) { + return fmt.Errorf("plugins: invalid plugin id %q: must not end in %q", id, dotConfigSuffix) + } + return nil +} + +// FSStore persists plugin installation records under dir as "{id}.yml" (the +// Record) and "{id}.config.yml" (operator-editable config). FSStore +// implements Store. +type FSStore struct { + dir string + log *logger.Logger + + // mu guards every disk write (Save, Delete, SetConfig) and read + // (GetConfig) that touches a plugin's record/config files, so + // concurrent callers (e.g. two operator UpdateConfig PATCH requests for + // the same id) can never interleave writes to the same path. Writers + // take Lock; GetConfig takes RLock since it only reads. + mu sync.RWMutex +} + +// NewFSStore returns a store that persists records under dir. +func NewFSStore(dir string) *FSStore { + return &FSStore{dir: dir} +} + +// SetLogger wires a logger List uses to warn about a corrupt/unreadable +// record file it has to skip. Optional: a nil (default, unset) logger makes +// List silently skip instead of warning. +func (s *FSStore) SetLogger(log *logger.Logger) { + s.log = log +} + +// recordPath returns the on-disk path for a plugin's installation record. +// Every public method validates id with safePluginID before reaching here. +func (s *FSStore) recordPath(id string) string { + return filepath.Join(s.dir, id+".yml") +} + +// configPath returns the on-disk path for a plugin's operator config. +func (s *FSStore) configPath(id string) string { + return filepath.Join(s.dir, id+".config.yml") +} + +// writeRecord marshals record to YAML and writes it to disk, creating the +// store directory if needed. Used both to create a fresh record (Install) +// and to persist updates to an existing one (Save). The write is atomic +// (tmp file + rename within the same directory) so a crash mid-write can +// never leave a half-written ".yml" for List()/Get() to trip over. +func (s *FSStore) writeRecord(record *Record) error { + if err := safePluginID(record.ID); err != nil { + return err + } + if err := os.MkdirAll(s.dir, 0o755); err != nil { + return fmt.Errorf("create plugin store dir: %w", err) + } + data, err := yaml.Marshal(record) + if err != nil { + return fmt.Errorf("marshal plugin record: %w", err) + } + if err := writeFileAtomic(s.dir, s.recordPath(record.ID), data, 0o600); err != nil { + return fmt.Errorf("write plugin record: %w", err) + } + return nil +} + +// writeFileAtomic writes data to path via a temp file created in dir (the +// same filesystem/directory as path, so the final os.Rename is atomic) and +// an fsync-then-rename, so a reader (List/Get) never observes a +// partially-written file. The temp file is removed on any failure before +// the rename. +func writeFileAtomic(dir, path string, data []byte, mode os.FileMode) error { + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmp.Name() + succeeded := false + defer func() { + if !succeeded { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp file: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Chmod(tmpPath, mode); err != nil { + return fmt.Errorf("chmod temp file: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("rename temp file: %w", err) + } + succeeded = true + return nil +} + +// Get loads the record for id from disk. +func (s *FSStore) Get(id string) (*Record, error) { + if err := safePluginID(id); err != nil { + return nil, err + } + data, err := os.ReadFile(s.recordPath(id)) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("read plugin record: %w", err) + } + var record Record + if err := yaml.Unmarshal(data, &record); err != nil { + return nil, fmt.Errorf("unmarshal plugin record: %w", err) + } + return &record, nil +} + +// List returns every installed plugin's record. A record file that fails to +// read or parse (corrupt, stray, or written by a future incompatible +// version) is skipped and warned about rather than failing List +// wholesale — the whole plugin subsystem's boot path depends on List() +// succeeding (Registry.Load), so one bad file must not disable every other +// installed plugin. +func (s *FSStore) List() ([]*Record, error) { + entries, err := os.ReadDir(s.dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read plugin store dir: %w", err) + } + + var records []*Record + for _, entry := range entries { + if entry.IsDir() || !isRecordFile(entry.Name()) { + continue + } + id := strings.TrimSuffix(entry.Name(), ".yml") + record, err := s.Get(id) + if err != nil { + s.warnSkippedRecord(id, err) + continue + } + records = append(records, record) + } + return records, nil +} + +// warnSkippedRecord logs (if a logger is wired via SetLogger) that List +// skipped id's record file rather than failing outright. +func (s *FSStore) warnSkippedRecord(id string, err error) { + if s.log == nil { + return + } + s.log.Warn("plugins: skipping unreadable plugin record", + zap.String("plugin_id", id), zap.Error(err)) +} + +// isRecordFile reports whether name is a plugin record file ("{id}.yml"), +// excluding config files ("{id}.config.yml"). +func isRecordFile(name string) bool { + return strings.HasSuffix(name, ".yml") && !strings.HasSuffix(name, ".config.yml") +} + +// Save writes record to disk, creating it if it does not already exist or +// overwriting it (in full) if it does. Used both to persist a fresh install +// and to persist status/metadata updates to an existing record. +func (s *FSStore) Save(record *Record) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.writeRecord(record) +} + +// Delete removes the record and its operator config for id. +func (s *FSStore) Delete(id string) error { + if err := safePluginID(id); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + + if _, err := s.Get(id); err != nil { + return err + } + + if err := os.Remove(s.recordPath(id)); err != nil { + return fmt.Errorf("delete plugin record: %w", err) + } + if err := os.Remove(s.configPath(id)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete plugin config: %w", err) + } + return nil +} + +// GetConfig returns the operator-editable config for id, or an empty map if +// no config has been set yet. +func (s *FSStore) GetConfig(id string) (map[string]any, error) { + if err := safePluginID(id); err != nil { + return nil, err + } + s.mu.RLock() + defer s.mu.RUnlock() + + if _, err := s.Get(id); err != nil { + return nil, err + } + data, err := os.ReadFile(s.configPath(id)) + if err != nil { + if os.IsNotExist(err) { + return map[string]any{}, nil + } + return nil, fmt.Errorf("read plugin config: %w", err) + } + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("unmarshal plugin config: %w", err) + } + if cfg == nil { + cfg = map[string]any{} + } + return cfg, nil +} + +// SetConfig replaces the operator-editable config for id. The write is +// 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. +func (s *FSStore) SetConfig(id string, config map[string]any) error { + if err := safePluginID(id); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + + if _, err := s.Get(id); err != nil { + return err + } + data, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("marshal plugin config: %w", err) + } + if err := writeFileAtomic(s.dir, s.configPath(id), data, 0o644); 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 new file mode 100644 index 0000000000..9efe88517b --- /dev/null +++ b/apps/backend/internal/plugins/store/fs_store_test.go @@ -0,0 +1,487 @@ +package store + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/plugins/manifest" +) + +// testRecord returns a minimal valid installed record for tests. +func testRecord(id string) *Record { + return &Record{ + Manifest: manifest.Manifest{ + ID: id, + APIVersion: 1, + Version: "1.0.0", + DisplayName: "Test Plugin", + Runtime: manifest.Runtime{ + Type: "binary", + Executables: map[string]string{"linux-amd64": "server/plugin-linux-amd64"}, + }, + }, + Status: StatusRegistered, + InstallPath: "/home/user/.kandev/plugins/" + id + "/1.0.0", + Signed: false, + InstalledAt: time.Now().UTC(), + } +} + +func TestFSStore_Save_WritesRecordFile(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + rec := testRecord("kandev-plugin-slack") + if err := s.Save(rec); err != nil { + t.Fatalf("Save() unexpected error: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, "kandev-plugin-slack.yml")); err != nil { + t.Fatalf("expected record file to exist: %v", err) + } + + got, err := s.Get("kandev-plugin-slack") + if err != nil { + t.Fatalf("Get() unexpected error: %v", err) + } + if got.ID != rec.ID || got.Version != rec.Version || got.InstallPath != rec.InstallPath { + t.Fatalf("Get() = %+v, want id/version/install_path to round-trip from %+v", got, rec) + } + if got.Status != StatusRegistered { + t.Fatalf("Get().Status = %q, want %q", got.Status, StatusRegistered) + } +} + +func TestFSStore_Get_UnknownIDReturnsErrNotFound(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + _, err := s.Get("does-not-exist") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("Get() error = %v, want ErrNotFound", err) + } +} + +func TestFSStore_Delete_RemovesRecordAndConfig(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + rec := testRecord("kandev-plugin-slack") + if err := s.Save(rec); err != nil { + t.Fatalf("Save() unexpected error: %v", err) + } + if err := s.SetConfig("kandev-plugin-slack", map[string]any{"a": 1}); err != nil { + t.Fatalf("SetConfig() unexpected error: %v", err) + } + + if err := s.Delete("kandev-plugin-slack"); err != nil { + t.Fatalf("Delete() unexpected error: %v", err) + } + + if _, err := s.Get("kandev-plugin-slack"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get() after Delete() error = %v, want ErrNotFound", err) + } + if _, err := os.Stat(filepath.Join(dir, "kandev-plugin-slack.config.yml")); !os.IsNotExist(err) { + t.Fatalf("expected config file to be removed, stat err = %v", err) + } +} + +func TestFSStore_Delete_UnknownIDReturnsErrNotFound(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + if err := s.Delete("does-not-exist"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Delete() error = %v, want ErrNotFound", err) + } +} + +func TestFSStore_List_ReturnsAllInstalledPlugins(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + if err := s.Save(testRecord("kandev-plugin-slack")); err != nil { + t.Fatalf("Save(slack) unexpected error: %v", err) + } + if err := s.Save(testRecord("kandev-plugin-jira")); err != nil { + t.Fatalf("Save(jira) unexpected error: %v", err) + } + + records, err := s.List() + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + if len(records) != 2 { + t.Fatalf("List() returned %d records, want 2", len(records)) + } + + ids := map[string]bool{} + for _, r := range records { + ids[r.ID] = true + } + if !ids["kandev-plugin-slack"] || !ids["kandev-plugin-jira"] { + t.Fatalf("List() ids = %v, want both plugins present", ids) + } +} + +// TestFSStore_List_SkipsCorruptRecordAndReturnsRest pins the fix that one +// unparseable ".yml" (a stray/corrupt file, or one written by a future +// incompatible version) never aborts List() wholesale — the whole plugin +// subsystem depends on List() succeeding at boot (Registry.Load), so a +// single bad record must be skipped (and logged), not fatal. +func TestFSStore_List_SkipsCorruptRecordAndReturnsRest(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + if err := s.Save(testRecord("kandev-plugin-slack")); err != nil { + t.Fatalf("Save(slack) unexpected error: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "kandev-plugin-broken.yml"), []byte("not: [valid yaml"), 0o600); err != nil { + t.Fatalf("write corrupt record: %v", err) + } + + records, err := s.List() + if err != nil { + t.Fatalf("List() unexpected error (a corrupt record must be skipped, not fatal): %v", err) + } + if len(records) != 1 || records[0].ID != "kandev-plugin-slack" { + t.Fatalf("List() = %v, want only the valid kandev-plugin-slack record", records) + } +} + +func TestFSStore_List_EmptyDirReturnsNoRecords(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + records, err := s.List() + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + if len(records) != 0 { + t.Fatalf("List() returned %d records, want 0", len(records)) + } +} + +// TestFSStore_Save_LeavesNoTempFilesBehind pins the fix that writeRecord +// writes via a tmp-file + rename instead of a plain os.WriteFile, so a +// process crash mid-write can never leave a half-written ".yml" for +// List()/Get() to trip over. A successful Save must not leave any stray +// tmp artifact in the store directory. +func TestFSStore_Save_LeavesNoTempFilesBehind(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + if err := s.Save(testRecord("kandev-plugin-slack")); err != nil { + t.Fatalf("Save() unexpected error: %v", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir() unexpected error: %v", err) + } + if len(entries) != 1 || entries[0].Name() != "kandev-plugin-slack.yml" { + t.Fatalf("store dir entries = %v, want exactly [kandev-plugin-slack.yml] (no leaked tmp file)", entries) + } +} + +func TestFSStore_Save_PersistsUpdatedRecord(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + rec := testRecord("kandev-plugin-slack") + if err := s.Save(rec); err != nil { + t.Fatalf("Save() unexpected error: %v", err) + } + + rec.Status = StatusDisabled + rec.RestartCount = 2 + if err := s.Save(rec); err != nil { + t.Fatalf("Save() (update) unexpected error: %v", err) + } + + got, err := s.Get("kandev-plugin-slack") + if err != nil { + t.Fatalf("Get() unexpected error: %v", err) + } + if got.Status != StatusDisabled { + t.Fatalf("Get().Status = %q, want %q", got.Status, StatusDisabled) + } + if got.RestartCount != 2 { + t.Fatalf("Get().RestartCount = %d, want 2", got.RestartCount) + } +} + +func TestFSStore_SetConfigThenGetConfig_RoundTrips(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + if err := s.Save(testRecord("kandev-plugin-slack")); err != nil { + t.Fatalf("Save() unexpected error: %v", err) + } + + cfg := map[string]any{"default_channel": "#dev", "notify_on_task_created": true} + if err := s.SetConfig("kandev-plugin-slack", cfg); err != nil { + t.Fatalf("SetConfig() unexpected error: %v", err) + } + + got, err := s.GetConfig("kandev-plugin-slack") + if err != nil { + t.Fatalf("GetConfig() unexpected error: %v", err) + } + if got["default_channel"] != "#dev" { + t.Fatalf("GetConfig()[\"default_channel\"] = %v, want %q", got["default_channel"], "#dev") + } + if got["notify_on_task_created"] != true { + t.Fatalf("GetConfig()[\"notify_on_task_created\"] = %v, want true", got["notify_on_task_created"]) + } +} + +func TestFSStore_GetConfig_NoConfigFileReturnsEmptyMap(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + if err := s.Save(testRecord("kandev-plugin-slack")); err != nil { + t.Fatalf("Save() unexpected error: %v", err) + } + + got, err := s.GetConfig("kandev-plugin-slack") + if err != nil { + t.Fatalf("GetConfig() unexpected error: %v", err) + } + if len(got) != 0 { + t.Fatalf("GetConfig() = %v, want empty map when no config was ever set", got) + } +} + +// --- id validation: every FSStore method that builds a path from an id +// must reject a traversal/unsafe id before touching the filesystem. --- + +func TestFSStore_Get_RejectsTraversalID(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + if _, err := s.Get("../escape"); err == nil { + t.Fatal("Get() expected error for traversal id, got nil") + } +} + +func TestFSStore_Save_RejectsTraversalID(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + err := s.Save(testRecord("../escape")) + if err == nil { + t.Fatal("Save() expected error for traversal id, got nil") + } + if _, statErr := os.Stat(filepath.Join(filepath.Dir(dir), "escape.yml")); !os.IsNotExist(statErr) { + t.Fatalf("Save() wrote a record file outside the store dir: stat err = %v", statErr) + } +} + +func TestFSStore_Delete_RejectsTraversalID(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + if err := s.Delete("../escape"); err == nil { + t.Fatal("Delete() expected error for traversal id, got nil") + } +} + +func TestFSStore_GetConfig_RejectsTraversalID(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + if _, err := s.GetConfig("../escape"); err == nil { + t.Fatal("GetConfig() expected error for traversal id, got nil") + } +} + +func TestFSStore_SetConfig_RejectsTraversalID(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + err := s.SetConfig("../escape", map[string]any{"a": 1}) + if err == nil { + t.Fatal("SetConfig() expected error for traversal id, got nil") + } + if _, statErr := os.Stat(filepath.Join(filepath.Dir(dir), "escape.config.yml")); !os.IsNotExist(statErr) { + t.Fatalf("SetConfig() wrote a config file outside the store dir: stat err = %v", statErr) + } +} + +func TestFSStore_Get_RejectsIDWithPathSeparator(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + if _, err := s.Get("kandev/plugin"); err == nil { + t.Fatal("Get() expected error for id containing a path separator, got nil") + } +} + +func TestFSStore_Get_RejectsEmptyID(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + if _, err := s.Get(""); err == nil { + t.Fatal("Get() expected error for empty id, got nil") + } +} + +// TestFSStore_Save_RejectsIDEndingInDotConfig pins the fix for an id whose +// record filename (".yml") would alias another plugin's config +// filename convention: an id like "foo.config" writes "foo.config.yml", +// which isRecordFile classifies as plugin "foo"'s config file, not a +// record — so the "foo.config" record silently vanishes from List() and +// collides with "foo"'s config storage. safePluginID must reject this +// shape before any FSStore method reaches disk. +func TestFSStore_Save_RejectsIDEndingInDotConfig(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + err := s.Save(testRecord("foo.config")) + if err == nil { + t.Fatal("Save() expected error for an id ending in \".config\", got nil") + } + if !strings.Contains(err.Error(), "invalid plugin id") { + t.Fatalf("Save() error = %q, want it to come from the id guard (\"invalid plugin id\")", err.Error()) + } + if _, statErr := os.Stat(filepath.Join(dir, "foo.config.yml")); !os.IsNotExist(statErr) { + t.Fatalf("Save() wrote foo.config.yml despite rejecting the id: stat err = %v", statErr) + } +} + +func TestFSStore_Get_RejectsIDEndingInDotConfig(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + + if _, err := s.Get("foo.config"); err == nil { + t.Fatal("Get() expected error for an id ending in \".config\", got nil") + } +} + +// --- concurrency: SetConfig/GetConfig must hold s.mu, matching Save/Delete, +// so concurrent UpdateConfig PATCH requests cannot interleave writes to the +// same config path. --- + +// TestFSStore_SetConfig_SerializedByMu proves SetConfig takes s.mu (the same +// mutex Save/Delete already hold during disk I/O) rather than writing to +// disk unsynchronized. This test is in-package so it can grab s.mu directly +// to simulate a concurrent Save/Delete/SetConfig already in flight. +func TestFSStore_SetConfig_SerializedByMu(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + if err := s.Save(testRecord("kandev-plugin-slack")); err != nil { + t.Fatalf("Save() unexpected error: %v", err) + } + + s.mu.Lock() + + done := make(chan error, 1) + go func() { + done <- s.SetConfig("kandev-plugin-slack", map[string]any{"a": 1}) + }() + + select { + case err := <-done: + t.Fatalf("SetConfig() returned (err=%v) while s.mu was held externally — SetConfig is not guarded by s.mu", err) + case <-time.After(200 * time.Millisecond): + } + + s.mu.Unlock() + + select { + case err := <-done: + if err != nil { + t.Fatalf("SetConfig() unexpected error: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("SetConfig() never returned after s.mu was released") + } +} + +// TestFSStore_GetConfig_SerializedByMu proves GetConfig takes s.mu (RLock) +// before reading the config file, so a read can never observe a write that +// is only partially applied. +func TestFSStore_GetConfig_SerializedByMu(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + if err := s.Save(testRecord("kandev-plugin-slack")); err != nil { + t.Fatalf("Save() unexpected error: %v", err) + } + + s.mu.Lock() + + done := make(chan error, 1) + go func() { + _, err := s.GetConfig("kandev-plugin-slack") + done <- err + }() + + select { + case err := <-done: + t.Fatalf("GetConfig() returned (err=%v) while s.mu was held externally — GetConfig is not guarded by s.mu", err) + case <-time.After(200 * time.Millisecond): + } + + s.mu.Unlock() + + select { + case err := <-done: + if err != nil { + t.Fatalf("GetConfig() unexpected error: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("GetConfig() never returned after s.mu was released") + } +} + +// TestFSStore_SetConfig_ConcurrentCallsLeaveConsistentFile stress-tests many +// concurrent SetConfig calls for the same id: the config file must always +// end up fully valid (never truncated/interleaved content from two +// writers), and no tmp artifact from the atomic-write helper may leak into +// the store dir. Run with -race. +func TestFSStore_SetConfig_ConcurrentCallsLeaveConsistentFile(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + if err := s.Save(testRecord("kandev-plugin-slack")); err != nil { + t.Fatalf("Save() unexpected error: %v", err) + } + + const n = 20 + var wg sync.WaitGroup + wg.Add(n) + for i := 0; i < n; i++ { + i := i + go func() { + defer wg.Done() + cfg := map[string]any{"writer": i, "padding": strings.Repeat("x", 4096)} + if err := s.SetConfig("kandev-plugin-slack", cfg); err != nil { + t.Errorf("SetConfig(%d) unexpected error: %v", i, err) + } + }() + } + wg.Wait() + + got, err := s.GetConfig("kandev-plugin-slack") + if err != nil { + t.Fatalf("GetConfig() unexpected error after concurrent SetConfig calls (corrupt file): %v", err) + } + writer, ok := got["writer"].(int) + if !ok || writer < 0 || writer >= n { + t.Fatalf("GetConfig() = %v, want a single writer's complete config (not merged/corrupted)", got) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir() unexpected error: %v", err) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".tmp-") { + t.Fatalf("store dir leaked a temp file after concurrent SetConfig calls: %v", entries) + } + } +} diff --git a/apps/backend/internal/plugins/store/store.go b/apps/backend/internal/plugins/store/store.go new file mode 100644 index 0000000000..2abe920d79 --- /dev/null +++ b/apps/backend/internal/plugins/store/store.go @@ -0,0 +1,61 @@ +// Package store persists installed plugin records to the filesystem, as +// described in docs/plans/plugins/GRPC-CONTRACT.md §6 ("Package format"). +// +// Managed plugins (runtime.type: binary) are gRPC subprocesses spawned by +// kandev — there is no credential machinery here anymore (no api_key, no +// webhook_secret, no HMAC). Auth is the spawn relationship itself plus the +// go-plugin handshake and AutoMTLS. A Record instead tracks where the +// package was extracted to on disk (InstallPath), which version is +// installed, whether the package was signed, and best-effort restart +// bookkeeping for the runtime manager's supervision loop. +package store + +import ( + "errors" + "time" + + "github.com/kandev/kandev/internal/plugins/manifest" +) + +// Status values for a plugin registration, per the state machine in +// docs/specs/plugins/spec.md ("State machine"). +const ( + StatusRegistered = "registered" + StatusActive = "active" + StatusError = "error" + StatusDisabled = "disabled" + StatusUninstalled = "uninstalled" +) + +// ErrNotFound is returned by Get, Delete, GetConfig, and SetConfig when no +// record exists for the id. +var ErrNotFound = errors.New("plugin not found") + +// Record is a stored plugin installation: the manifest extracted from the +// package, plus the runtime fields kandev manages. Manifest.Version is the +// authoritative installed version (accessible as Record.Version via +// embedding); InstallPath is where pkgtar.Install extracted the package +// (destRoot//). +type Record struct { + manifest.Manifest `yaml:",inline"` + + Status string `yaml:"status" json:"status"` + InstallPath string `yaml:"install_path" json:"install_path"` + Signed bool `yaml:"signed" json:"signed"` + InstalledAt time.Time `yaml:"installed_at" json:"installed_at"` + RestartCount int `yaml:"restart_count" json:"restart_count"` +} + +// Store is the persistence interface for plugin installations and their +// operator-editable config. +type Store interface { + List() ([]*Record, error) + Get(id string) (*Record, error) + Save(record *Record) error + Delete(id string) error + GetConfig(id string) (map[string]any, error) + SetConfig(id string, config map[string]any) error +} + +// _ compile-time asserts that FSStore (fs_store.go) implements Store. +var _ Store = (*FSStore)(nil) diff --git a/apps/backend/internal/plugins/transport.go b/apps/backend/internal/plugins/transport.go new file mode 100644 index 0000000000..7cfcd2ca5d --- /dev/null +++ b/apps/backend/internal/plugins/transport.go @@ -0,0 +1,34 @@ +package plugins + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// RuntimeTransport adapts a PluginRuntime (runtime.Manager in production) +// to internal/plugins/delivery's Transport interface structurally — +// delivery does not import this package (see that package's doc comment +// for why), so this type's DeliverEvent method is all that's needed to +// satisfy it. +type RuntimeTransport struct { + runtime PluginRuntime +} + +// NewRuntimeTransport builds a delivery-facing transport over rt. +func NewRuntimeTransport(rt PluginRuntime) *RuntimeTransport { + return &RuntimeTransport{runtime: rt} +} + +// DeliverEvent calls the plugin's live subprocess DeliverEvent RPC. +// Timeout/retry policy is the caller's (internal/plugins/delivery's) +// responsibility — this is a thin, context-respecting proxy over +// PluginRuntime.Get + RemotePlugin.DeliverEvent. +func (t *RuntimeTransport) DeliverEvent(ctx context.Context, pluginID string, e *pluginsdk.Event) error { + remote, ok := t.runtime.Get(pluginID) + if !ok { + return fmt.Errorf("plugins: plugin %q is not running", pluginID) + } + return remote.DeliverEvent(ctx, e) +} diff --git a/apps/backend/internal/plugins/types.go b/apps/backend/internal/plugins/types.go new file mode 100644 index 0000000000..de44719678 --- /dev/null +++ b/apps/backend/internal/plugins/types.go @@ -0,0 +1,133 @@ +// Package plugins implements the core plugin Service: package install/ +// uninstall (internal/plugins/pkgtar), the in-memory Registry loaded from +// the filesystem store (internal/plugins/store), the lifecycle state +// machine, spawning/supervision via internal/plugins/runtime, the Host RPC +// implementation (host.go) plugins call back into, and event delivery +// wiring (internal/plugins/delivery). +package plugins + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// Status is a plugin's lifecycle state, per the state machine in +// docs/specs/plugins/spec.md ("State machine"): +// +// registered -> active -> disabled -> uninstalled +// | | +// +-> error -+ +// +// Status is a type alias (not a distinct type) for store's Record.Status +// field, and the constants below are direct aliases of the string values +// store already defines on Record — store.go is the single source of truth +// for the status vocabulary; this package never redefines it. +type Status = string + +// Status values, aliased from internal/plugins/store so callers can use +// either plugins.StatusActive or store.StatusActive interchangeably. +const ( + StatusRegistered = store.StatusRegistered + StatusActive = store.StatusActive + StatusError = store.StatusError + StatusDisabled = store.StatusDisabled + StatusUninstalled = store.StatusUninstalled +) + +// ErrInvalidTransition is returned by Service.SetStatus (and the Enable / +// Disable / SetStatus family) when the requested status change is not a +// legal single-hop edge in the state machine. +type ErrInvalidTransition struct { + ID string + From Status + To Status +} + +func (e *ErrInvalidTransition) Error() string { + return fmt.Sprintf("plugin %q: invalid status transition %s -> %s", e.ID, e.From, e.To) +} + +// allowedTransitions enumerates the legal single-hop edges of the state +// machine. Notably absent: any edge into StatusUninstalled. Uninstalling is +// not a status transition in this implementation — Service.Uninstall +// performs a hard delete (stop the runtime process, remove the extracted +// package, delete the record) instead of persisting status: +// "uninstalled". +var allowedTransitions = map[Status][]Status{ + StatusRegistered: {StatusActive, StatusError}, + StatusActive: {StatusDisabled, StatusError}, + StatusError: {StatusActive, StatusDisabled}, + StatusDisabled: {StatusActive}, +} + +// canTransition reports whether to is a legal single-hop transition from +// from. Same-status "transitions" are rejected here; Enable/Disable treat +// those as idempotent no-ops before ever calling SetStatus. +func canTransition(from, to Status) bool { + for _, allowed := range allowedTransitions[from] { + if allowed == to { + return true + } + } + return false +} + +// PluginRuntime is the subset of *runtime.Manager's surface Service +// depends on, defined here (consumer side) so Service stays testable +// against a fake without spawning real subprocesses. *runtime.Manager +// satisfies this structurally. +type PluginRuntime interface { + // Start spawns and begins supervising rec's process. hostFactory is + // called by the runtime manager (possibly more than once, across + // restarts) to build the Host implementation bound to a given plugin + // id at spawn time. + Start(ctx context.Context, rec *store.Record, hostFactory func(pluginID string) pluginsdk.Host) error + // Stop kills and stops supervising id's process. A no-op if not running. + Stop(id string) + // Get returns the live RemotePlugin for id, and whether one exists. + Get(id string) (*pluginsdk.RemotePlugin, bool) + // Ping issues an on-demand health check against id's current process. + Ping(id string) error + // Running reports whether id currently has a live process. + Running(id string) bool + // RestartCount returns how many times id's process has been + // automatically restarted since it was started. + RestartCount(id string) int + // StopAll stops every currently-running plugin. Used for graceful + // backend shutdown. + 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 { + Reveal(ctx context.Context, ref string) (string, error) +} + +// Deliverer is the minimal surface the event-delivery subsystem +// (internal/plugins/delivery) exposes back to Service. Defined here +// (consumer side) so internal/plugins/delivery does not need to import this +// package — its Deliverer type satisfies this interface structurally. +// +// Service.SetDeliverer wires an implementation in post-construction (see +// the "Extension points" doc comment on Service), mirroring the +// SetTaskDeleter / SetRepositoryLookup pattern used in internal/jira to +// avoid import cycles between sibling packages. +type Deliverer interface { + // Refresh re-subscribes to the event bus based on the current registry + // state. Service calls this after every mutation that can change which + // plugins/events should receive deliveries: Install, Uninstall, Enable, + // Disable, and any other successful SetStatus transition. + Refresh() + + // Flush delivers any events buffered for pluginID while it was in the + // error state, in order. Service calls this itself after an + // error -> active recovery transition driven by the runtime manager's + // OnStatusChange callback. + Flush(pluginID string) +} diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index 3317806052..250cf0fe0d 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -43,6 +43,13 @@ features: dev: "true" e2e: "true" + # Plugins (extensible plugin system). Off in prod until the feature + # is user-ready; on in dev/e2e so contributors and tests exercise it. + KANDEV_FEATURES_PLUGINS: + prod: "false" + dev: "true" + e2e: "true" + # ── Mock providers ──────────────────────────────────────────────── # Swap real third-party services for in-memory fakes. Production # leaves all of these unset (real APIs). Dev opts into the mock agent diff --git a/apps/backend/internal/profiles/profiles_test.go b/apps/backend/internal/profiles/profiles_test.go index 8033dd4d89..2aadc9f0d3 100644 --- a/apps/backend/internal/profiles/profiles_test.go +++ b/apps/backend/internal/profiles/profiles_test.go @@ -21,14 +21,18 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) { if env != EnvProd { t.Errorf("env = %q, want %q (no selector env vars set)", env, EnvProd) } - // prod values for the office flag are "false"; everything else in - // the prod column is "". Only office should have been written. - if count != 1 { - t.Errorf("ApplyProfile wrote %d vars in prod; want 1 (KANDEV_FEATURES_OFFICE=false)", count) + // prod values for the office and plugins flags are "false"; everything + // else in the prod column is "". Only those two should have been + // written. + if count != 2 { + t.Errorf("ApplyProfile wrote %d vars in prod; want 2 (KANDEV_FEATURES_OFFICE=false, KANDEV_FEATURES_PLUGINS=false)", count) } if v := os.Getenv("KANDEV_FEATURES_OFFICE"); v != "false" { t.Errorf("KANDEV_FEATURES_OFFICE = %q after prod ApplyProfile; want %q", v, "false") } + if v := os.Getenv("KANDEV_FEATURES_PLUGINS"); v != "false" { + t.Errorf("KANDEV_FEATURES_PLUGINS = %q after prod ApplyProfile; want %q", v, "false") + } } // TestApplyProfile_DevTurnsOnFeatures verifies that selecting the dev @@ -49,6 +53,9 @@ func TestApplyProfile_DevTurnsOnFeatures(t *testing.T) { if v := os.Getenv("KANDEV_FEATURES_OFFICE"); v != "true" { t.Errorf("KANDEV_FEATURES_OFFICE = %q in dev; want %q", v, "true") } + if v := os.Getenv("KANDEV_FEATURES_PLUGINS"); v != "true" { + t.Errorf("KANDEV_FEATURES_PLUGINS = %q in dev; want %q", v, "true") + } } // TestApplyProfile_E2EWinsOverDev checks the documented detection @@ -130,6 +137,9 @@ func TestFeatureFlagDefaults_LowercasesShortName(t *testing.T) { if _, ok := defaults["office"]; !ok { t.Errorf("FeatureFlagDefaults missing %q key; got %#v", "office", defaults) } + if _, ok := defaults["plugins"]; !ok { + t.Errorf("FeatureFlagDefaults missing %q key; got %#v", "plugins", defaults) + } } func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) { @@ -157,7 +167,7 @@ func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) { // hell out of whoever debugs it. func TestProfilesYAML_ContainsRequiredSections(t *testing.T) { yaml := string(ProfilesYAML()) - for _, section := range []string{"features:", "mocks:", "debug:", "KANDEV_FEATURES_OFFICE:"} { + for _, section := range []string{"features:", "mocks:", "debug:", "KANDEV_FEATURES_OFFICE:", "KANDEV_FEATURES_PLUGINS:"} { if !strings.Contains(yaml, section) { t.Errorf("embedded profiles.yaml missing section/key %q; embed broken or file truncated", section) } diff --git a/apps/backend/internal/runtimeflags/config.go b/apps/backend/internal/runtimeflags/config.go index 5e76025820..a92c22d036 100644 --- a/apps/backend/internal/runtimeflags/config.go +++ b/apps/backend/internal/runtimeflags/config.go @@ -14,6 +14,7 @@ func OptionsFromConfig(cfg *config.Config) Options { RuntimeValues: ValuesFromConfig(cfg), EnvValues: map[string]bool{ "KANDEV_FEATURES_OFFICE": isTruthy(os.Getenv("KANDEV_FEATURES_OFFICE")), + "KANDEV_FEATURES_PLUGINS": isTruthy(os.Getenv("KANDEV_FEATURES_PLUGINS")), "KANDEV_DEBUG_DEV_MODE": isTruthy(os.Getenv("KANDEV_DEBUG_DEV_MODE")), "KANDEV_DEBUG_PPROF_ENABLED": isTruthy(os.Getenv("KANDEV_DEBUG_PPROF_ENABLED")), "KANDEV_DEBUG_AGENT_MESSAGES": isTruthy(os.Getenv("KANDEV_DEBUG_AGENT_MESSAGES")), @@ -28,8 +29,9 @@ func OptionsFromConfig(cfg *config.Config) Options { func ValuesFromConfig(cfg *config.Config) map[string]bool { debugEnabled := cfg.Debug.DevMode || cfg.Debug.PprofEnabled return map[string]bool{ - "features.office": cfg.Features.Office, - "debug.devMode": debugEnabled, + "features.office": cfg.Features.Office, + "features.plugins": cfg.Features.Plugins, + "debug.devMode": debugEnabled, } } @@ -38,6 +40,8 @@ func ApplyStatesToConfig(cfg *config.Config, states []RuntimeFlagState) { switch state.Key { case "features.office": cfg.Features.Office = state.EffectiveValue + case "features.plugins": + cfg.Features.Plugins = state.EffectiveValue case "debug.devMode": cfg.Debug.DevMode = state.EffectiveValue cfg.Debug.PprofEnabled = state.EffectiveValue diff --git a/apps/backend/internal/runtimeflags/config_test.go b/apps/backend/internal/runtimeflags/config_test.go index de64d2ffa0..35790382ad 100644 --- a/apps/backend/internal/runtimeflags/config_test.go +++ b/apps/backend/internal/runtimeflags/config_test.go @@ -58,6 +58,40 @@ func TestOptionsFromConfigParsesUppercaseTruthyEnv(t *testing.T) { } } +func TestOptionsFromConfigParsesUppercaseTruthyEnvForPlugins(t *testing.T) { + preserveEnv(t, "KANDEV_FEATURES_PLUGINS") + t.Setenv("KANDEV_FEATURES_PLUGINS", "TRUE") + + opts := OptionsFromConfig(&config.Config{}) + + if !opts.EnvValues["KANDEV_FEATURES_PLUGINS"] { + t.Fatal("KANDEV_FEATURES_PLUGINS TRUE parsed false, want true") + } +} + +func TestValuesFromConfigIncludesPlugins(t *testing.T) { + cfg := &config.Config{} + cfg.Features.Plugins = true + + values := ValuesFromConfig(cfg) + + if !values["features.plugins"] { + t.Fatal("ValuesFromConfig did not surface features.plugins = true") + } +} + +func TestApplyStatesToConfigSetsPlugins(t *testing.T) { + cfg := &config.Config{} + ApplyStatesToConfig(cfg, []RuntimeFlagState{{ + Key: "features.plugins", + EffectiveValue: true, + }}) + + if !cfg.Features.Plugins { + t.Fatal("ApplyStatesToConfig did not set Features.Plugins = true") + } +} + func TestApplyStatesToConfigMarksImpliedDebugEnvAsApplied(t *testing.T) { for _, name := range []string{ "KANDEV_DEBUG_PPROF_ENABLED", diff --git a/apps/backend/internal/runtimeflags/registry.go b/apps/backend/internal/runtimeflags/registry.go index 116e597a1f..4c7bd1d146 100644 --- a/apps/backend/internal/runtimeflags/registry.go +++ b/apps/backend/internal/runtimeflags/registry.go @@ -14,6 +14,20 @@ var definitions = []RuntimeFlagDefinition{ RestartRequired: true, Mutable: true, }, + { + Key: "features.plugins", + EnvVar: "KANDEV_FEATURES_PLUGINS", + Kind: KindFeature, + Label: "Plugins", + Description: "Enables the extensible plugin system and related settings.", + Stability: StabilityExperimental, + RiskLevel: RiskMedium, + RiskDescription: "Plugins are still evolving. Loaded plugin code runs with backend " + + "privileges, and behavior may change between releases and should be reviewed " + + "before relying on it.", + RestartRequired: true, + Mutable: true, + }, { Key: "debug.devMode", EnvVar: "KANDEV_DEBUG_DEV_MODE", diff --git a/apps/backend/internal/runtimeflags/registry_test.go b/apps/backend/internal/runtimeflags/registry_test.go index c2843d3f6c..45de926e79 100644 --- a/apps/backend/internal/runtimeflags/registry_test.go +++ b/apps/backend/internal/runtimeflags/registry_test.go @@ -21,6 +21,25 @@ func TestDefinitionsIncludeOfficeExperimentalMetadata(t *testing.T) { } } +func TestDefinitionsIncludePluginsExperimentalMetadata(t *testing.T) { + def, ok := DefinitionByKey("features.plugins") + if !ok { + t.Fatal("features.plugins definition missing") + } + if def.EnvVar != "KANDEV_FEATURES_PLUGINS" { + t.Fatalf("EnvVar = %q, want KANDEV_FEATURES_PLUGINS", def.EnvVar) + } + if def.Stability != StabilityExperimental { + t.Fatalf("Stability = %q, want %q", def.Stability, StabilityExperimental) + } + if def.RiskDescription == "" { + t.Fatal("RiskDescription empty") + } + if !def.RestartRequired { + t.Fatal("RestartRequired = false, want true") + } +} + func TestDefinitionsExposeSingleUserFacingDebugToggle(t *testing.T) { def, ok := DefinitionByKey("debug.devMode") if !ok { diff --git a/apps/backend/internal/webapp/payload.go b/apps/backend/internal/webapp/payload.go index 30989eb243..1c713a75e7 100644 --- a/apps/backend/internal/webapp/payload.go +++ b/apps/backend/internal/webapp/payload.go @@ -11,6 +11,22 @@ type BootPayload struct { InitialState map[string]any `json:"initialState"` RouteData map[string]any `json:"routeData,omitempty"` Errors []BootError `json:"errors,omitempty"` + // Plugins lists every active, UI-bundle-declaring plugin, per + // docs/plans/plugins/PLUGIN-API.md ("Loading model"). Populated + // only when the plugins feature flag is on; the flag itself is not + // carried here (see /api/v1/features) — this is purely active-bundle + // data the frontend still gates loading on via useFeature("plugins"). + Plugins []ActivePluginPayload `json:"plugins,omitempty"` +} + +// ActivePluginPayload is one entry of BootPayload.Plugins: the browser-facing +// shape the SPA's plugin host (apps/web/lib/plugins/host.ts) iterates to +// inject styles and dynamically import() each plugin's bundle. +type ActivePluginPayload struct { + ID string `json:"id"` + Name string `json:"name"` + BundleURL string `json:"bundleUrl"` + StyleURLs []string `json:"styleUrls,omitempty"` } // RuntimeConfig contains browser-facing runtime endpoints for the SPA. diff --git a/apps/backend/pkg/pluginsdk/host.go b/apps/backend/pkg/pluginsdk/host.go new file mode 100644 index 0000000000..26b223249b --- /dev/null +++ b/apps/backend/pkg/pluginsdk/host.go @@ -0,0 +1,202 @@ +// host.go implements the Host side of the kandev.plugin.v1.Host service +// (§3 of docs/plans/plugins/GRPC-CONTRACT.md) in both directions: +// +// - grpcHostClient: used inside the plugin subprocess. Wraps a +// pluginv1.HostClient dialed over the go-plugin broker (see serve.go) +// and satisfies the Go-native Host interface that Serve injects into +// the author's Plugin. +// - grpcHostServer: used inside kandev. Wraps kandev's own Go-native Host +// implementation (state store, secrets, event bus) and satisfies the +// generated pluginv1.HostServer interface so it can be registered on +// the broker-served grpc.Server that GRPCPlugin.GRPCClient spins up +// (see serve.go's "Host injection" section). +// +// Both directions share the same Go-native Host interface and the same +// proto conversion helpers in types.go, so kandev's runtime manager +// implements Host exactly once and gets both the client and server wiring +// for free via GRPCPlugin. +package pluginsdk + +import ( + "context" + + pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1" + "google.golang.org/grpc" +) + +// Host is the set of operations kandev exposes back to a running plugin, +// per §3's Host service. On the plugin side, Serve injects an +// SDK-provided implementation that proxies these calls to kandev over the +// go-plugin broker. On the kandev side, the runtime manager provides its +// own Go-native implementation of this same interface (backed by the real +// state store / secrets / event bus) and hands it to GRPCPlugin.Host; the +// SDK wraps it into the generated pluginv1.HostServer for registration. +type Host interface { + // GetState looks up a single state entry. found is false, err is nil + // when the key does not exist. + GetState(ctx context.Context, scope, scopeID, key string) (value map[string]any, found bool, err error) + + // SetState upserts a single state entry. + SetState(ctx context.Context, scope, scopeID, key string, value map[string]any) error + + // DeleteState removes a single state entry. Deleting a missing key is + // not an error. + DeleteState(ctx context.Context, scope, scopeID, key string) error + + // 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. + RevealSecret(ctx context.Context, ref string) (string, error) + + // EmitEvent publishes a plugin-originated event onto kandev's bus. + EmitEvent(ctx context.Context, name string, payload map[string]any) error +} + +// newHostClient wraps a *grpc.ClientConn (dialed over the go-plugin broker) +// as a Go-native Host implementation. +func newHostClient(conn *grpc.ClientConn) Host { + return &grpcHostClient{client: pluginv1.NewHostClient(conn)} +} + +type grpcHostClient struct { + client pluginv1.HostClient +} + +func (h *grpcHostClient) GetState(ctx context.Context, scope, scopeID, key string) (map[string]any, bool, error) { + resp, err := h.client.GetState(ctx, &pluginv1.GetStateRequest{Scope: scope, ScopeId: scopeID, Key: key}) + if err != nil { + return nil, false, err + } + if !resp.GetFound() { + return nil, false, nil + } + value, err := structToMap(resp.GetValue()) + if err != nil { + return nil, false, err + } + return value, true, nil +} + +func (h *grpcHostClient) SetState(ctx context.Context, scope, scopeID, key string, value map[string]any) error { + protoValue, err := mapToStruct(value) + if err != nil { + return err + } + _, err = h.client.SetState(ctx, &pluginv1.SetStateRequest{Scope: scope, ScopeId: scopeID, Key: key, Value: protoValue}) + return err +} + +func (h *grpcHostClient) DeleteState(ctx context.Context, scope, scopeID, key string) error { + _, err := h.client.DeleteState(ctx, &pluginv1.DeleteStateRequest{Scope: scope, ScopeId: scopeID, Key: key}) + return err +} + +func (h *grpcHostClient) ListState(ctx context.Context, scope, scopeID string) ([]StateEntry, error) { + resp, err := h.client.ListState(ctx, &pluginv1.ListStateRequest{Scope: scope, ScopeId: scopeID}) + if err != nil { + return nil, err + } + return stateEntriesFromProto(resp.GetEntries()) +} + +func (h *grpcHostClient) RevealSecret(ctx context.Context, ref string) (string, error) { + resp, err := h.client.RevealSecret(ctx, &pluginv1.RevealSecretRequest{Ref: ref}) + if err != nil { + return "", err + } + return resp.GetValue(), nil +} + +func (h *grpcHostClient) EmitEvent(ctx context.Context, name string, payload map[string]any) error { + protoPayload, err := mapToStruct(payload) + if err != nil { + return err + } + _, err = h.client.EmitEvent(ctx, &pluginv1.EmitEventRequest{EventName: name, Payload: protoPayload}) + return err +} + +var _ Host = (*grpcHostClient)(nil) + +// registerHostServer registers a grpc server that dispatches +// kandev.plugin.v1.Host RPCs to impl (kandev's Go-native Host +// implementation), converting proto<->Go-native types at the boundary. +func registerHostServer(s grpc.ServiceRegistrar, impl Host) { + pluginv1.RegisterHostServer(s, &grpcHostServer{impl: impl}) +} + +type grpcHostServer struct { + pluginv1.UnimplementedHostServer + impl Host +} + +func (s *grpcHostServer) GetState(ctx context.Context, req *pluginv1.GetStateRequest) (*pluginv1.GetStateResponse, error) { + value, found, err := s.impl.GetState(ctx, req.GetScope(), req.GetScopeId(), req.GetKey()) + if err != nil { + return nil, err + } + if !found { + return &pluginv1.GetStateResponse{Found: false}, nil + } + protoValue, err := mapToStruct(value) + if err != nil { + return nil, err + } + return &pluginv1.GetStateResponse{Found: true, Value: protoValue}, nil +} + +func (s *grpcHostServer) SetState(ctx context.Context, req *pluginv1.SetStateRequest) (*pluginv1.SetStateResponse, error) { + value, err := structToMap(req.GetValue()) + if err != nil { + return nil, err + } + if err := s.impl.SetState(ctx, req.GetScope(), req.GetScopeId(), req.GetKey(), value); err != nil { + return nil, err + } + return &pluginv1.SetStateResponse{}, nil +} + +func (s *grpcHostServer) DeleteState(ctx context.Context, req *pluginv1.DeleteStateRequest) (*pluginv1.DeleteStateResponse, error) { + if err := s.impl.DeleteState(ctx, req.GetScope(), req.GetScopeId(), req.GetKey()); err != nil { + return nil, err + } + return &pluginv1.DeleteStateResponse{}, nil +} + +func (s *grpcHostServer) ListState(ctx context.Context, req *pluginv1.ListStateRequest) (*pluginv1.ListStateResponse, error) { + entries, err := s.impl.ListState(ctx, req.GetScope(), req.GetScopeId()) + if err != nil { + return nil, err + } + protoEntries := make([]*pluginv1.StateEntry, len(entries)) + for i := range entries { + converted, err := entries[i].toProto() + if err != nil { + return nil, err + } + protoEntries[i] = converted + } + return &pluginv1.ListStateResponse{Entries: protoEntries}, 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 { + return nil, err + } + return &pluginv1.RevealSecretResponse{Value: value}, nil +} + +func (s *grpcHostServer) EmitEvent(ctx context.Context, req *pluginv1.EmitEventRequest) (*pluginv1.EmitEventResponse, error) { + payload, err := structToMap(req.GetPayload()) + if err != nil { + return nil, err + } + if err := s.impl.EmitEvent(ctx, req.GetEventName(), payload); err != nil { + return nil, err + } + return &pluginv1.EmitEventResponse{}, nil +} + +var _ pluginv1.HostServer = (*grpcHostServer)(nil) diff --git a/apps/backend/pkg/pluginsdk/host_test.go b/apps/backend/pkg/pluginsdk/host_test.go new file mode 100644 index 0000000000..0e16417090 --- /dev/null +++ b/apps/backend/pkg/pluginsdk/host_test.go @@ -0,0 +1,161 @@ +package pluginsdk + +import ( + "context" + "errors" + "net" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" +) + +// recordingHost is a fake Go-native Host implementation used to drive the +// grpcHostServer adapter without a real state store. +type recordingHost struct { + getStateFn func(ctx context.Context, scope, scopeID, key string) (map[string]any, bool, error) + setState struct { + scope, scopeID, key string + value map[string]any + } + listStateEntries []StateEntry + revealSecretFn func(ctx context.Context, ref string) (string, error) + emitEvent struct { + name string + payload map[string]any + } + deleteStateCalled bool +} + +func (h *recordingHost) GetState(ctx context.Context, scope, scopeID, key string) (map[string]any, bool, error) { + return h.getStateFn(ctx, scope, scopeID, key) +} + +func (h *recordingHost) SetState(_ context.Context, scope, scopeID, key string, value map[string]any) error { + h.setState.scope, h.setState.scopeID, h.setState.key, h.setState.value = scope, scopeID, key, value + return nil +} + +func (h *recordingHost) DeleteState(context.Context, string, string, string) error { + h.deleteStateCalled = true + return nil +} + +func (h *recordingHost) ListState(context.Context, string, string) ([]StateEntry, error) { + return h.listStateEntries, nil +} + +func (h *recordingHost) RevealSecret(ctx context.Context, ref string) (string, error) { + return h.revealSecretFn(ctx, ref) +} + +func (h *recordingHost) EmitEvent(_ context.Context, name string, payload map[string]any) error { + h.emitEvent.name, h.emitEvent.payload = name, payload + return nil +} + +// dialHostOverBufconn wires a grpcHostServer (wrapping impl) to a +// grpcHostClient over an in-memory bufconn listener, bypassing go-plugin's +// broker entirely. The broker handshake itself is covered by the real +// end-to-end test in serve_test.go. +func dialHostOverBufconn(t *testing.T, impl Host) Host { + t.Helper() + + lis := bufconn.Listen(1024 * 1024) + srv := grpc.NewServer() + registerHostServer(srv, impl) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return lis.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + return newHostClient(conn) +} + +func TestHost_GetState_FoundAndNotFound(t *testing.T) { + impl := &recordingHost{ + getStateFn: func(_ context.Context, scope, scopeID, key string) (map[string]any, bool, error) { + if scope == "task" && scopeID == "t1" && key == "k1" { + return map[string]any{"v": float64(1)}, true, nil + } + return nil, false, nil + }, + } + host := dialHostOverBufconn(t, impl) + + value, found, err := host.GetState(context.Background(), "task", "t1", "k1") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, map[string]any{"v": float64(1)}, value) + + _, found, err = host.GetState(context.Background(), "task", "t1", "missing") + require.NoError(t, err) + require.False(t, found) +} + +func TestHost_SetState(t *testing.T) { + impl := &recordingHost{} + host := dialHostOverBufconn(t, impl) + + err := host.SetState(context.Background(), "workspace", "ws1", "cfg", map[string]any{"enabled": true}) + require.NoError(t, err) + require.Equal(t, "workspace", impl.setState.scope) + require.Equal(t, "ws1", impl.setState.scopeID) + require.Equal(t, "cfg", impl.setState.key) + require.Equal(t, map[string]any{"enabled": true}, impl.setState.value) +} + +func TestHost_DeleteState(t *testing.T) { + impl := &recordingHost{} + host := dialHostOverBufconn(t, impl) + + require.NoError(t, host.DeleteState(context.Background(), "task", "t1", "k1")) + require.True(t, impl.deleteStateCalled) +} + +func TestHost_ListState(t *testing.T) { + impl := &recordingHost{listStateEntries: []StateEntry{ + {Key: "a", Value: map[string]any{"x": float64(1)}, UpdatedAt: "2026-07-15T00:00:00Z"}, + {Key: "b", Value: nil, UpdatedAt: "2026-07-15T00:00:01Z"}, + }} + host := dialHostOverBufconn(t, impl) + + entries, err := host.ListState(context.Background(), "instance", "") + require.NoError(t, err) + require.Equal(t, impl.listStateEntries, entries) +} + +func TestHost_RevealSecret(t *testing.T) { + impl := &recordingHost{revealSecretFn: func(_ context.Context, ref string) (string, error) { + if ref == "known" { + return "sh-secret", nil + } + return "", errors.New("not found") + }} + host := dialHostOverBufconn(t, impl) + + value, err := host.RevealSecret(context.Background(), "known") + require.NoError(t, err) + require.Equal(t, "sh-secret", value) + + _, err = host.RevealSecret(context.Background(), "unknown") + require.Error(t, err) +} + +func TestHost_EmitEvent(t *testing.T) { + impl := &recordingHost{} + host := dialHostOverBufconn(t, impl) + + require.NoError(t, host.EmitEvent(context.Background(), "custom.thing", map[string]any{"a": float64(1)})) + require.Equal(t, "custom.thing", impl.emitEvent.name) + require.Equal(t, map[string]any{"a": float64(1)}, impl.emitEvent.payload) +} diff --git a/apps/backend/pkg/pluginsdk/plugin.go b/apps/backend/pkg/pluginsdk/plugin.go new file mode 100644 index 0000000000..11ee65b0ae --- /dev/null +++ b/apps/backend/pkg/pluginsdk/plugin.go @@ -0,0 +1,85 @@ +package pluginsdk + +import ( + "context" + "sync" +) + +// Plugin is the interface a plugin author implements. It is delivered RPCs +// from kandev over the go-plugin gRPC transport (§3 of +// docs/plans/plugins/GRPC-CONTRACT.md): DeliverEvent -> OnEvent, InvokeTool +// -> InvokeTool, HandleWebhook -> HandleWebhook. +type Plugin interface { + // OnEvent handles a single bus event delivery. A non-nil error causes + // kandev's delivery subsystem to retry per §5 (3 retries, 5s/15s/45s). + OnEvent(ctx context.Context, e *Event) error + + // InvokeTool handles a tool call routed to this plugin. + InvokeTool(ctx context.Context, req *ToolRequest) (*ToolResponse, error) + + // HandleWebhook handles an inbound webhook relayed by kandev's + // POST /api/plugins/{id}/webhooks/{key} endpoint. + HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error) +} + +// HostSetter is implemented by Plugin values that want Serve to inject the +// Host once the broker connection back to kandev is established (see the +// "Host injection" section of the serve.go file header). UnimplementedPlugin +// implements this, so embedding it is the easiest way to opt in. +type HostSetter interface { + SetHost(Host) +} + +// UnimplementedPlugin is an embeddable no-op base for Plugin. Authors that +// only care about one or two RPCs embed this and override the rest. It also +// implements HostSetter, storing the injected Host for retrieval via Host(). +// +// SetHost is called from a background goroutine (see the GRPCPlugin doc +// comment in serve.go for why), so access to the stored Host is +// mutex-guarded — Host() may be called concurrently with SetHost from +// another goroutine. +type UnimplementedPlugin struct { + mu sync.RWMutex + host Host +} + +var ( + _ Plugin = (*UnimplementedPlugin)(nil) + _ HostSetter = (*UnimplementedPlugin)(nil) +) + +// OnEvent is a no-op default: it accepts the event without doing anything. +// +// Pointer receiver (like every other UnimplementedPlugin method): the type +// embeds a sync.RWMutex, and a value receiver would trip go vet's copylocks +// check even though this method never touches it. +func (*UnimplementedPlugin) OnEvent(context.Context, *Event) error { + return nil +} + +// InvokeTool is a no-op default: it returns an empty successful response. +func (*UnimplementedPlugin) InvokeTool(context.Context, *ToolRequest) (*ToolResponse, error) { + return &ToolResponse{}, nil +} + +// HandleWebhook is a no-op default: it returns 404, since an unhandled +// webhook path has nothing sensible to serve. +func (*UnimplementedPlugin) HandleWebhook(context.Context, *WebhookRequest) (*WebhookResponse, error) { + return &WebhookResponse{Status: 404}, nil +} + +// SetHost stores the Host injected by Serve. Call Host() from your +// overridden methods to reach kandev. +func (p *UnimplementedPlugin) SetHost(h Host) { + p.mu.Lock() + defer p.mu.Unlock() + p.host = h +} + +// Host returns the Host injected by Serve, or nil if the broker connection +// hasn't completed yet (or Serve hasn't been called, e.g. in unit tests). +func (p *UnimplementedPlugin) Host() Host { + p.mu.RLock() + defer p.mu.RUnlock() + return p.host +} diff --git a/apps/backend/pkg/pluginsdk/plugin_test.go b/apps/backend/pkg/pluginsdk/plugin_test.go new file mode 100644 index 0000000000..d6e017b58c --- /dev/null +++ b/apps/backend/pkg/pluginsdk/plugin_test.go @@ -0,0 +1,52 @@ +package pluginsdk + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnimplementedPlugin_DefaultsAreNoOps(t *testing.T) { + var p UnimplementedPlugin + + require.NoError(t, p.OnEvent(context.Background(), &Event{EventID: "e1"})) + + resp, err := p.InvokeTool(context.Background(), &ToolRequest{ToolName: "noop"}) + require.NoError(t, err) + require.NotNil(t, resp) + + webhookResp, err := p.HandleWebhook(context.Background(), &WebhookRequest{Path: "/x"}) + require.NoError(t, err) + require.NotNil(t, webhookResp) + require.Equal(t, int32(404), webhookResp.Status) +} + +func TestUnimplementedPlugin_SetHostThenHost(t *testing.T) { + var p UnimplementedPlugin + require.Nil(t, p.Host()) + + h := &fakeHost{} + p.SetHost(h) + require.Equal(t, h, p.Host()) +} + +// fakeHost is a minimal Host implementation used only to prove SetHost/Host +// wiring; broker-backed Host behavior is covered by the integration test in +// serve_test.go. +type fakeHost struct{} + +func (f *fakeHost) GetState(context.Context, string, string, string) (map[string]any, bool, error) { + return nil, false, nil +} +func (f *fakeHost) SetState(context.Context, string, string, string, map[string]any) error { + return nil +} +func (f *fakeHost) DeleteState(context.Context, string, string, string) error { return nil } +func (f *fakeHost) ListState(context.Context, string, string) ([]StateEntry, error) { + return nil, 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 } + +var _ Host = (*fakeHost)(nil) diff --git a/apps/backend/pkg/pluginsdk/serve.go b/apps/backend/pkg/pluginsdk/serve.go new file mode 100644 index 0000000000..68403084a1 --- /dev/null +++ b/apps/backend/pkg/pluginsdk/serve.go @@ -0,0 +1,277 @@ +// serve.go wires plugin authors (via Serve) and kandev's runtime manager +// (via the exported GRPCPlugin type) onto hashicorp/go-plugin, per §2/§3 of +// docs/plans/plugins/GRPC-CONTRACT.md. +// +// # Host injection +// +// The frozen Plugin service (§3) has no RPC for negotiating a broker ID, so +// the two sides can't use go-plugin's usual "allocate an ID and pass it in +// the request" bidirectional pattern for the Host service. Instead they +// agree on a constant, hostBrokerID: +// +// - kandev (host side, GRPCPlugin.GRPCClient — called once when kandev +// dispenses the plugin) starts serving its own Host implementation on +// hostBrokerID via broker.AcceptAndServe. +// - The plugin subprocess (GRPCPlugin.GRPCServer — called once at +// subprocess startup, before the process has even accepted a +// connection from kandev) cannot dial hostBrokerID synchronously: the +// broker's control stream to kandev doesn't exist yet, so a blocking +// Dial here would deadlock startup. It instead spawns a background +// goroutine that retries broker.Dial(hostBrokerID) with backoff until +// it succeeds or HostDialTimeout elapses, then injects the resulting +// Host into Impl via the optional HostSetter interface — +// UnimplementedPlugin implements HostSetter, so embedding it is enough +// to opt in. A Plugin that doesn't implement HostSetter simply never +// receives a Host (fine for plugins that don't need one). +// +// GRPCPlugin is exported specifically so kandev's runtime manager can reuse +// it on the host side with plugin.NewClient — see the GRPCPlugin doc +// comment for the exact wiring. +package pluginsdk + +import ( + "context" + "fmt" + "time" + + hcplugin "github.com/hashicorp/go-plugin" + pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1" + "google.golang.org/grpc" +) + +// Handshake is the go-plugin handshake shared by kandev and every plugin +// backend (§2 of docs/plans/plugins/GRPC-CONTRACT.md). Both sides must use +// this exact value — Serve uses it automatically; kandev's runtime manager +// must set it on plugin.ClientConfig.HandshakeConfig. +var Handshake = hcplugin.HandshakeConfig{ + ProtocolVersion: 1, + MagicCookieKey: "KANDEV_PLUGIN", + MagicCookieValue: "kandev-plugin-v1", +} + +// PluginMapKey is the go-plugin plugin-map key both Serve (plugin side) and +// kandev's runtime manager (host side, plugin.ClientConfig.Plugins) use. +const PluginMapKey = "plugin" + +// hostBrokerID is the well-known go-plugin broker stream ID used for the +// Host service. See the "Host injection" section in the file header. +const hostBrokerID = 1 + +// defaultHostDialTimeout bounds how long the plugin subprocess retries +// dialing hostBrokerID before giving up and leaving Host unset. +const defaultHostDialTimeout = 30 * time.Second + +// GRPCPlugin adapts a Plugin (subprocess side) and/or a Host (kandev side) +// to hashicorp/go-plugin's plugin.GRPCPlugin interface. It is the shared +// type both sides use: +// +// - Plugin authors never construct it directly; Serve does so internally. +// +// - kandev's runtime manager constructs it directly: +// +// client := plugin.NewClient(&plugin.ClientConfig{ +// HandshakeConfig: pluginsdk.Handshake, +// Plugins: map[string]plugin.Plugin{pluginsdk.PluginMapKey: &pluginsdk.GRPCPlugin{Host: myHostImpl}}, +// AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC}, +// AutoMTLS: true, +// Cmd: exec.Command(pluginBinaryPath), +// }) +// rpcClient, _ := client.Client() +// raw, _ := rpcClient.Dispense(pluginsdk.PluginMapKey) +// remote := raw.(*pluginsdk.RemotePlugin) +// +// See the file header for how Host injection works across the two sides. +type GRPCPlugin struct { + hcplugin.NetRPCUnsupportedPlugin + + // Impl is the plugin author's implementation. Set by Serve on the + // plugin subprocess side. + Impl Plugin + + // Host is kandev's Go-native Host implementation. Set by kandev's + // runtime manager on the host side. + Host Host + + // HostDialTimeout overrides defaultHostDialTimeout. Zero means use the + // default; mainly useful in tests. + HostDialTimeout time.Duration +} + +var _ hcplugin.GRPCPlugin = (*GRPCPlugin)(nil) + +// GRPCServer is called once inside the plugin subprocess (by plugin.Serve, +// via Serve) to register the Plugin service and — if Impl implements +// HostSetter — kick off the background Host dial described in the file +// header. +func (p *GRPCPlugin) GRPCServer(broker *hcplugin.GRPCBroker, s *grpc.Server) error { + pluginv1.RegisterPluginServer(s, &grpcPluginServer{impl: p.Impl}) + + setter, ok := p.Impl.(HostSetter) + if !ok { + return nil + } + timeout := p.HostDialTimeout + if timeout <= 0 { + timeout = defaultHostDialTimeout + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + conn, err := dialBrokerWithRetry(ctx, broker, hostBrokerID) + if err != nil { + return + } + setter.SetHost(newHostClient(conn)) + }() + return nil +} + +// GRPCClient is called once inside kandev (by plugin.NewClient's Dispense) +// to build the RemotePlugin used to call the plugin, and — if Host is set — +// to start serving it on hostBrokerID so the plugin subprocess can call +// back. See the file header. +func (p *GRPCPlugin) GRPCClient(_ context.Context, broker *hcplugin.GRPCBroker, conn *grpc.ClientConn) (interface{}, error) { + if p.Host != nil { + go broker.AcceptAndServe(hostBrokerID, func(opts []grpc.ServerOption) *grpc.Server { + s := grpc.NewServer(opts...) + registerHostServer(s, p.Host) + return s + }) + } + return &RemotePlugin{client: pluginv1.NewPluginClient(conn)}, nil +} + +// dialBrokerWithRetry retries broker.Dial(id) with backoff until it +// succeeds or ctx is done. A single Dial attempt can legitimately fail +// while the peer hasn't called AcceptAndServe(id, ...) yet (see the file +// header); retrying absorbs that startup race. +func dialBrokerWithRetry(ctx context.Context, broker *hcplugin.GRPCBroker, id uint32) (*grpc.ClientConn, error) { + backoff := 100 * time.Millisecond + const maxBackoff = 2 * time.Second + for { + conn, err := broker.Dial(id) + if err == nil { + return conn, nil + } + select { + case <-ctx.Done(): + return nil, fmt.Errorf("pluginsdk: dial broker id %d: %w", id, ctx.Err()) + case <-time.After(backoff): + } + if backoff < maxBackoff { + backoff *= 2 + } + } +} + +// RemotePlugin is the Go-native client kandev's runtime manager uses to +// call a running plugin, returned by Dispense(pluginsdk.PluginMapKey). +type RemotePlugin struct { + client pluginv1.PluginClient +} + +// DeliverEvent calls the plugin's DeliverEvent RPC. Timeout/retry policy +// (§5: 10s timeout, 3 retries) is the caller's responsibility — this method +// is a thin, context-respecting proxy. +func (r *RemotePlugin) DeliverEvent(ctx context.Context, e *Event) error { + proto, err := e.toProto() + if err != nil { + return err + } + _, err = r.client.DeliverEvent(ctx, proto) + return err +} + +// InvokeTool calls the plugin's InvokeTool RPC. +func (r *RemotePlugin) InvokeTool(ctx context.Context, req *ToolRequest) (*ToolResponse, error) { + proto, err := req.toProto() + if err != nil { + return nil, err + } + resp, err := r.client.InvokeTool(ctx, proto) + if err != nil { + return nil, err + } + return toolResponseFromProto(resp) +} + +// HandleWebhook calls the plugin's HandleWebhook RPC. +func (r *RemotePlugin) HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error) { + resp, err := r.client.HandleWebhook(ctx, req.toProto()) + if err != nil { + return nil, err + } + return webhookResponseFromProto(resp), nil +} + +// grpcPluginServer adapts the author-facing Plugin interface to the +// generated pluginv1.PluginServer interface. Registered inside the plugin +// subprocess by GRPCPlugin.GRPCServer. +type grpcPluginServer struct { + pluginv1.UnimplementedPluginServer + impl Plugin +} + +func (s *grpcPluginServer) DeliverEvent(ctx context.Context, req *pluginv1.Event) (*pluginv1.EventAck, error) { + e, err := eventFromProto(req) + if err != nil { + return nil, err + } + if err := s.impl.OnEvent(ctx, e); err != nil { + return nil, err + } + return &pluginv1.EventAck{}, nil +} + +func (s *grpcPluginServer) InvokeTool(ctx context.Context, req *pluginv1.ToolRequest) (*pluginv1.ToolResponse, error) { + toolReq, err := toolRequestFromProto(req) + if err != nil { + return nil, err + } + resp, err := s.impl.InvokeTool(ctx, toolReq) + if err != nil { + return nil, err + } + return resp.toProto() +} + +func (s *grpcPluginServer) HandleWebhook(ctx context.Context, req *pluginv1.WebhookRequest) (*pluginv1.WebhookResponse, error) { + resp, err := s.impl.HandleWebhook(ctx, webhookRequestFromProto(req)) + if err != nil { + return nil, err + } + return resp.toProto(), nil +} + +var _ pluginv1.PluginServer = (*grpcPluginServer)(nil) + +// Option configures Serve. +type Option func(*serveConfig) + +type serveConfig struct { + hostDialTimeout time.Duration +} + +// WithHostDialTimeout overrides how long Serve retries dialing kandev's +// Host broker connection before giving up (default 30s). Mainly useful in +// tests that want a tighter bound than production. +func WithHostDialTimeout(d time.Duration) Option { + return func(c *serveConfig) { c.hostDialTimeout = d } +} + +// Serve wires p up as a kandev plugin backend and blocks until the process +// is terminated by kandev. It owns all go-plugin/grpc plumbing: the +// handshake (Handshake), the plugin map (PluginMapKey), and the Host broker +// dial + injection described in the file header. +func Serve(p Plugin, opts ...Option) { + cfg := &serveConfig{hostDialTimeout: defaultHostDialTimeout} + for _, opt := range opts { + opt(cfg) + } + gp := &GRPCPlugin{Impl: p, HostDialTimeout: cfg.hostDialTimeout} + hcplugin.Serve(&hcplugin.ServeConfig{ + HandshakeConfig: Handshake, + Plugins: map[string]hcplugin.Plugin{PluginMapKey: gp}, + GRPCServer: hcplugin.DefaultGRPCServer, + }) +} diff --git a/apps/backend/pkg/pluginsdk/serve_test.go b/apps/backend/pkg/pluginsdk/serve_test.go new file mode 100644 index 0000000000..b1c4525c6f --- /dev/null +++ b/apps/backend/pkg/pluginsdk/serve_test.go @@ -0,0 +1,107 @@ +package pluginsdk + +import ( + "context" + "testing" + "time" + + hcplugin "github.com/hashicorp/go-plugin" + "github.com/stretchr/testify/require" +) + +// fakeAuthorPlugin is a minimal author-facing Plugin used to exercise the +// real go-plugin gRPC + broker wiring end to end (no subprocess spawn — +// hcplugin.TestPluginGRPCConn wires a real unix-socket grpc.Server/Client +// pair with a real GRPCBroker, matching production transport behavior). +type fakeAuthorPlugin struct { + UnimplementedPlugin + events []*Event +} + +func (p *fakeAuthorPlugin) OnEvent(_ context.Context, e *Event) error { + p.events = append(p.events, e) + return nil +} + +func (p *fakeAuthorPlugin) InvokeTool(_ context.Context, req *ToolRequest) (*ToolResponse, error) { + return &ToolResponse{Output: map[string]any{"echo": req.ToolName}}, nil +} + +func (p *fakeAuthorPlugin) HandleWebhook(_ context.Context, req *WebhookRequest) (*WebhookResponse, error) { + return &WebhookResponse{Status: 200, Body: append([]byte("got:"), req.Body...)}, nil +} + +// TestServe_EndToEnd exercises the same GRPCPlugin wiring Serve() (plugin +// side) and kandev's runtime manager (host side, via plugin.NewClient) +// use, over a real go-plugin gRPC connection + broker +// (hcplugin.TestPluginGRPCConn). It covers all three Plugin RPCs plus the +// Host broker round trip (§3/§4 of docs/plans/plugins/GRPC-CONTRACT.md). +func TestServe_EndToEnd(t *testing.T) { + author := &fakeAuthorPlugin{} + hostImpl := &recordingHost{ + getStateFn: func(_ context.Context, scope, scopeID, key string) (map[string]any, bool, error) { + if scope == "task" && scopeID == "t1" && key == "k1" { + return map[string]any{"greeting": "hi"}, true, nil + } + return nil, false, nil + }, + } + // hcplugin.TestPluginGRPCConn dispenses/serves the SAME Plugin value on + // both ends of the harness (there's no real process boundary), so one + // GRPCPlugin with both Impl and Host set plays both roles: GRPCServer + // runs as the "plugin subprocess" would, GRPCClient runs as "kandev" + // would. In production these are two separate processes each setting + // only one of Impl/Host. + gp := &GRPCPlugin{Impl: author, Host: hostImpl, HostDialTimeout: 5 * time.Second} + + client, server := hcplugin.TestPluginGRPCConn(t, false, map[string]hcplugin.Plugin{ + PluginMapKey: gp, + }) + defer func() { _ = client.Close() }() + defer server.Stop() + + raw, err := client.Dispense(PluginMapKey) + require.NoError(t, err) + remote, ok := raw.(*RemotePlugin) + require.True(t, ok, "Dispense(%q) should return *RemotePlugin, got %T", PluginMapKey, raw) + + t.Run("DeliverEvent", func(t *testing.T) { + err := remote.DeliverEvent(context.Background(), &Event{ + EventID: "e1", + EventType: "task.created", + Payload: map[string]any{"a": float64(1)}, + }) + require.NoError(t, err) + require.Len(t, author.events, 1) + require.Equal(t, "e1", author.events[0].EventID) + require.Equal(t, map[string]any{"a": float64(1)}, author.events[0].Payload) + }) + + t.Run("InvokeTool", func(t *testing.T) { + resp, err := remote.InvokeTool(context.Background(), &ToolRequest{ToolName: "search"}) + require.NoError(t, err) + require.Equal(t, "search", resp.Output["echo"]) + }) + + t.Run("HandleWebhook", func(t *testing.T) { + resp, err := remote.HandleWebhook(context.Background(), &WebhookRequest{Body: []byte("hi")}) + require.NoError(t, err) + require.Equal(t, int32(200), resp.Status) + require.Equal(t, []byte("got:hi"), resp.Body) + }) + + t.Run("HostBrokerRoundTrip", func(t *testing.T) { + require.Eventually(t, func() bool { + return author.Host() != nil + }, 5*time.Second, 10*time.Millisecond, "Serve-equivalent GRPCServer should inject Host via the broker") + + value, found, err := author.Host().GetState(context.Background(), "task", "t1", "k1") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, map[string]any{"greeting": "hi"}, value) + + _, found, err = author.Host().GetState(context.Background(), "task", "t1", "missing") + require.NoError(t, err) + require.False(t, found) + }) +} diff --git a/apps/backend/pkg/pluginsdk/types.go b/apps/backend/pkg/pluginsdk/types.go new file mode 100644 index 0000000000..b5b87640fb --- /dev/null +++ b/apps/backend/pkg/pluginsdk/types.go @@ -0,0 +1,279 @@ +// Package pluginsdk is the author-facing SDK for kandev plugin backends, and +// the shared wiring kandev's own runtime manager imports to spawn and talk +// to those plugins. See docs/plans/plugins/GRPC-CONTRACT.md §4 for the +// frozen public surface this package implements. +// +// types.go defines the Go-native mirrors of the kandev.plugin.v1 proto +// messages (using map[string]any in place of google.protobuf.Struct) plus +// the proto<->Go conversion helpers used by every other file in this +// package. Authors and kandev's runtime manager only ever see these +// Go-native types; proto types from +// github.com/kandev/kandev/proto/kandev/plugin/v1 never leak past the +// package boundary. +package pluginsdk + +import ( + "fmt" + + pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1" + "google.golang.org/protobuf/types/known/structpb" +) + +// Event is the Go-native mirror of kandev.plugin.v1.Event, delivered to a +// Plugin's OnEvent method. +type Event struct { + EventID string + EventType string + OccurredAt string + WorkspaceID string + Payload map[string]any +} + +func (e *Event) toProto() (*pluginv1.Event, error) { + payload, err := mapToStruct(e.Payload) + if err != nil { + return nil, fmt.Errorf("pluginsdk: event payload: %w", err) + } + return &pluginv1.Event{ + EventId: e.EventID, + EventType: e.EventType, + OccurredAt: e.OccurredAt, + WorkspaceId: e.WorkspaceID, + Payload: payload, + }, nil +} + +func eventFromProto(p *pluginv1.Event) (*Event, error) { + payload, err := structToMap(p.GetPayload()) + if err != nil { + return nil, fmt.Errorf("pluginsdk: event payload: %w", err) + } + return &Event{ + EventID: p.GetEventId(), + EventType: p.GetEventType(), + OccurredAt: p.GetOccurredAt(), + WorkspaceID: p.GetWorkspaceId(), + Payload: payload, + }, nil +} + +// ToolContext is the Go-native mirror of kandev.plugin.v1.ToolContext. +type ToolContext struct { + TaskID string + AgentInstanceID string + SessionID string +} + +func (c *ToolContext) toProto() *pluginv1.ToolContext { + if c == nil { + return nil + } + return &pluginv1.ToolContext{ + TaskId: c.TaskID, + AgentInstanceId: c.AgentInstanceID, + SessionId: c.SessionID, + } +} + +func toolContextFromProto(p *pluginv1.ToolContext) *ToolContext { + if p == nil { + return nil + } + return &ToolContext{ + TaskID: p.GetTaskId(), + AgentInstanceID: p.GetAgentInstanceId(), + SessionID: p.GetSessionId(), + } +} + +// ToolRequest is the Go-native mirror of kandev.plugin.v1.ToolRequest, +// delivered to a Plugin's InvokeTool method. +type ToolRequest struct { + ToolCallID string + ToolName string + Input map[string]any + Context *ToolContext +} + +func (r *ToolRequest) toProto() (*pluginv1.ToolRequest, error) { + input, err := mapToStruct(r.Input) + if err != nil { + return nil, fmt.Errorf("pluginsdk: tool request input: %w", err) + } + return &pluginv1.ToolRequest{ + ToolCallId: r.ToolCallID, + ToolName: r.ToolName, + Input: input, + Context: r.Context.toProto(), + }, nil +} + +func toolRequestFromProto(p *pluginv1.ToolRequest) (*ToolRequest, error) { + input, err := structToMap(p.GetInput()) + if err != nil { + return nil, fmt.Errorf("pluginsdk: tool request input: %w", err) + } + return &ToolRequest{ + ToolCallID: p.GetToolCallId(), + ToolName: p.GetToolName(), + Input: input, + Context: toolContextFromProto(p.GetContext()), + }, nil +} + +// ToolResponse is the Go-native mirror of kandev.plugin.v1.ToolResponse, +// returned by a Plugin's InvokeTool method. +type ToolResponse struct { + Output map[string]any + Error string +} + +func (r *ToolResponse) toProto() (*pluginv1.ToolResponse, error) { + output, err := mapToStruct(r.Output) + if err != nil { + return nil, fmt.Errorf("pluginsdk: tool response output: %w", err) + } + return &pluginv1.ToolResponse{ + Output: output, + Error: r.Error, + }, nil +} + +func toolResponseFromProto(p *pluginv1.ToolResponse) (*ToolResponse, error) { + output, err := structToMap(p.GetOutput()) + if err != nil { + return nil, fmt.Errorf("pluginsdk: tool response output: %w", err) + } + return &ToolResponse{ + Output: output, + Error: p.GetError(), + }, nil +} + +// WebhookRequest is the Go-native mirror of kandev.plugin.v1.WebhookRequest, +// delivered to a Plugin's HandleWebhook method. Unlike Event/ToolRequest, +// it has no Struct-typed fields so conversion cannot fail. +type WebhookRequest struct { + WebhookKey string + Method string + Path string + Query string + Headers map[string]string + Body []byte +} + +func (r *WebhookRequest) toProto() *pluginv1.WebhookRequest { + return &pluginv1.WebhookRequest{ + WebhookKey: r.WebhookKey, + Method: r.Method, + Path: r.Path, + Query: r.Query, + Headers: r.Headers, + Body: r.Body, + } +} + +func webhookRequestFromProto(p *pluginv1.WebhookRequest) *WebhookRequest { + return &WebhookRequest{ + WebhookKey: p.GetWebhookKey(), + Method: p.GetMethod(), + Path: p.GetPath(), + Query: p.GetQuery(), + Headers: p.GetHeaders(), + Body: p.GetBody(), + } +} + +// WebhookResponse is the Go-native mirror of kandev.plugin.v1.WebhookResponse, +// returned by a Plugin's HandleWebhook method. +type WebhookResponse struct { + Status int32 + Headers map[string]string + Body []byte +} + +func (r *WebhookResponse) toProto() *pluginv1.WebhookResponse { + return &pluginv1.WebhookResponse{ + Status: r.Status, + Headers: r.Headers, + Body: r.Body, + } +} + +func webhookResponseFromProto(p *pluginv1.WebhookResponse) *WebhookResponse { + return &WebhookResponse{ + Status: p.GetStatus(), + Headers: p.GetHeaders(), + Body: p.GetBody(), + } +} + +// StateEntry is the Go-native mirror of kandev.plugin.v1.StateEntry, as +// returned by Host.ListState. +type StateEntry struct { + Key string + Value map[string]any + UpdatedAt string +} + +func (e *StateEntry) toProto() (*pluginv1.StateEntry, error) { + value, err := mapToStruct(e.Value) + if err != nil { + return nil, fmt.Errorf("pluginsdk: state entry value: %w", err) + } + return &pluginv1.StateEntry{ + Key: e.Key, + Value: value, + UpdatedAt: e.UpdatedAt, + }, nil +} + +func stateEntryFromProto(p *pluginv1.StateEntry) (*StateEntry, error) { + value, err := structToMap(p.GetValue()) + if err != nil { + return nil, fmt.Errorf("pluginsdk: state entry value: %w", err) + } + return &StateEntry{ + Key: p.GetKey(), + Value: value, + UpdatedAt: p.GetUpdatedAt(), + }, nil +} + +func stateEntriesFromProto(entries []*pluginv1.StateEntry) ([]StateEntry, error) { + if entries == nil { + return nil, nil + } + out := make([]StateEntry, len(entries)) + for i, e := range entries { + converted, err := stateEntryFromProto(e) + if err != nil { + return nil, err + } + out[i] = *converted + } + return out, nil +} + +// mapToStruct converts a Go-native map to a google.protobuf.Struct. A nil +// map converts to a nil Struct (distinguishing "no payload" from "empty +// payload" across the wire). +func mapToStruct(m map[string]any) (*structpb.Struct, error) { + if m == nil { + return nil, nil + } + s, err := structpb.NewStruct(m) + if err != nil { + return nil, fmt.Errorf("pluginsdk: convert map to struct: %w", err) + } + return s, nil +} + +// structToMap converts a google.protobuf.Struct to a Go-native map. A nil +// Struct converts to a nil map. +func structToMap(s *structpb.Struct) (map[string]any, error) { + if s == nil { + return nil, nil + } + return s.AsMap(), nil +} diff --git a/apps/backend/pkg/pluginsdk/types_test.go b/apps/backend/pkg/pluginsdk/types_test.go new file mode 100644 index 0000000000..ca6d32d225 --- /dev/null +++ b/apps/backend/pkg/pluginsdk/types_test.go @@ -0,0 +1,108 @@ +package pluginsdk + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEventProtoRoundTrip(t *testing.T) { + e := &Event{ + EventID: "evt-1", + EventType: "task.created", + OccurredAt: "2026-07-15T12:00:00Z", + WorkspaceID: "ws-1", + Payload: map[string]any{ + "task_id": "task-1", + "count": float64(3), + }, + } + + proto, err := e.toProto() + require.NoError(t, err) + require.Equal(t, "evt-1", proto.GetEventId()) + require.Equal(t, "task.created", proto.GetEventType()) + + back, err := eventFromProto(proto) + require.NoError(t, err) + require.Equal(t, e, back) +} + +func TestEventProtoRoundTrip_NilPayload(t *testing.T) { + e := &Event{EventID: "evt-2", EventType: "task.updated"} + + proto, err := e.toProto() + require.NoError(t, err) + require.Nil(t, proto.GetPayload()) + + back, err := eventFromProto(proto) + require.NoError(t, err) + require.Nil(t, back.Payload) +} + +func TestToolRequestResponseProtoRoundTrip(t *testing.T) { + req := &ToolRequest{ + ToolCallID: "call-1", + ToolName: "search", + Input: map[string]any{"query": "kandev"}, + Context: &ToolContext{ + TaskID: "task-1", + AgentInstanceID: "agent-1", + SessionID: "session-1", + }, + } + + proto, err := req.toProto() + require.NoError(t, err) + + back, err := toolRequestFromProto(proto) + require.NoError(t, err) + require.Equal(t, req, back) + + resp := &ToolResponse{Output: map[string]any{"result": "ok"}} + protoResp, err := resp.toProto() + require.NoError(t, err) + + backResp, err := toolResponseFromProto(protoResp) + require.NoError(t, err) + require.Equal(t, resp, backResp) +} + +func TestWebhookRequestResponseProtoRoundTrip(t *testing.T) { + req := &WebhookRequest{ + WebhookKey: "key-1", + Method: "POST", + Path: "/foo", + Query: "a=b", + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(`{"ok":true}`), + } + + proto := req.toProto() + back := webhookRequestFromProto(proto) + require.Equal(t, req, back) + + resp := &WebhookResponse{ + Status: 200, + Headers: map[string]string{"X-Test": "1"}, + Body: []byte("done"), + } + protoResp := resp.toProto() + backResp := webhookResponseFromProto(protoResp) + require.Equal(t, resp, backResp) +} + +func TestStateEntryProtoRoundTrip(t *testing.T) { + e := &StateEntry{ + Key: "foo", + Value: map[string]any{"bar": "baz"}, + UpdatedAt: "2026-07-15T12:00:00Z", + } + + proto, err := e.toProto() + require.NoError(t, err) + + back, err := stateEntryFromProto(proto) + require.NoError(t, err) + require.Equal(t, e, back) +} diff --git a/apps/backend/proto/buf.gen.yaml b/apps/backend/proto/buf.gen.yaml new file mode 100644 index 0000000000..02c4d632a2 --- /dev/null +++ b/apps/backend/proto/buf.gen.yaml @@ -0,0 +1,10 @@ +version: v2 +plugins: + - local: protoc-gen-go + out: . + opt: + - paths=source_relative + - local: protoc-gen-go-grpc + out: . + opt: + - paths=source_relative diff --git a/apps/backend/proto/buf.yaml b/apps/backend/proto/buf.yaml new file mode 100644 index 0000000000..f74da98a3c --- /dev/null +++ b/apps/backend/proto/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: . +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go new file mode 100644 index 0000000000..5fc549bc20 --- /dev/null +++ b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go @@ -0,0 +1,1300 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: kandev/plugin/v1/plugin.proto + +package pluginv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Event struct { + state protoimpl.MessageState `protogen:"open.v1"` + EventId string `protobuf:"bytes,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` // fresh uuid per delivery + EventType string `protobuf:"bytes,2,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"` // bus subject, e.g. "task.created" + OccurredAt string `protobuf:"bytes,3,opt,name=occurred_at,json=occurredAt,proto3" json:"occurred_at,omitempty"` // RFC3339 UTC + WorkspaceId string `protobuf:"bytes,4,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` // empty if not derivable + Payload *structpb.Struct `protobuf:"bytes,5,opt,name=payload,proto3" json:"payload,omitempty"` // marshaled bus event.Data + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Event) Reset() { + *x = Event{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Event) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Event) ProtoMessage() {} + +func (x *Event) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[0] + 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 Event.ProtoReflect.Descriptor instead. +func (*Event) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{0} +} + +func (x *Event) GetEventId() string { + if x != nil { + return x.EventId + } + return "" +} + +func (x *Event) GetEventType() string { + if x != nil { + return x.EventType + } + return "" +} + +func (x *Event) GetOccurredAt() string { + if x != nil { + return x.OccurredAt + } + return "" +} + +func (x *Event) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId + } + return "" +} + +func (x *Event) GetPayload() *structpb.Struct { + if x != nil { + return x.Payload + } + return nil +} + +type EventAck struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EventAck) Reset() { + *x = EventAck{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EventAck) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EventAck) ProtoMessage() {} + +func (x *EventAck) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[1] + 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 EventAck.ProtoReflect.Descriptor instead. +func (*EventAck) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{1} +} + +type ToolRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ToolCallId string `protobuf:"bytes,1,opt,name=tool_call_id,json=toolCallId,proto3" json:"tool_call_id,omitempty"` + ToolName string `protobuf:"bytes,2,opt,name=tool_name,json=toolName,proto3" json:"tool_name,omitempty"` + Input *structpb.Struct `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` + Context *ToolContext `protobuf:"bytes,4,opt,name=context,proto3" json:"context,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolRequest) Reset() { + *x = ToolRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolRequest) ProtoMessage() {} + +func (x *ToolRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[2] + 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 ToolRequest.ProtoReflect.Descriptor instead. +func (*ToolRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{2} +} + +func (x *ToolRequest) GetToolCallId() string { + if x != nil { + return x.ToolCallId + } + return "" +} + +func (x *ToolRequest) GetToolName() string { + if x != nil { + return x.ToolName + } + return "" +} + +func (x *ToolRequest) GetInput() *structpb.Struct { + if x != nil { + return x.Input + } + return nil +} + +func (x *ToolRequest) GetContext() *ToolContext { + if x != nil { + return x.Context + } + return nil +} + +type ToolContext struct { + state protoimpl.MessageState `protogen:"open.v1"` + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + AgentInstanceId string `protobuf:"bytes,2,opt,name=agent_instance_id,json=agentInstanceId,proto3" json:"agent_instance_id,omitempty"` + SessionId string `protobuf:"bytes,3,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolContext) Reset() { + *x = ToolContext{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolContext) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolContext) ProtoMessage() {} + +func (x *ToolContext) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[3] + 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 ToolContext.ProtoReflect.Descriptor instead. +func (*ToolContext) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{3} +} + +func (x *ToolContext) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *ToolContext) GetAgentInstanceId() string { + if x != nil { + return x.AgentInstanceId + } + return "" +} + +func (x *ToolContext) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type ToolResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Output *structpb.Struct `protobuf:"bytes,1,opt,name=output,proto3" json:"output,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolResponse) Reset() { + *x = ToolResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolResponse) ProtoMessage() {} + +func (x *ToolResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[4] + 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 ToolResponse.ProtoReflect.Descriptor instead. +func (*ToolResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{4} +} + +func (x *ToolResponse) GetOutput() *structpb.Struct { + if x != nil { + return x.Output + } + return nil +} + +func (x *ToolResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type WebhookRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WebhookKey string `protobuf:"bytes,1,opt,name=webhook_key,json=webhookKey,proto3" json:"webhook_key,omitempty"` + Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"` + Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` // remainder after the key + Query string `protobuf:"bytes,4,opt,name=query,proto3" json:"query,omitempty"` + Headers map[string]string `protobuf:"bytes,5,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // single-valued; multi joined by ", " + Body []byte `protobuf:"bytes,6,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WebhookRequest) Reset() { + *x = WebhookRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WebhookRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WebhookRequest) ProtoMessage() {} + +func (x *WebhookRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[5] + 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 WebhookRequest.ProtoReflect.Descriptor instead. +func (*WebhookRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{5} +} + +func (x *WebhookRequest) GetWebhookKey() string { + if x != nil { + return x.WebhookKey + } + return "" +} + +func (x *WebhookRequest) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *WebhookRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *WebhookRequest) GetQuery() string { + if x != nil { + return x.Query + } + return "" +} + +func (x *WebhookRequest) GetHeaders() map[string]string { + if x != nil { + return x.Headers + } + return nil +} + +func (x *WebhookRequest) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +type WebhookResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status int32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"` + Headers map[string]string `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Body []byte `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WebhookResponse) Reset() { + *x = WebhookResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WebhookResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WebhookResponse) ProtoMessage() {} + +func (x *WebhookResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[6] + 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 WebhookResponse.ProtoReflect.Descriptor instead. +func (*WebhookResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{6} +} + +func (x *WebhookResponse) GetStatus() int32 { + if x != nil { + return x.Status + } + return 0 +} + +func (x *WebhookResponse) GetHeaders() map[string]string { + if x != nil { + return x.Headers + } + return nil +} + +func (x *WebhookResponse) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +type GetStateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Scope string `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"` + ScopeId string `protobuf:"bytes,2,opt,name=scope_id,json=scopeId,proto3" json:"scope_id,omitempty"` + Key string `protobuf:"bytes,3,opt,name=key,proto3" json:"key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetStateRequest) Reset() { + *x = GetStateRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetStateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStateRequest) ProtoMessage() {} + +func (x *GetStateRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[7] + 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 GetStateRequest.ProtoReflect.Descriptor instead. +func (*GetStateRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{7} +} + +func (x *GetStateRequest) GetScope() string { + if x != nil { + return x.Scope + } + return "" +} + +func (x *GetStateRequest) GetScopeId() string { + if x != nil { + return x.ScopeId + } + return "" +} + +func (x *GetStateRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +type GetStateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Found bool `protobuf:"varint,1,opt,name=found,proto3" json:"found,omitempty"` + Value *structpb.Struct `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetStateResponse) Reset() { + *x = GetStateResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetStateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStateResponse) ProtoMessage() {} + +func (x *GetStateResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[8] + 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 GetStateResponse.ProtoReflect.Descriptor instead. +func (*GetStateResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{8} +} + +func (x *GetStateResponse) GetFound() bool { + if x != nil { + return x.Found + } + return false +} + +func (x *GetStateResponse) GetValue() *structpb.Struct { + if x != nil { + return x.Value + } + return nil +} + +type SetStateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Scope string `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"` + ScopeId string `protobuf:"bytes,2,opt,name=scope_id,json=scopeId,proto3" json:"scope_id,omitempty"` + Key string `protobuf:"bytes,3,opt,name=key,proto3" json:"key,omitempty"` + Value *structpb.Struct `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetStateRequest) Reset() { + *x = SetStateRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetStateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetStateRequest) ProtoMessage() {} + +func (x *SetStateRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[9] + 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 SetStateRequest.ProtoReflect.Descriptor instead. +func (*SetStateRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{9} +} + +func (x *SetStateRequest) GetScope() string { + if x != nil { + return x.Scope + } + return "" +} + +func (x *SetStateRequest) GetScopeId() string { + if x != nil { + return x.ScopeId + } + return "" +} + +func (x *SetStateRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *SetStateRequest) GetValue() *structpb.Struct { + if x != nil { + return x.Value + } + return nil +} + +type SetStateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetStateResponse) Reset() { + *x = SetStateResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetStateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetStateResponse) ProtoMessage() {} + +func (x *SetStateResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[10] + 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 SetStateResponse.ProtoReflect.Descriptor instead. +func (*SetStateResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{10} +} + +type DeleteStateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Scope string `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"` + ScopeId string `protobuf:"bytes,2,opt,name=scope_id,json=scopeId,proto3" json:"scope_id,omitempty"` + Key string `protobuf:"bytes,3,opt,name=key,proto3" json:"key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteStateRequest) Reset() { + *x = DeleteStateRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteStateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteStateRequest) ProtoMessage() {} + +func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[11] + 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 DeleteStateRequest.ProtoReflect.Descriptor instead. +func (*DeleteStateRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{11} +} + +func (x *DeleteStateRequest) GetScope() string { + if x != nil { + return x.Scope + } + return "" +} + +func (x *DeleteStateRequest) GetScopeId() string { + if x != nil { + return x.ScopeId + } + return "" +} + +func (x *DeleteStateRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +type DeleteStateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteStateResponse) Reset() { + *x = DeleteStateResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteStateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteStateResponse) ProtoMessage() {} + +func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[12] + 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 DeleteStateResponse.ProtoReflect.Descriptor instead. +func (*DeleteStateResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{12} +} + +type ListStateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Scope string `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"` + ScopeId string `protobuf:"bytes,2,opt,name=scope_id,json=scopeId,proto3" json:"scope_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListStateRequest) Reset() { + *x = ListStateRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListStateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListStateRequest) ProtoMessage() {} + +func (x *ListStateRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[13] + 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 ListStateRequest.ProtoReflect.Descriptor instead. +func (*ListStateRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{13} +} + +func (x *ListStateRequest) GetScope() string { + if x != nil { + return x.Scope + } + return "" +} + +func (x *ListStateRequest) GetScopeId() string { + if x != nil { + return x.ScopeId + } + return "" +} + +type ListStateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries []*StateEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListStateResponse) Reset() { + *x = ListStateResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListStateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListStateResponse) ProtoMessage() {} + +func (x *ListStateResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[14] + 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 ListStateResponse.ProtoReflect.Descriptor instead. +func (*ListStateResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{14} +} + +func (x *ListStateResponse) GetEntries() []*StateEntry { + if x != nil { + return x.Entries + } + return nil +} + +type StateEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + Value *structpb.Struct `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + UpdatedAt string `protobuf:"bytes,3,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StateEntry) Reset() { + *x = StateEntry{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StateEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StateEntry) ProtoMessage() {} + +func (x *StateEntry) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[15] + 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 StateEntry.ProtoReflect.Descriptor instead. +func (*StateEntry) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{15} +} + +func (x *StateEntry) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *StateEntry) GetValue() *structpb.Struct { + if x != nil { + return x.Value + } + return nil +} + +func (x *StateEntry) GetUpdatedAt() string { + if x != nil { + return x.UpdatedAt + } + return "" +} + +type RevealSecretRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ref string `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevealSecretRequest) Reset() { + *x = RevealSecretRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevealSecretRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevealSecretRequest) ProtoMessage() {} + +func (x *RevealSecretRequest) 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 RevealSecretRequest.ProtoReflect.Descriptor instead. +func (*RevealSecretRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{16} +} + +func (x *RevealSecretRequest) GetRef() string { + if x != nil { + return x.Ref + } + return "" +} + +type RevealSecretResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevealSecretResponse) Reset() { + *x = RevealSecretResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevealSecretResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevealSecretResponse) ProtoMessage() {} + +func (x *RevealSecretResponse) 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 RevealSecretResponse.ProtoReflect.Descriptor instead. +func (*RevealSecretResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{17} +} + +func (x *RevealSecretResponse) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type EmitEventRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + EventName string `protobuf:"bytes,1,opt,name=event_name,json=eventName,proto3" json:"event_name,omitempty"` + Payload *structpb.Struct `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EmitEventRequest) Reset() { + *x = EmitEventRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmitEventRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmitEventRequest) ProtoMessage() {} + +func (x *EmitEventRequest) 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 EmitEventRequest.ProtoReflect.Descriptor instead. +func (*EmitEventRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{18} +} + +func (x *EmitEventRequest) GetEventName() string { + if x != nil { + return x.EventName + } + return "" +} + +func (x *EmitEventRequest) GetPayload() *structpb.Struct { + if x != nil { + return x.Payload + } + return nil +} + +type EmitEventResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EmitEventResponse) Reset() { + *x = EmitEventResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmitEventResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmitEventResponse) ProtoMessage() {} + +func (x *EmitEventResponse) 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 EmitEventResponse.ProtoReflect.Descriptor instead. +func (*EmitEventResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{19} +} + +var File_kandev_plugin_v1_plugin_proto protoreflect.FileDescriptor + +const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + + "\n" + + "\x1dkandev/plugin/v1/plugin.proto\x12\x10kandev.plugin.v1\x1a\x1cgoogle/protobuf/struct.proto\"\xb8\x01\n" + + "\x05Event\x12\x19\n" + + "\bevent_id\x18\x01 \x01(\tR\aeventId\x12\x1d\n" + + "\n" + + "event_type\x18\x02 \x01(\tR\teventType\x12\x1f\n" + + "\voccurred_at\x18\x03 \x01(\tR\n" + + "occurredAt\x12!\n" + + "\fworkspace_id\x18\x04 \x01(\tR\vworkspaceId\x121\n" + + "\apayload\x18\x05 \x01(\v2\x17.google.protobuf.StructR\apayload\"\n" + + "\n" + + "\bEventAck\"\xb4\x01\n" + + "\vToolRequest\x12 \n" + + "\ftool_call_id\x18\x01 \x01(\tR\n" + + "toolCallId\x12\x1b\n" + + "\ttool_name\x18\x02 \x01(\tR\btoolName\x12-\n" + + "\x05input\x18\x03 \x01(\v2\x17.google.protobuf.StructR\x05input\x127\n" + + "\acontext\x18\x04 \x01(\v2\x1d.kandev.plugin.v1.ToolContextR\acontext\"q\n" + + "\vToolContext\x12\x17\n" + + "\atask_id\x18\x01 \x01(\tR\x06taskId\x12*\n" + + "\x11agent_instance_id\x18\x02 \x01(\tR\x0fagentInstanceId\x12\x1d\n" + + "\n" + + "session_id\x18\x03 \x01(\tR\tsessionId\"U\n" + + "\fToolResponse\x12/\n" + + "\x06output\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x06output\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"\x8c\x02\n" + + "\x0eWebhookRequest\x12\x1f\n" + + "\vwebhook_key\x18\x01 \x01(\tR\n" + + "webhookKey\x12\x16\n" + + "\x06method\x18\x02 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x03 \x01(\tR\x04path\x12\x14\n" + + "\x05query\x18\x04 \x01(\tR\x05query\x12G\n" + + "\aheaders\x18\x05 \x03(\v2-.kandev.plugin.v1.WebhookRequest.HeadersEntryR\aheaders\x12\x12\n" + + "\x04body\x18\x06 \x01(\fR\x04body\x1a:\n" + + "\fHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xc3\x01\n" + + "\x0fWebhookResponse\x12\x16\n" + + "\x06status\x18\x01 \x01(\x05R\x06status\x12H\n" + + "\aheaders\x18\x02 \x03(\v2..kandev.plugin.v1.WebhookResponse.HeadersEntryR\aheaders\x12\x12\n" + + "\x04body\x18\x03 \x01(\fR\x04body\x1a:\n" + + "\fHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"T\n" + + "\x0fGetStateRequest\x12\x14\n" + + "\x05scope\x18\x01 \x01(\tR\x05scope\x12\x19\n" + + "\bscope_id\x18\x02 \x01(\tR\ascopeId\x12\x10\n" + + "\x03key\x18\x03 \x01(\tR\x03key\"W\n" + + "\x10GetStateResponse\x12\x14\n" + + "\x05found\x18\x01 \x01(\bR\x05found\x12-\n" + + "\x05value\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x05value\"\x83\x01\n" + + "\x0fSetStateRequest\x12\x14\n" + + "\x05scope\x18\x01 \x01(\tR\x05scope\x12\x19\n" + + "\bscope_id\x18\x02 \x01(\tR\ascopeId\x12\x10\n" + + "\x03key\x18\x03 \x01(\tR\x03key\x12-\n" + + "\x05value\x18\x04 \x01(\v2\x17.google.protobuf.StructR\x05value\"\x12\n" + + "\x10SetStateResponse\"W\n" + + "\x12DeleteStateRequest\x12\x14\n" + + "\x05scope\x18\x01 \x01(\tR\x05scope\x12\x19\n" + + "\bscope_id\x18\x02 \x01(\tR\ascopeId\x12\x10\n" + + "\x03key\x18\x03 \x01(\tR\x03key\"\x15\n" + + "\x13DeleteStateResponse\"C\n" + + "\x10ListStateRequest\x12\x14\n" + + "\x05scope\x18\x01 \x01(\tR\x05scope\x12\x19\n" + + "\bscope_id\x18\x02 \x01(\tR\ascopeId\"K\n" + + "\x11ListStateResponse\x126\n" + + "\aentries\x18\x01 \x03(\v2\x1c.kandev.plugin.v1.StateEntryR\aentries\"l\n" + + "\n" + + "StateEntry\x12\x10\n" + + "\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" + + "\x13RevealSecretRequest\x12\x10\n" + + "\x03ref\x18\x01 \x01(\tR\x03ref\",\n" + + "\x14RevealSecretResponse\x12\x14\n" + + "\x05value\x18\x01 \x01(\tR\x05value\"d\n" + + "\x10EmitEventRequest\x12\x1d\n" + + "\n" + + "event_name\x18\x01 \x01(\tR\teventName\x121\n" + + "\apayload\x18\x02 \x01(\v2\x17.google.protobuf.StructR\apayload\"\x13\n" + + "\x11EmitEventResponse2\xf0\x01\n" + + "\x06Plugin\x12C\n" + + "\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\x93\x04\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" + + "\vDeleteState\x12$.kandev.plugin.v1.DeleteStateRequest\x1a%.kandev.plugin.v1.DeleteStateResponse\x12T\n" + + "\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.EmitEventResponseB:Z8github.com/kandev/kandev/proto/kandev/plugin/v1;pluginv1b\x06proto3" + +var ( + file_kandev_plugin_v1_plugin_proto_rawDescOnce sync.Once + file_kandev_plugin_v1_plugin_proto_rawDescData []byte +) + +func file_kandev_plugin_v1_plugin_proto_rawDescGZIP() []byte { + file_kandev_plugin_v1_plugin_proto_rawDescOnce.Do(func() { + file_kandev_plugin_v1_plugin_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_kandev_plugin_v1_plugin_proto_rawDesc), len(file_kandev_plugin_v1_plugin_proto_rawDesc))) + }) + return file_kandev_plugin_v1_plugin_proto_rawDescData +} + +var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_kandev_plugin_v1_plugin_proto_goTypes = []any{ + (*Event)(nil), // 0: kandev.plugin.v1.Event + (*EventAck)(nil), // 1: kandev.plugin.v1.EventAck + (*ToolRequest)(nil), // 2: kandev.plugin.v1.ToolRequest + (*ToolContext)(nil), // 3: kandev.plugin.v1.ToolContext + (*ToolResponse)(nil), // 4: kandev.plugin.v1.ToolResponse + (*WebhookRequest)(nil), // 5: kandev.plugin.v1.WebhookRequest + (*WebhookResponse)(nil), // 6: kandev.plugin.v1.WebhookResponse + (*GetStateRequest)(nil), // 7: kandev.plugin.v1.GetStateRequest + (*GetStateResponse)(nil), // 8: kandev.plugin.v1.GetStateResponse + (*SetStateRequest)(nil), // 9: kandev.plugin.v1.SetStateRequest + (*SetStateResponse)(nil), // 10: kandev.plugin.v1.SetStateResponse + (*DeleteStateRequest)(nil), // 11: kandev.plugin.v1.DeleteStateRequest + (*DeleteStateResponse)(nil), // 12: kandev.plugin.v1.DeleteStateResponse + (*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 + nil, // 20: kandev.plugin.v1.WebhookRequest.HeadersEntry + nil, // 21: kandev.plugin.v1.WebhookResponse.HeadersEntry + (*structpb.Struct)(nil), // 22: google.protobuf.Struct +} +var file_kandev_plugin_v1_plugin_proto_depIdxs = []int32{ + 22, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct + 22, // 1: kandev.plugin.v1.ToolRequest.input:type_name -> google.protobuf.Struct + 3, // 2: kandev.plugin.v1.ToolRequest.context:type_name -> kandev.plugin.v1.ToolContext + 22, // 3: kandev.plugin.v1.ToolResponse.output:type_name -> google.protobuf.Struct + 20, // 4: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry + 21, // 5: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry + 22, // 6: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct + 22, // 7: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct + 15, // 8: kandev.plugin.v1.ListStateResponse.entries:type_name -> kandev.plugin.v1.StateEntry + 22, // 9: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct + 22, // 10: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct + 0, // 11: kandev.plugin.v1.Plugin.DeliverEvent:input_type -> kandev.plugin.v1.Event + 2, // 12: kandev.plugin.v1.Plugin.InvokeTool:input_type -> kandev.plugin.v1.ToolRequest + 5, // 13: kandev.plugin.v1.Plugin.HandleWebhook:input_type -> kandev.plugin.v1.WebhookRequest + 7, // 14: kandev.plugin.v1.Host.GetState:input_type -> kandev.plugin.v1.GetStateRequest + 9, // 15: kandev.plugin.v1.Host.SetState:input_type -> kandev.plugin.v1.SetStateRequest + 11, // 16: kandev.plugin.v1.Host.DeleteState:input_type -> kandev.plugin.v1.DeleteStateRequest + 13, // 17: kandev.plugin.v1.Host.ListState:input_type -> kandev.plugin.v1.ListStateRequest + 16, // 18: kandev.plugin.v1.Host.RevealSecret:input_type -> kandev.plugin.v1.RevealSecretRequest + 18, // 19: kandev.plugin.v1.Host.EmitEvent:input_type -> kandev.plugin.v1.EmitEventRequest + 1, // 20: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck + 4, // 21: kandev.plugin.v1.Plugin.InvokeTool:output_type -> kandev.plugin.v1.ToolResponse + 6, // 22: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse + 8, // 23: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse + 10, // 24: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse + 12, // 25: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse + 14, // 26: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse + 17, // 27: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse + 19, // 28: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse + 20, // [20:29] is the sub-list for method output_type + 11, // [11:20] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_kandev_plugin_v1_plugin_proto_init() } +func file_kandev_plugin_v1_plugin_proto_init() { + if File_kandev_plugin_v1_plugin_proto != nil { + return + } + 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: 22, + NumExtensions: 0, + NumServices: 2, + }, + GoTypes: file_kandev_plugin_v1_plugin_proto_goTypes, + DependencyIndexes: file_kandev_plugin_v1_plugin_proto_depIdxs, + MessageInfos: file_kandev_plugin_v1_plugin_proto_msgTypes, + }.Build() + File_kandev_plugin_v1_plugin_proto = out.File + file_kandev_plugin_v1_plugin_proto_goTypes = nil + file_kandev_plugin_v1_plugin_proto_depIdxs = nil +} diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.proto b/apps/backend/proto/kandev/plugin/v1/plugin.proto new file mode 100644 index 0000000000..f3c594c6f9 --- /dev/null +++ b/apps/backend/proto/kandev/plugin/v1/plugin.proto @@ -0,0 +1,112 @@ +syntax = "proto3"; + +package kandev.plugin.v1; + +import "google/protobuf/struct.proto"; + +option go_package = "github.com/kandev/kandev/proto/kandev/plugin/v1;pluginv1"; + +// Implemented by the PLUGIN. kandev is the client. +service Plugin { + rpc DeliverEvent(Event) returns (EventAck); + rpc InvokeTool(ToolRequest) returns (ToolResponse); + rpc HandleWebhook(WebhookRequest) returns (WebhookResponse); +} + +// Implemented by KANDEV (served back over the go-plugin broker). +// Every RPC is capability-gated server-side (§5). +service Host { + rpc GetState(GetStateRequest) returns (GetStateResponse); + rpc SetState(SetStateRequest) returns (SetStateResponse); + rpc DeleteState(DeleteStateRequest) returns (DeleteStateResponse); + rpc ListState(ListStateRequest) returns (ListStateResponse); + rpc RevealSecret(RevealSecretRequest) returns (RevealSecretResponse); + rpc EmitEvent(EmitEventRequest) returns (EmitEventResponse); +} + +message Event { + string event_id = 1; // fresh uuid per delivery + string event_type = 2; // bus subject, e.g. "task.created" + string occurred_at = 3; // RFC3339 UTC + string workspace_id = 4; // empty if not derivable + google.protobuf.Struct payload = 5; // marshaled bus event.Data +} +message EventAck {} + +message ToolRequest { + string tool_call_id = 1; + string tool_name = 2; + google.protobuf.Struct input = 3; + ToolContext context = 4; +} +message ToolContext { + string task_id = 1; + string agent_instance_id = 2; + string session_id = 3; +} +message ToolResponse { + google.protobuf.Struct output = 1; + string error = 2; +} + +message WebhookRequest { + string webhook_key = 1; + string method = 2; + string path = 3; // remainder after the key + string query = 4; + map headers = 5; // single-valued; multi joined by ", " + bytes body = 6; +} +message WebhookResponse { + int32 status = 1; + map headers = 2; + bytes body = 3; +} + +message GetStateRequest { + string scope = 1; + string scope_id = 2; + string key = 3; +} +message GetStateResponse { + bool found = 1; + google.protobuf.Struct value = 2; +} +message SetStateRequest { + string scope = 1; + string scope_id = 2; + string key = 3; + google.protobuf.Struct value = 4; +} +message SetStateResponse {} +message DeleteStateRequest { + string scope = 1; + string scope_id = 2; + string key = 3; +} +message DeleteStateResponse {} +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 RevealSecretRequest { + string ref = 1; +} +message RevealSecretResponse { + string value = 1; +} + +message EmitEventRequest { + string event_name = 1; + google.protobuf.Struct payload = 2; +} +message EmitEventResponse {} diff --git a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go new file mode 100644 index 0000000000..b072991cad --- /dev/null +++ b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go @@ -0,0 +1,499 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc (unknown) +// source: kandev/plugin/v1/plugin.proto + +package pluginv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Plugin_DeliverEvent_FullMethodName = "/kandev.plugin.v1.Plugin/DeliverEvent" + Plugin_InvokeTool_FullMethodName = "/kandev.plugin.v1.Plugin/InvokeTool" + Plugin_HandleWebhook_FullMethodName = "/kandev.plugin.v1.Plugin/HandleWebhook" +) + +// PluginClient is the client API for Plugin service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// Implemented by the PLUGIN. kandev is the client. +type PluginClient interface { + DeliverEvent(ctx context.Context, in *Event, opts ...grpc.CallOption) (*EventAck, error) + InvokeTool(ctx context.Context, in *ToolRequest, opts ...grpc.CallOption) (*ToolResponse, error) + HandleWebhook(ctx context.Context, in *WebhookRequest, opts ...grpc.CallOption) (*WebhookResponse, error) +} + +type pluginClient struct { + cc grpc.ClientConnInterface +} + +func NewPluginClient(cc grpc.ClientConnInterface) PluginClient { + return &pluginClient{cc} +} + +func (c *pluginClient) DeliverEvent(ctx context.Context, in *Event, opts ...grpc.CallOption) (*EventAck, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EventAck) + err := c.cc.Invoke(ctx, Plugin_DeliverEvent_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *pluginClient) InvokeTool(ctx context.Context, in *ToolRequest, opts ...grpc.CallOption) (*ToolResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ToolResponse) + err := c.cc.Invoke(ctx, Plugin_InvokeTool_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *pluginClient) HandleWebhook(ctx context.Context, in *WebhookRequest, opts ...grpc.CallOption) (*WebhookResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(WebhookResponse) + err := c.cc.Invoke(ctx, Plugin_HandleWebhook_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// PluginServer is the server API for Plugin service. +// All implementations must embed UnimplementedPluginServer +// for forward compatibility. +// +// Implemented by the PLUGIN. kandev is the client. +type PluginServer interface { + DeliverEvent(context.Context, *Event) (*EventAck, error) + InvokeTool(context.Context, *ToolRequest) (*ToolResponse, error) + HandleWebhook(context.Context, *WebhookRequest) (*WebhookResponse, error) + mustEmbedUnimplementedPluginServer() +} + +// UnimplementedPluginServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedPluginServer struct{} + +func (UnimplementedPluginServer) DeliverEvent(context.Context, *Event) (*EventAck, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeliverEvent not implemented") +} +func (UnimplementedPluginServer) InvokeTool(context.Context, *ToolRequest) (*ToolResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method InvokeTool not implemented") +} +func (UnimplementedPluginServer) HandleWebhook(context.Context, *WebhookRequest) (*WebhookResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method HandleWebhook not implemented") +} +func (UnimplementedPluginServer) mustEmbedUnimplementedPluginServer() {} +func (UnimplementedPluginServer) testEmbeddedByValue() {} + +// UnsafePluginServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to PluginServer will +// result in compilation errors. +type UnsafePluginServer interface { + mustEmbedUnimplementedPluginServer() +} + +func RegisterPluginServer(s grpc.ServiceRegistrar, srv PluginServer) { + // If the following call pancis, it indicates UnimplementedPluginServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Plugin_ServiceDesc, srv) +} + +func _Plugin_DeliverEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Event) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PluginServer).DeliverEvent(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Plugin_DeliverEvent_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PluginServer).DeliverEvent(ctx, req.(*Event)) + } + return interceptor(ctx, in, info, handler) +} + +func _Plugin_InvokeTool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ToolRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PluginServer).InvokeTool(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Plugin_InvokeTool_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PluginServer).InvokeTool(ctx, req.(*ToolRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Plugin_HandleWebhook_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(WebhookRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PluginServer).HandleWebhook(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Plugin_HandleWebhook_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PluginServer).HandleWebhook(ctx, req.(*WebhookRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// Plugin_ServiceDesc is the grpc.ServiceDesc for Plugin service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Plugin_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "kandev.plugin.v1.Plugin", + HandlerType: (*PluginServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "DeliverEvent", + Handler: _Plugin_DeliverEvent_Handler, + }, + { + MethodName: "InvokeTool", + Handler: _Plugin_InvokeTool_Handler, + }, + { + MethodName: "HandleWebhook", + Handler: _Plugin_HandleWebhook_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "kandev/plugin/v1/plugin.proto", +} + +const ( + Host_GetState_FullMethodName = "/kandev.plugin.v1.Host/GetState" + Host_SetState_FullMethodName = "/kandev.plugin.v1.Host/SetState" + Host_DeleteState_FullMethodName = "/kandev.plugin.v1.Host/DeleteState" + Host_ListState_FullMethodName = "/kandev.plugin.v1.Host/ListState" + Host_RevealSecret_FullMethodName = "/kandev.plugin.v1.Host/RevealSecret" + Host_EmitEvent_FullMethodName = "/kandev.plugin.v1.Host/EmitEvent" +) + +// HostClient is the client API for Host service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// Implemented by KANDEV (served back over the go-plugin broker). +// Every RPC is capability-gated server-side (§5). +type HostClient interface { + GetState(ctx context.Context, in *GetStateRequest, opts ...grpc.CallOption) (*GetStateResponse, error) + SetState(ctx context.Context, in *SetStateRequest, opts ...grpc.CallOption) (*SetStateResponse, error) + DeleteState(ctx context.Context, in *DeleteStateRequest, opts ...grpc.CallOption) (*DeleteStateResponse, error) + 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) +} + +type hostClient struct { + cc grpc.ClientConnInterface +} + +func NewHostClient(cc grpc.ClientConnInterface) HostClient { + return &hostClient{cc} +} + +func (c *hostClient) GetState(ctx context.Context, in *GetStateRequest, opts ...grpc.CallOption) (*GetStateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetStateResponse) + err := c.cc.Invoke(ctx, Host_GetState_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) SetState(ctx context.Context, in *SetStateRequest, opts ...grpc.CallOption) (*SetStateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SetStateResponse) + err := c.cc.Invoke(ctx, Host_SetState_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) DeleteState(ctx context.Context, in *DeleteStateRequest, opts ...grpc.CallOption) (*DeleteStateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteStateResponse) + err := c.cc.Invoke(ctx, Host_DeleteState_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) ListState(ctx context.Context, in *ListStateRequest, opts ...grpc.CallOption) (*ListStateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListStateResponse) + err := c.cc.Invoke(ctx, Host_ListState_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) RevealSecret(ctx context.Context, in *RevealSecretRequest, opts ...grpc.CallOption) (*RevealSecretResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RevealSecretResponse) + err := c.cc.Invoke(ctx, Host_RevealSecret_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) EmitEvent(ctx context.Context, in *EmitEventRequest, opts ...grpc.CallOption) (*EmitEventResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EmitEventResponse) + err := c.cc.Invoke(ctx, Host_EmitEvent_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// HostServer is the server API for Host service. +// All implementations must embed UnimplementedHostServer +// for forward compatibility. +// +// Implemented by KANDEV (served back over the go-plugin broker). +// Every RPC is capability-gated server-side (§5). +type HostServer interface { + GetState(context.Context, *GetStateRequest) (*GetStateResponse, error) + SetState(context.Context, *SetStateRequest) (*SetStateResponse, error) + DeleteState(context.Context, *DeleteStateRequest) (*DeleteStateResponse, error) + ListState(context.Context, *ListStateRequest) (*ListStateResponse, error) + RevealSecret(context.Context, *RevealSecretRequest) (*RevealSecretResponse, error) + EmitEvent(context.Context, *EmitEventRequest) (*EmitEventResponse, error) + mustEmbedUnimplementedHostServer() +} + +// UnimplementedHostServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedHostServer struct{} + +func (UnimplementedHostServer) GetState(context.Context, *GetStateRequest) (*GetStateResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetState not implemented") +} +func (UnimplementedHostServer) SetState(context.Context, *SetStateRequest) (*SetStateResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SetState not implemented") +} +func (UnimplementedHostServer) DeleteState(context.Context, *DeleteStateRequest) (*DeleteStateResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteState not implemented") +} +func (UnimplementedHostServer) ListState(context.Context, *ListStateRequest) (*ListStateResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListState not implemented") +} +func (UnimplementedHostServer) RevealSecret(context.Context, *RevealSecretRequest) (*RevealSecretResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RevealSecret not implemented") +} +func (UnimplementedHostServer) EmitEvent(context.Context, *EmitEventRequest) (*EmitEventResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method EmitEvent not implemented") +} +func (UnimplementedHostServer) mustEmbedUnimplementedHostServer() {} +func (UnimplementedHostServer) testEmbeddedByValue() {} + +// UnsafeHostServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to HostServer will +// result in compilation errors. +type UnsafeHostServer interface { + mustEmbedUnimplementedHostServer() +} + +func RegisterHostServer(s grpc.ServiceRegistrar, srv HostServer) { + // If the following call pancis, it indicates UnimplementedHostServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Host_ServiceDesc, srv) +} + +func _Host_GetState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetStateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).GetState(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_GetState_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).GetState(ctx, req.(*GetStateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_SetState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetStateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).SetState(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_SetState_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).SetState(ctx, req.(*SetStateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_DeleteState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteStateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).DeleteState(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_DeleteState_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).DeleteState(ctx, req.(*DeleteStateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_ListState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListStateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).ListState(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_ListState_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).ListState(ctx, req.(*ListStateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_RevealSecret_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RevealSecretRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).RevealSecret(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_RevealSecret_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).RevealSecret(ctx, req.(*RevealSecretRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_EmitEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EmitEventRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).EmitEvent(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_EmitEvent_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).EmitEvent(ctx, req.(*EmitEventRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// Host_ServiceDesc is the grpc.ServiceDesc for Host service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Host_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "kandev.plugin.v1.Host", + HandlerType: (*HostServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetState", + Handler: _Host_GetState_Handler, + }, + { + MethodName: "SetState", + Handler: _Host_SetState_Handler, + }, + { + MethodName: "DeleteState", + Handler: _Host_DeleteState_Handler, + }, + { + MethodName: "ListState", + Handler: _Host_ListState_Handler, + }, + { + MethodName: "RevealSecret", + Handler: _Host_RevealSecret_Handler, + }, + { + MethodName: "EmitEvent", + Handler: _Host_EmitEvent_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "kandev/plugin/v1/plugin.proto", +} diff --git a/apps/web/app/settings/plugins/page.test.tsx b/apps/web/app/settings/plugins/page.test.tsx new file mode 100644 index 0000000000..6a6722d492 --- /dev/null +++ b/apps/web/app/settings/plugins/page.test.tsx @@ -0,0 +1,391 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "sonner"; +import type { PluginRecord, SyncResult } from "@/lib/types/plugins"; + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } })); + +const { + enablePluginSpy, + disablePluginSpy, + uninstallPluginSpy, + installPluginFromUrlSpy, + installPluginUploadSpy, + listPluginsSpy, + syncPluginsSpy, +} = vi.hoisted(() => ({ + enablePluginSpy: vi.fn(), + disablePluginSpy: vi.fn(), + uninstallPluginSpy: vi.fn(), + installPluginFromUrlSpy: vi.fn(), + installPluginUploadSpy: vi.fn(), + listPluginsSpy: vi.fn(), + syncPluginsSpy: vi.fn(), +})); + +vi.mock("@/lib/api/domains/plugins-api", () => ({ + listPlugins: (...args: unknown[]) => { + listPluginsSpy(...args); + return Promise.resolve([]); + }, + enablePlugin: (...args: [string]) => { + enablePluginSpy(...args); + return Promise.resolve({ enabled: true }); + }, + disablePlugin: (...args: [string]) => { + disablePluginSpy(...args); + return Promise.resolve({ disabled: true }); + }, + uninstallPlugin: (...args: [string]) => { + uninstallPluginSpy(...args); + return Promise.resolve({ deleted: true }); + }, + installPluginFromUrl: (...args: [string]) => installPluginFromUrlSpy(...args), + installPluginUpload: (...args: [File]) => installPluginUploadSpy(...args), + syncPlugins: (...args: unknown[]) => syncPluginsSpy(...args), +})); + +const { loadPluginsSpy, unloadPluginSpy } = vi.hoisted(() => ({ + loadPluginsSpy: vi.fn(), + unloadPluginSpy: vi.fn(), +})); + +vi.mock("@/lib/plugins/host", () => ({ + loadPlugins: (...args: unknown[]) => { + loadPluginsSpy(...args); + return Promise.resolve(); + }, + unloadPlugin: (...args: unknown[]) => unloadPluginSpy(...args), +})); + +vi.mock("@/lib/plugins/host-api", () => ({ + buildHostApi: (pluginId: string) => ({ pluginId }), +})); + +vi.mock("@/components/theme/app-theme", () => ({ + useTheme: () => ({ resolvedTheme: "light" }), +})); + +let featureEnabled = true; +vi.mock("@/hooks/domains/features/use-feature", () => ({ + useFeature: () => featureEnabled, +})); + +let storeState: Record = {}; +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: Record) => unknown) => selector(storeState), + useAppStoreApi: () => ({ + getState: () => storeState, + setState: vi.fn(), + subscribe: vi.fn(() => () => {}), + }), +})); + +import PluginsSettingsPage from "./page"; + +const PLUGIN_ID = "acme-tools"; +const NEW_PLUGIN_ID = "new-plugin"; +const SYNC_BUTTON_TESTID = "plugins-sync-button"; +const INSTALL_TRIGGER_TESTID = "install-plugin-trigger"; +const NEW_PLUGIN_URL = "https://example.test/new-plugin-1.0.0.tar.gz"; + +function activePlugin(overrides: Partial = {}): PluginRecord { + return { + id: PLUGIN_ID, + api_version: 1, + version: "1.0.0", + display_name: "Acme Tools", + description: "desc", + author: "acme", + categories: ["productivity"], + capabilities: {}, + status: "active", + install_path: "/home/user/.kandev/plugins/acme-tools/1.0.0", + signed: true, + installed_at: "2026-01-01T00:00:00Z", + restart_count: 0, + ui: { bundle: "/ui/bundle.js", styles: ["/ui/style.css"] }, + ...overrides, + }; +} + +function installedPlugin(overrides: Partial = {}): PluginRecord { + return activePlugin({ + id: NEW_PLUGIN_ID, + display_name: "New Plugin", + install_path: `/home/user/.kandev/plugins/${NEW_PLUGIN_ID}/1.0.0`, + ...overrides, + }); +} + +function setStoreState(plugins: PluginRecord[]) { + storeState = { + plugins: { items: plugins, loading: false, loaded: true, error: null }, + setPlugins: vi.fn(), + setPluginsLoading: vi.fn(), + setPluginsError: vi.fn(), + upsertPlugin: vi.fn(), + removePlugin: vi.fn(), + }; +} + +function emptySyncResult(overrides: Partial = {}): SyncResult { + return { added: [], installed: [], missing: [], errors: [], ...overrides }; +} + +beforeEach(() => { + installPluginFromUrlSpy.mockResolvedValue({ plugin: installedPlugin() }); + installPluginUploadSpy.mockResolvedValue({ plugin: installedPlugin() }); + syncPluginsSpy.mockResolvedValue(emptySyncResult()); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + featureEnabled = true; + storeState = {}; +}); + +describe("PluginsSettingsPage", () => { + it("renders nothing when the plugins feature flag is off", () => { + featureEnabled = false; + setStoreState([]); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("shows an empty state when there are no plugins", () => { + setStoreState([]); + + render(); + + expect(screen.getByText(/no plugins/i)).toBeTruthy(); + }); + + it("lists plugins with their status, version, and an unsigned badge when unverified", () => { + setStoreState([ + activePlugin(), + activePlugin({ id: "unsigned-one", display_name: "Unsigned Plugin", signed: false }), + ]); + + render(); + + expect(screen.getByText("Acme Tools")).toBeTruthy(); + expect(screen.getByText(/acme-tools.*v1\.0\.0/)).toBeTruthy(); + expect(screen.getAllByText(/active/i).length).toBeGreaterThan(0); + expect(screen.getAllByTestId("plugin-unsigned-badge")).toHaveLength(1); + }); + + it("disables an active plugin: calls the API, unloads the bundle, and updates the store", async () => { + setStoreState([activePlugin()]); + + render(); + fireEvent.click(screen.getByRole("button", { name: /disable/i })); + + await vi.waitFor(() => expect(disablePluginSpy).toHaveBeenCalledWith(PLUGIN_ID)); + expect(unloadPluginSpy).toHaveBeenCalledWith(PLUGIN_ID); + const upsertPlugin = storeState.upsertPlugin as ReturnType; + expect(upsertPlugin).toHaveBeenCalledWith( + expect.objectContaining({ id: PLUGIN_ID, status: "disabled" }), + ); + }); + + it("enables a disabled plugin: calls the API, updates the store, and reloads the bundle", async () => { + setStoreState([activePlugin({ status: "disabled" })]); + + render(); + fireEvent.click(screen.getByRole("button", { name: /enable/i })); + + await vi.waitFor(() => expect(enablePluginSpy).toHaveBeenCalledWith(PLUGIN_ID)); + const upsertPlugin = storeState.upsertPlugin as ReturnType; + expect(upsertPlugin).toHaveBeenCalledWith( + expect.objectContaining({ id: PLUGIN_ID, status: "active" }), + ); + await vi.waitFor(() => + expect(loadPluginsSpy).toHaveBeenCalledWith( + [expect.objectContaining({ id: PLUGIN_ID, bundleUrl: `/api/plugins/${PLUGIN_ID}/bundle` })], + expect.any(Function), + ), + ); + }); + + it("uninstalls a plugin after confirmation: calls the API, unloads the bundle, and removes it from the store", async () => { + setStoreState([activePlugin()]); + + render(); + fireEvent.click(screen.getByRole("button", { name: /uninstall/i })); + fireEvent.click(screen.getByRole("button", { name: /confirm uninstall/i })); + + await vi.waitFor(() => expect(uninstallPluginSpy).toHaveBeenCalledWith(PLUGIN_ID)); + expect(unloadPluginSpy).toHaveBeenCalledWith(PLUGIN_ID); + const removePlugin = storeState.removePlugin as ReturnType; + expect(removePlugin).toHaveBeenCalledWith(PLUGIN_ID); + }); + + it("does not uninstall when the confirmation dialog is cancelled", () => { + setStoreState([activePlugin()]); + + render(); + fireEvent.click(screen.getByRole("button", { name: /uninstall/i })); + fireEvent.click(screen.getByRole("button", { name: /^cancel$/i })); + + expect(uninstallPluginSpy).not.toHaveBeenCalled(); + }); +}); + +describe("PluginsSettingsPage install dialog", () => { + it("installs a plugin from a URL: calls the API, loads the bundle, and updates the store", async () => { + setStoreState([]); + + render(); + fireEvent.click(screen.getByTestId(INSTALL_TRIGGER_TESTID)); + fireEvent.change(screen.getByTestId("install-plugin-url-input"), { + target: { value: NEW_PLUGIN_URL }, + }); + fireEvent.click(screen.getByTestId("install-plugin-url-submit")); + + await vi.waitFor(() => expect(installPluginFromUrlSpy).toHaveBeenCalledWith(NEW_PLUGIN_URL)); + const upsertPlugin = storeState.upsertPlugin as ReturnType; + expect(upsertPlugin).toHaveBeenCalledWith(expect.objectContaining({ id: NEW_PLUGIN_ID })); + await vi.waitFor(() => + expect(loadPluginsSpy).toHaveBeenCalledWith( + [ + expect.objectContaining({ + id: NEW_PLUGIN_ID, + bundleUrl: `/api/plugins/${NEW_PLUGIN_ID}/bundle`, + }), + ], + expect.any(Function), + ), + ); + }); + + it("installs a plugin by uploading a file: calls the API and updates the store", async () => { + setStoreState([]); + const file = new File([new Uint8Array([1, 2, 3])], "new-plugin-1.0.0.tar.gz", { + type: "application/gzip", + }); + + render(); + fireEvent.click(screen.getByTestId(INSTALL_TRIGGER_TESTID)); + // Radix TabsTrigger switches tabs on mousedown, not click (see + // @radix-ui/react-tabs) — fireEvent.click alone never fires it. + fireEvent.mouseDown(screen.getByTestId("install-plugin-tab-upload")); + fireEvent.change(screen.getByTestId("install-plugin-file-input"), { + target: { files: [file] }, + }); + fireEvent.click(screen.getByTestId("install-plugin-upload-submit")); + + await vi.waitFor(() => expect(installPluginUploadSpy).toHaveBeenCalledWith(file)); + const upsertPlugin = storeState.upsertPlugin as ReturnType; + expect(upsertPlugin).toHaveBeenCalledWith(expect.objectContaining({ id: NEW_PLUGIN_ID })); + }); + + it("shows a warning toast (not a bare success toast) when the package installed but failed to start", async () => { + installPluginFromUrlSpy.mockResolvedValueOnce({ + plugin: installedPlugin({ status: "error" }), + warning: "plugin installed but failed to start: handshake timed out", + }); + setStoreState([]); + + render(); + fireEvent.click(screen.getByTestId(INSTALL_TRIGGER_TESTID)); + fireEvent.change(screen.getByTestId("install-plugin-url-input"), { + target: { value: NEW_PLUGIN_URL }, + }); + fireEvent.click(screen.getByTestId("install-plugin-url-submit")); + + await vi.waitFor(() => + expect(toast.warning).toHaveBeenCalledWith( + "plugin installed but failed to start: handshake timed out", + ), + ); + expect(toast.success).not.toHaveBeenCalled(); + const upsertPlugin = storeState.upsertPlugin as ReturnType; + expect(upsertPlugin).toHaveBeenCalledWith( + expect.objectContaining({ id: NEW_PLUGIN_ID, status: "error" }), + ); + }); + + it("shows the backend's error message inline when install fails", async () => { + installPluginFromUrlSpy.mockRejectedValueOnce( + new Error("bad checksum for server/plugin-linux-amd64"), + ); + setStoreState([]); + + render(); + fireEvent.click(screen.getByTestId(INSTALL_TRIGGER_TESTID)); + fireEvent.change(screen.getByTestId("install-plugin-url-input"), { + target: { value: "https://example.test/bad.tar.gz" }, + }); + fireEvent.click(screen.getByTestId("install-plugin-url-submit")); + + await vi.waitFor(() => + expect(screen.getByTestId("install-plugin-error").textContent).toMatch(/bad checksum/), + ); + }); +}); + +describe("PluginsSettingsPage sync button", () => { + it("syncs plugins: calls the API, refreshes the list, and toasts a summary", async () => { + setStoreState([]); + syncPluginsSpy.mockResolvedValueOnce(emptySyncResult({ added: ["sideloaded-plugin"] })); + const callsBeforeClick = listPluginsSpy.mock.calls.length; + + render(); + fireEvent.click(screen.getByTestId(SYNC_BUTTON_TESTID)); + + await vi.waitFor(() => expect(syncPluginsSpy).toHaveBeenCalled()); + await vi.waitFor(() => + expect(listPluginsSpy.mock.calls.length).toBeGreaterThan(callsBeforeClick), + ); + const setPlugins = storeState.setPlugins as ReturnType; + await vi.waitFor(() => expect(setPlugins).toHaveBeenCalledWith([])); + await vi.waitFor(() => expect(toast.success).toHaveBeenCalledWith("Sync: 1 sideloaded")); + }); + + it("toasts 'Everything up to date' when the sync finds nothing to do", async () => { + setStoreState([]); + syncPluginsSpy.mockResolvedValueOnce(emptySyncResult()); + + render(); + fireEvent.click(screen.getByTestId(SYNC_BUTTON_TESTID)); + + await vi.waitFor(() => expect(toast.success).toHaveBeenCalledWith("Everything up to date")); + }); + + it("shows sync errors inline", async () => { + setStoreState([]); + syncPluginsSpy.mockResolvedValueOnce( + emptySyncResult({ + errors: [{ path: "/plugins/junk.tar.gz", reason: "invalid gzip stream" }], + }), + ); + + render(); + expect(screen.queryByTestId("plugins-sync-errors")).toBeNull(); + fireEvent.click(screen.getByTestId(SYNC_BUTTON_TESTID)); + + await vi.waitFor(() => + expect(screen.getByTestId("plugins-sync-errors").textContent).toMatch(/invalid gzip stream/), + ); + }); + + it("toasts an error and does not refresh the list when the sync call fails", async () => { + setStoreState([]); + syncPluginsSpy.mockRejectedValueOnce(new Error("backend unreachable")); + + render(); + // Let the mount-triggered usePlugins() load settle before capturing the + // baseline call count, so it only reflects the click below. + await vi.waitFor(() => expect(listPluginsSpy).toHaveBeenCalled()); + const callsBeforeClick = listPluginsSpy.mock.calls.length; + + fireEvent.click(screen.getByTestId(SYNC_BUTTON_TESTID)); + + await vi.waitFor(() => expect(toast.error).toHaveBeenCalledWith("backend unreachable")); + expect(listPluginsSpy.mock.calls.length).toBe(callsBeforeClick); + }); +}); diff --git a/apps/web/app/settings/plugins/page.tsx b/apps/web/app/settings/plugins/page.tsx new file mode 100644 index 0000000000..768972ea65 --- /dev/null +++ b/apps/web/app/settings/plugins/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { PluginsSettings } from "@/components/settings/plugins/plugins-settings"; +import { useFeature } from "@/hooks/domains/features/use-feature"; + +export default function PluginsSettingsPage() { + const pluginsEnabled = useFeature("plugins"); + if (!pluginsEnabled) return null; + + return ; +} diff --git a/apps/web/components/app-sidebar/app-sidebar.test.tsx b/apps/web/components/app-sidebar/app-sidebar.test.tsx index 45e8bb48b6..75fa59109e 100644 --- a/apps/web/components/app-sidebar/app-sidebar.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar.test.tsx @@ -86,6 +86,10 @@ vi.mock("@/hooks/use-workflows", () => ({ })); const storeState = { + features: { + office: false, + plugins: false, + }, appSidebar: { collapsed: false, sectionExpanded: { diff --git a/apps/web/components/app-sidebar/app-sidebar.tsx b/apps/web/components/app-sidebar/app-sidebar.tsx index 2355b92f9e..f887d3a380 100644 --- a/apps/web/components/app-sidebar/app-sidebar.tsx +++ b/apps/web/components/app-sidebar/app-sidebar.tsx @@ -11,6 +11,7 @@ import { APP_SIDEBAR_EXPANDED_WIDTH, APP_SIDEBAR_SECTION_IDS, } from "./app-sidebar-constants"; +import { PluginNavItems } from "@/components/plugins/plugin-nav-items"; import { AppSidebarFooter } from "./app-sidebar-footer"; import { AppSidebarHeader } from "./app-sidebar-header"; import { AppSidebarPrimaryNav } from "./app-sidebar-primary-nav"; @@ -64,6 +65,7 @@ function AppSidebarNavigation({ collapsed, inOffice, settingsMode }: AppSidebarN data-testid="app-sidebar-scroll" > + {inOffice && } diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx index 29bab57cf6..67efd4a2a3 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx @@ -19,6 +19,10 @@ const state = { executors: { items: [], }, + features: { + office: false, + plugins: false, + }, }; vi.mock("@/components/state-provider", () => ({ diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx index 328ad63344..3e476bd22c 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx @@ -6,8 +6,11 @@ import { IconMessageCircle, IconMicrophone, IconPlugConnected, + IconPuzzle, IconWand, } from "@tabler/icons-react"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { useFeature } from "@/hooks/domains/features/use-feature"; import { AgentsGroup } from "./agents-group"; import { ExecutorsGroup } from "./executors-group"; import { GeneralGroup } from "./general-group"; @@ -20,6 +23,7 @@ const VOICE_MODE_HREF = "/settings/voice-mode"; const UTILITY_HREF = "/settings/utility-agents"; const SECRETS_HREF = "/settings/general/secrets"; const EXT_MCP_HREF = "/settings/external-mcp"; +const PLUGINS_HREF = "/settings/plugins"; const DEFAULT_OPEN_GROUP = "workspaces"; // Single-open accordion: each top-level group owns a route prefix. The group @@ -51,6 +55,7 @@ export function settingsOpenGroupIdForPath(pathname: string): string { * footer gear is active, as the full-height sidebar takeover. */ export function SettingsTree({ pathname }: { pathname: string }) { + const pluginsEnabled = useFeature("plugins"); const [openGroup, setOpenGroup] = useState(() => settingsOpenGroupIdForPath(pathname), ); @@ -104,6 +109,15 @@ export function SettingsTree({ pathname }: { pathname: string }) { isActive={pathname === EXT_MCP_HREF} /> + {pluginsEnabled && ( + + )} + ); } diff --git a/apps/web/components/plugins/plugin-error-boundary.test.tsx b/apps/web/components/plugins/plugin-error-boundary.test.tsx new file mode 100644 index 0000000000..d68fa87bc3 --- /dev/null +++ b/apps/web/components/plugins/plugin-error-boundary.test.tsx @@ -0,0 +1,68 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PluginErrorBoundary } from "./plugin-error-boundary"; + +function Boom(): never { + throw new Error("boom"); +} + +beforeEach(() => { + // React logs caught errors to console.error — silence so test output stays clean. + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("PluginErrorBoundary", () => { + it("renders children when nothing throws", () => { + render( + +
child content
+
, + ); + + expect(screen.getByTestId("child")).not.toBeNull(); + }); + + it("renders nothing when a child throws and no fallback is given", () => { + const { container } = render( + + + , + ); + + expect(container.innerHTML).toBe(""); + }); + + it("renders the given fallback when a child throws", () => { + render( + This plugin page failed to load} + > + + , + ); + + expect(screen.getByTestId("fallback")).not.toBeNull(); + }); + + it("logs the failure with the provided context so a specific plugin/route/slot is identifiable", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + render( + + + , + ); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('route "/plugins/hello"'), + expect.any(Error), + expect.anything(), + ); + }); +}); diff --git a/apps/web/components/plugins/plugin-error-boundary.tsx b/apps/web/components/plugins/plugin-error-boundary.tsx new file mode 100644 index 0000000000..0862802f78 --- /dev/null +++ b/apps/web/components/plugins/plugin-error-boundary.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Component, type ErrorInfo, type ReactNode } from "react"; + +type PluginErrorBoundaryProps = { + /** Identifies what threw in the console log, e.g. `slot "task-sidebar"` or `route "/plugins/hello"`. */ + context: string; + /** Rendered in place of `children` after a throw. Defaults to nothing (silent). */ + fallback?: ReactNode; + children: ReactNode; +}; + +type PluginErrorBoundaryState = { + error: Error | null; +}; + +/** + * Isolates plugin-owned React output (a slot component, a top-level plugin + * route, or a plugin settings route) so a throw anywhere in one plugin's + * render can't tear down the host shell or other plugins — plugin code runs + * in-process (see PLUGIN-API.md security posture). + */ +export class PluginErrorBoundary extends Component< + PluginErrorBoundaryProps, + PluginErrorBoundaryState +> { + state: PluginErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): PluginErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error(`[plugins] ${this.props.context} threw`, error, info.componentStack); + } + + render(): ReactNode { + if (this.state.error) return this.props.fallback ?? null; + return this.props.children; + } +} + +/** Shared fallback for a plugin-owned top-level or settings route that threw during render. */ +export function PluginRouteFallback() { + return ( +
+ This plugin page failed to load. +
+ ); +} diff --git a/apps/web/components/plugins/plugin-nav-items.test.tsx b/apps/web/components/plugins/plugin-nav-items.test.tsx new file mode 100644 index 0000000000..8a4e4642d7 --- /dev/null +++ b/apps/web/components/plugins/plugin-nav-items.test.tsx @@ -0,0 +1,90 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { pluginRegistry } from "@/lib/plugins/registry"; +import { PluginNavItems } from "./plugin-nav-items"; + +let pathname = "/"; +let pluginsEnabled = true; +const HELLO_PATH = "/plugins/hello"; + +function cleanupPlugins(...pluginIds: string[]) { + pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id)); +} + +vi.mock("@/lib/routing/client-router", () => ({ + usePathname: () => pathname, +})); + +vi.mock("@/hooks/domains/features/use-feature", () => ({ + useFeature: () => pluginsEnabled, +})); + +function renderNavItems(collapsed = false) { + return render( + + + , + ); +} + +describe("PluginNavItems", () => { + afterEach(() => { + cleanup(); + cleanupPlugins("plugin-a", "plugin-b"); + pathname = "/"; + pluginsEnabled = true; + window.history.pushState({}, "", "/"); + }); + + it("renders nothing when no plugin has registered a nav item", () => { + const { container } = renderNavItems(); + expect(container.innerHTML).toBe(""); + }); + + it("renders a registered main-section nav item", () => { + pluginRegistry + .forPlugin("plugin-a") + .registerNavItem({ id: "hello", label: "Hello", path: HELLO_PATH }); + + renderNavItems(); + + expect(screen.getByTestId("plugin-nav-item-hello")).not.toBeNull(); + expect(screen.getByText("Hello")).not.toBeNull(); + }); + + it("omits a nav item registered for a non-main section", () => { + pluginRegistry.forPlugin("plugin-a").registerNavItem({ + id: "settings-item", + label: "Settings Item", + path: "/settings/plugins/plugin-a", + section: "settings", + }); + + renderNavItems(); + + expect(screen.queryByTestId("plugin-nav-item-settings-item")).toBeNull(); + }); + + it("renders nothing when the plugins feature flag is off, even with a registered nav item", () => { + pluginsEnabled = false; + pluginRegistry + .forPlugin("plugin-a") + .registerNavItem({ id: "hello", label: "Hello", path: HELLO_PATH }); + + const { container } = renderNavItems(); + + expect(container.innerHTML).toBe(""); + }); + + it("navigates to item.path when clicked", () => { + pluginRegistry + .forPlugin("plugin-a") + .registerNavItem({ id: "hello", label: "Hello", path: HELLO_PATH }); + + renderNavItems(); + screen.getByTestId("plugin-nav-item-hello").click(); + + expect(window.location.pathname).toBe(HELLO_PATH); + }); +}); diff --git a/apps/web/components/plugins/plugin-nav-items.tsx b/apps/web/components/plugins/plugin-nav-items.tsx new file mode 100644 index 0000000000..ed2b0caee3 --- /dev/null +++ b/apps/web/components/plugins/plugin-nav-items.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { IconPuzzle } from "@tabler/icons-react"; +import { AppSidebarNavItem } from "@/components/app-sidebar/app-sidebar-nav-item"; +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { usePluginRegistry } from "@/lib/plugins/registry"; + +type PluginNavItemsProps = { + collapsed: boolean; +}; + +/** + * Renders every plugin-registered "main" section nav item + * (`registry.registerNavItem(item)`) in the app sidebar, styled and behaving + * like a first-party `AppSidebarNavItem`. Plugin nav items don't carry a + * host icon component (only an opaque `icon` name string in the frozen + * contract), so every entry uses a generic puzzle-piece glyph. Gated on the + * "plugins" feature flag for consistency with the other plugin surfaces + * (settings nav entry, /settings/plugins page) even though the registry is + * empty and this already renders nothing when the flag is off. + */ +export function PluginNavItems({ collapsed }: PluginNavItemsProps) { + const pluginsEnabled = useFeature("plugins"); + const registry = usePluginRegistry(); + const items = registry.getNavItems().filter((item) => (item.section ?? "main") === "main"); + + if (!pluginsEnabled) return null; + + return ( + <> + {items.map((item) => ( + + ))} + + ); +} diff --git a/apps/web/components/plugins/plugin-slot.test.tsx b/apps/web/components/plugins/plugin-slot.test.tsx new file mode 100644 index 0000000000..7e6afc1d4d --- /dev/null +++ b/apps/web/components/plugins/plugin-slot.test.tsx @@ -0,0 +1,75 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { pluginRegistry } from "@/lib/plugins/registry"; +import { PluginSlot } from "./plugin-slot"; + +const SLOT = "task-sidebar"; + +function cleanupPlugins(...pluginIds: string[]) { + pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id)); +} + +describe("PluginSlot", () => { + afterEach(() => { + cleanup(); + cleanupPlugins("plugin-a", "plugin-b"); + }); + + it("renders nothing when no plugin has registered a component for the slot", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("renders every component registered for the named slot", () => { + pluginRegistry + .forPlugin("plugin-a") + .registerComponent(SLOT, () =>
A
); + pluginRegistry + .forPlugin("plugin-b") + .registerComponent(SLOT, () =>
B
); + + render(); + + expect(screen.getByTestId("slot-a")).not.toBeNull(); + expect(screen.getByTestId("slot-b")).not.toBeNull(); + }); + + it("does not render a component registered for a different slot", () => { + pluginRegistry + .forPlugin("plugin-a") + .registerComponent("settings-nav", () =>
A
); + + render(); + + expect(screen.queryByTestId("slot-a")).toBeNull(); + }); + + it("passes slotProps through to each registered component", () => { + pluginRegistry + .forPlugin("plugin-a") + .registerComponent(SLOT, ({ slotProps }) => ( +
{String((slotProps as { label: string })?.label)}
+ )); + + render(); + + expect(screen.getByTestId("slot-a").textContent).toBe("hello"); + }); + + it("isolates a throwing slot component so a sibling still renders", () => { + // eslint-disable-next-line no-console -- expected error boundary log, assert + silence it + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, () => { + throw new Error("boom"); + }); + pluginRegistry + .forPlugin("plugin-b") + .registerComponent(SLOT, () =>
B
); + + render(); + + expect(screen.getByTestId("slot-b")).not.toBeNull(); + consoleError.mockRestore(); + }); +}); diff --git a/apps/web/components/plugins/plugin-slot.tsx b/apps/web/components/plugins/plugin-slot.tsx new file mode 100644 index 0000000000..a7e8893035 --- /dev/null +++ b/apps/web/components/plugins/plugin-slot.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { usePluginRegistry } from "@/lib/plugins/registry"; +import type { SlotComponent } from "@/lib/plugins/types"; +import { PluginErrorBoundary } from "./plugin-error-boundary"; + +export type PluginSlotProps = { + /** Named slot to render — see PLUGIN-API.md for the initial set of slot names. */ + name: string; + /** Forwarded to each registered component as `slotProps`. */ + slotProps?: unknown; +}; + +/** + * Renders every plugin component registered for the named slot + * (`registry.registerComponent(name, Component)`), each isolated behind its + * own error boundary so one broken plugin can't break the host surface. + */ +export function PluginSlot({ name, slotProps }: PluginSlotProps) { + const registry = usePluginRegistry(); + const components = registry.getSlotComponents(name); + + if (components.length === 0) return null; + + return ( + <> + {components.map((SlotComponentImpl: SlotComponent, index) => ( + + + + ))} + + ); +} diff --git a/apps/web/components/settings/plugins/install-plugin-dialog.tsx b/apps/web/components/settings/plugins/install-plugin-dialog.tsx new file mode 100644 index 0000000000..4d0294e4a3 --- /dev/null +++ b/apps/web/components/settings/plugins/install-plugin-dialog.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Button } from "@kandev/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs"; +import { cn } from "@/lib/utils"; + +type InstallTab = "url" | "upload"; + +type InstallPluginDialogProps = { + open: boolean; + busy: boolean; + error: string | null; + onOpenChange: (open: boolean) => void; + onSubmitUrl: (url: string) => void; + onSubmitFile: (file: File) => void; +}; + +/** + * Replaces the old "Register plugin" manifest-paste dialog with the + * install-based flow (docs/plans/plugins/GRPC-CONTRACT.md §7): install from a + * remote tarball URL, or upload a .tar.gz package directly. No credentials + * are ever shown — installing a plugin has nothing to copy or reveal. + */ +export function InstallPluginDialog({ + open, + busy, + error, + onOpenChange, + onSubmitUrl, + onSubmitFile, +}: InstallPluginDialogProps) { + const [tab, setTab] = useState("url"); + const [url, setUrl] = useState(""); + + const handleOpenChange = (next: boolean) => { + if (!next) { + setUrl(""); + setTab("url"); + } + onOpenChange(next); + }; + + return ( + + + + Install plugin + + Install a plugin package from a URL, or upload a .tar.gz file directly. + + + + setTab(v as InstallTab)}> + + + From URL + + + Upload file + + + + + + + + + + + {error && ( +
+ {error} +
+ )} + + + + +
+
+ ); +} + +function UrlTab({ + url, + setUrl, + busy, + onSubmit, +}: { + url: string; + setUrl: (v: string) => void; + busy: boolean; + onSubmit: (url: string) => void; +}) { + return ( +
+
+ + setUrl(e.target.value)} + placeholder="https://example.com/acme-tools-1.0.0.tar.gz" + className="font-mono text-sm" + /> +
+ +
+ ); +} + +function UploadTab({ busy, onSubmit }: { busy: boolean; onSubmit: (file: File) => void }) { + const inputRef = useRef(null); + const [file, setFile] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.types.includes("Files")) setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const dropped = e.dataTransfer.files[0]; + if (dropped) setFile(dropped); + }; + + return ( +
+
inputRef.current?.click()} + className={cn( + "flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground cursor-pointer", + isDragging && "border-primary bg-primary/5 ring-1 ring-primary/30", + )} + > + + {file ? ( + {file.name} + ) : ( + "Drag and drop a .tar.gz package here, or click to browse" + )} + + setFile(e.target.files?.[0] ?? null)} + className="hidden" + /> +
+ +
+ ); +} diff --git a/apps/web/components/settings/plugins/plugin-row.tsx b/apps/web/components/settings/plugins/plugin-row.tsx new file mode 100644 index 0000000000..5a05062a90 --- /dev/null +++ b/apps/web/components/settings/plugins/plugin-row.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { PluginStatusBadge } from "./plugin-status-badge"; +import type { PluginRecord } from "@/lib/types/plugins"; + +type PluginRowProps = { + plugin: PluginRecord; + busy: boolean; + onEnable: (plugin: PluginRecord) => void; + onDisable: (plugin: PluginRecord) => void; + onUninstall: (plugin: PluginRecord) => void; +}; + +/** + * One plugin's row. Div-based (not a ``) so it wraps/stacks naturally + * on narrow viewports and inside the mobile settings sheet — no separate + * mobile layout needed. + */ +export function PluginRow({ plugin, busy, onEnable, onDisable, onUninstall }: PluginRowProps) { + const canEnable = plugin.status === "disabled" || plugin.status === "registered"; + const canDisable = plugin.status === "active" || plugin.status === "error"; + + return ( +
+
+
+
+ + {plugin.display_name} + + + {plugin.signed === false && ( + + unsigned + + )} +
+
+ {plugin.id} · v{plugin.version} +
+ {plugin.description && ( +
{plugin.description}
+ )} + {plugin.categories.length > 0 && ( +
+ {plugin.categories.map((category) => ( + + {category} + + ))} +
+ )} +
+ +
+ {canEnable && ( + + )} + {canDisable && ( + + )} + +
+
+
+ ); +} diff --git a/apps/web/components/settings/plugins/plugin-status-badge.tsx b/apps/web/components/settings/plugins/plugin-status-badge.tsx new file mode 100644 index 0000000000..e57638b4b0 --- /dev/null +++ b/apps/web/components/settings/plugins/plugin-status-badge.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Badge } from "@kandev/ui/badge"; +import type { PluginStatus } from "@/lib/types/plugins"; + +const STATUS_LABEL: Record = { + active: "Active", + error: "Error", + disabled: "Disabled", + registered: "Registered", + uninstalled: "Uninstalled", +}; + +// green=active, red=error, gray=disabled, amber=registered, per task-20 acceptance. +const STATUS_CLASS: Record = { + active: "border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-400", + error: "border-red-500/40 bg-red-500/10 text-red-600 dark:text-red-400", + disabled: "border-border bg-muted text-muted-foreground", + registered: "border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400", + uninstalled: "border-border bg-muted text-muted-foreground", +}; + +export function PluginStatusBadge({ status }: { status: PluginStatus }) { + return ( + + {STATUS_LABEL[status]} + + ); +} diff --git a/apps/web/components/settings/plugins/plugins-settings.tsx b/apps/web/components/settings/plugins/plugins-settings.tsx new file mode 100644 index 0000000000..5f80fb880f --- /dev/null +++ b/apps/web/components/settings/plugins/plugins-settings.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { IconRefresh } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { SettingsPageTemplate } from "@/components/settings/settings-page-template"; +import { usePlugins } from "@/hooks/domains/plugins/use-plugins"; +import { InstallPluginDialog } from "./install-plugin-dialog"; +import { PluginRow } from "./plugin-row"; +import { UninstallPluginDialog } from "./uninstall-plugin-dialog"; +import { usePluginActions } from "./use-plugin-actions"; + +/** + * Operator UI to install/list/enable/disable/uninstall plugins + * (docs/plans/plugins/task-20-mgmt-page.md, reworked for the install-based + * flow in docs/plans/plugins/GRPC-CONTRACT.md §7). Gated on the `plugins` + * feature flag by the page-level default export. + */ +export function PluginsSettings() { + const { items, loaded, loading, error } = usePlugins(); + const actions = usePluginActions(); + + return ( + undefined} + showSaveButton={false} + > +
+
+
Installed plugins
+
+ + +
+
+ + {actions.syncErrors.length > 0 && ( +
+ {actions.syncErrors.map((err) => ( +
+ {err.path}: {err.reason} +
+ ))} +
+ )} + + +
+ + + +
+ ); +} + +type PluginListProps = { + items: ReturnType["items"]; + loaded: boolean; + loading: boolean; + error: string | null; + actions: ReturnType; +}; + +function PluginList({ items, loaded, loading, error, actions }: PluginListProps) { + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!loaded && loading) { + return ( +
+ Loading plugins... +
+ ); + } + + if (loaded && items.length === 0) { + return ( +
+ No plugins yet. Install a plugin package to get started. +
+ ); + } + + return ( +
+ {items.map((plugin) => ( + + ))} +
+ ); +} diff --git a/apps/web/components/settings/plugins/uninstall-plugin-dialog.tsx b/apps/web/components/settings/plugins/uninstall-plugin-dialog.tsx new file mode 100644 index 0000000000..de32953da7 --- /dev/null +++ b/apps/web/components/settings/plugins/uninstall-plugin-dialog.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { Button } from "@kandev/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import type { PluginRecord } from "@/lib/types/plugins"; + +type UninstallPluginDialogProps = { + target: PluginRecord | null; + busy: boolean; + onClose: () => void; + onConfirm: () => void; +}; + +export function UninstallPluginDialog({ + target, + busy, + onClose, + onConfirm, +}: UninstallPluginDialogProps) { + return ( + { + if (!open) onClose(); + }} + > + + + Uninstall plugin + + This will permanently remove{" "} + + {target?.display_name ?? "this plugin"} + {" "} + and revoke its API key. This action cannot be undone. + + + + + + + + + ); +} diff --git a/apps/web/components/settings/plugins/use-plugin-actions.ts b/apps/web/components/settings/plugins/use-plugin-actions.ts new file mode 100644 index 0000000000..4506fd068b --- /dev/null +++ b/apps/web/components/settings/plugins/use-plugin-actions.ts @@ -0,0 +1,216 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import type { StoreApi } from "zustand"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useTheme } from "@/components/theme/app-theme"; +import { + disablePlugin, + enablePlugin, + installPluginFromUrl, + installPluginUpload, + listPlugins, + syncPlugins, + uninstallPlugin, +} from "@/lib/api/domains/plugins-api"; +import { toActivePlugin } from "@/lib/plugins/active-plugin"; +import { buildHostApi } from "@/lib/plugins/host-api"; +import { loadPlugins, unloadPlugin } from "@/lib/plugins/host"; +import { summarizeSyncResult } from "@/lib/plugins/sync-summary"; +import type { InstallResult } from "@/lib/api/domains/plugins-api"; +import type { PluginRecord, PluginStatus, SyncError } from "@/lib/types/plugins"; +import type { AppState } from "@/lib/state/store"; + +function withStatus(plugin: PluginRecord, status: PluginStatus): PluginRecord { + return { ...plugin, status }; +} + +/** Loads a plugin's UI bundle into the running app, if it declares one. */ +async function loadIfActive( + record: PluginRecord, + storeApi: StoreApi, + theme: "light" | "dark", +) { + if (record.status !== "active") return; + const active = toActivePlugin(record); + if (!active) return; + await loadPlugins([active], (pluginId) => buildHostApi(pluginId, storeApi, theme)); +} + +/** + * Enable/disable action wiring, per task-20's output contract: enabling a + * plugin with a UI bundle calls the task-18 runtime `loadPlugins` for just + * that plugin (no full page reload); disabling calls `unloadPlugin` to + * revoke its nav items/routes/slots immediately. + */ +function useEnableDisableActions(upsertPlugin: (p: PluginRecord) => void) { + const storeApi = useAppStoreApi(); + const { resolvedTheme } = useTheme(); + const [busyId, setBusyId] = useState(null); + + const handleEnable = async (plugin: PluginRecord) => { + setBusyId(plugin.id); + try { + await enablePlugin(plugin.id); + const updated = withStatus(plugin, "active"); + upsertPlugin(updated); + await loadIfActive(updated, storeApi, resolvedTheme); + } catch (err) { + toast.error(err instanceof Error ? err.message : `Failed to enable ${plugin.display_name}`); + } finally { + setBusyId(null); + } + }; + + const handleDisable = async (plugin: PluginRecord) => { + setBusyId(plugin.id); + try { + await disablePlugin(plugin.id); + unloadPlugin(plugin.id); + upsertPlugin(withStatus(plugin, "disabled")); + } catch (err) { + toast.error(err instanceof Error ? err.message : `Failed to disable ${plugin.display_name}`); + } finally { + setBusyId(null); + } + }; + + return { busyId, handleEnable, handleDisable }; +} + +function useUninstallAction(removePlugin: (id: string) => void) { + const [uninstallTarget, setUninstallTarget] = useState(null); + const [uninstallBusy, setUninstallBusy] = useState(false); + + const confirmUninstall = async () => { + if (!uninstallTarget) return; + const target = uninstallTarget; + setUninstallBusy(true); + try { + await uninstallPlugin(target.id); + unloadPlugin(target.id); + removePlugin(target.id); + setUninstallTarget(null); + } catch (err) { + toast.error( + err instanceof Error ? err.message : `Failed to uninstall ${target.display_name}`, + ); + } finally { + setUninstallBusy(false); + } + }; + + return { + uninstallTarget, + uninstallBusy, + openUninstall: setUninstallTarget, + closeUninstall: () => setUninstallTarget(null), + confirmUninstall, + }; +} + +/** + * Install-plugin dialog wiring. Install-from-URL and upload share the same + * open/busy/error state and post-install effect: upsert the record into the + * store, and if the backend already brought it up `active` with a UI + * bundle, hot-load it into the running app (no full page reload) — same as + * the enable path. + */ +function useInstallAction(upsertPlugin: (p: PluginRecord) => void) { + const storeApi = useAppStoreApi(); + const { resolvedTheme } = useTheme(); + const [installOpen, setInstallOpenState] = useState(false); + const [installBusy, setInstallBusy] = useState(false); + const [installError, setInstallError] = useState(null); + + const closeInstallDialog = () => { + setInstallOpenState(false); + setInstallError(null); + }; + + // A partial-install warning (package installed but failed to spawn — the + // backend leaves Plugin.Status "error") must not be masked by a green + // "installed" toast, so it takes priority over the success toast. + const afterInstall = async ({ plugin, warning }: InstallResult) => { + upsertPlugin(plugin); + await loadIfActive(plugin, storeApi, resolvedTheme); + if (warning) { + toast.warning(warning); + } else { + toast.success(`${plugin.display_name} installed`); + } + closeInstallDialog(); + }; + + const runInstall = async (install: () => Promise) => { + setInstallBusy(true); + setInstallError(null); + try { + const result = await install(); + await afterInstall(result); + } catch (err) { + setInstallError(err instanceof Error ? err.message : "Failed to install plugin"); + } finally { + setInstallBusy(false); + } + }; + + return { + installOpen, + openInstall: () => setInstallOpenState(true), + setInstallOpen: (open: boolean) => (open ? setInstallOpenState(true) : closeInstallDialog()), + installBusy, + installError, + submitInstallUrl: (url: string) => runInstall(() => installPluginFromUrl(url)), + submitInstallFile: (file: File) => runInstall(() => installPluginUpload(file)), + closeInstallDialog, + }; +} + +/** + * Sync-button wiring: POST /api/plugins/sync, then refresh the plugin list + * via the same GET /api/plugins call usePlugins itself makes on mount, and + * summarize the result as a toast. result.errors are kept in state for an + * inline `plugins-sync-errors` region rather than only living in the toast, + * so they stay visible without depending on toast auto-dismiss timing. + * + * A sync can bring a dropped tarball plugin all the way to `active` with a + * UI bundle, but (unlike install/enable) this does not hot-load it — an + * operator can re-enable it (or reload) to pick up the bundle; wiring a + * silent hot-load here is out of scope for the sync button itself. + */ +function useSyncAction(setPlugins: (plugins: PluginRecord[]) => void) { + const [syncBusy, setSyncBusy] = useState(false); + const [syncErrors, setSyncErrors] = useState([]); + + const handleSync = async () => { + setSyncBusy(true); + try { + const result = await syncPlugins(); + const refreshed = await listPlugins({ cache: "no-store" }); + setPlugins(refreshed); + setSyncErrors(result.errors ?? []); + toast.success(summarizeSyncResult(result)); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to sync plugins"); + } finally { + setSyncBusy(false); + } + }; + + return { syncBusy, syncErrors, handleSync }; +} + +export function usePluginActions() { + const upsertPlugin = useAppStore((s) => s.upsertPlugin); + const removePlugin = useAppStore((s) => s.removePlugin); + const setPlugins = useAppStore((s) => s.setPlugins); + + const enableDisable = useEnableDisableActions(upsertPlugin); + const uninstall = useUninstallAction(removePlugin); + const install = useInstallAction(upsertPlugin); + const sync = useSyncAction(setPlugins); + + return { ...enableDisable, ...uninstall, ...install, ...sync }; +} diff --git a/apps/web/components/task/task-session-sidebar-switcher-props.ts b/apps/web/components/task/task-session-sidebar-switcher-props.ts new file mode 100644 index 0000000000..b41c60f586 --- /dev/null +++ b/apps/web/components/task/task-session-sidebar-switcher-props.ts @@ -0,0 +1,59 @@ +import type { ComponentProps } from "react"; +import type { TaskSwitcher } from "./task-switcher"; +import type { useSidebarActions } from "./task-session-sidebar"; +import type { useSidebarTaskLinking } from "./task-session-sidebar-task-linking"; +import type { useSidebarSelection } from "./task-session-sidebar-selection"; + +type TaskSwitcherComponentProps = ComponentProps; + +/** + * Maps the sidebar's assembled hook state onto `TaskSwitcher`'s prop names. + * Pulled out of `TaskSessionSidebar` so the component body stays focused on + * hook orchestration rather than prop plumbing. + */ +export function buildTaskSwitcherProps(args: { + grouped: TaskSwitcherComponentProps["grouped"]; + workflows: TaskSwitcherComponentProps["workflows"]; + stepsByWorkflowId: TaskSwitcherComponentProps["stepsByWorkflowId"]; + highlightedTaskId: string | null; + highlightedSelectedTaskId: string | null; + effectiveView: { collapsedGroups: TaskSwitcherComponentProps["collapsedGroupKeys"] }; + handleToggleGroup: TaskSwitcherComponentProps["onToggleGroup"]; + collapsedSubtaskParents: TaskSwitcherComponentProps["collapsedSubtaskParentIds"]; + toggleSubtaskCollapsed: TaskSwitcherComponentProps["onToggleSubtasks"]; + sidebarActions: ReturnType; + taskLinkHandlers: ReturnType; + pinnedTaskIds: TaskSwitcherComponentProps["pinnedTaskIds"]; + togglePinnedTask: TaskSwitcherComponentProps["onTogglePin"]; + handleReorderGroup: TaskSwitcherComponentProps["onReorderGroup"]; + handleReorderSubtasks: TaskSwitcherComponentProps["onReorderSubtasks"]; + isLoadingWorkflow: boolean; + totalTaskCount: number; + selection: ReturnType; +}): TaskSwitcherComponentProps { + return { + grouped: args.grouped, + workflows: args.workflows, + stepsByWorkflowId: args.stepsByWorkflowId, + activeTaskId: args.highlightedTaskId, + selectedTaskId: args.highlightedSelectedTaskId, + collapsedGroupKeys: args.effectiveView.collapsedGroups, + onToggleGroup: args.handleToggleGroup, + collapsedSubtaskParentIds: args.collapsedSubtaskParents, + onToggleSubtasks: args.toggleSubtaskCollapsed, + onSelectTask: args.sidebarActions.handleSelectTask, + onRenameTask: args.sidebarActions.handleRenameTask, + onArchiveTask: args.sidebarActions.handleArchiveTask, + onDeleteTask: args.sidebarActions.handleDeleteTask, + ...args.taskLinkHandlers, + onMoveToStep: args.sidebarActions.handleMoveToStep, + onTogglePin: args.togglePinnedTask, + onReorderGroup: args.handleReorderGroup, + onReorderSubtasks: args.handleReorderSubtasks, + pinnedTaskIds: args.pinnedTaskIds, + deletingTaskId: args.sidebarActions.deletingTaskId, + isLoading: args.isLoadingWorkflow, + totalTaskCount: args.totalTaskCount, + ...args.selection.switcherProps, + }; +} diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 7ec4b149b7..76a240f72c 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -7,7 +7,9 @@ import type { Repository, TaskSession, TaskSessionState, TaskState } from "@/lib import type { TaskPR } from "@/lib/types/github"; import type { KanbanState } from "@/lib/state/slices"; import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher"; +import { buildTaskSwitcherProps } from "./task-session-sidebar-switcher-props"; import { SidebarFilterBar } from "./sidebar-filter/sidebar-filter-bar"; import { MOCK_ITEMS, MOCK_SIDEBAR } from "./sidebar-mock-data"; import { SidebarDialogs } from "./task-session-sidebar-dialogs"; @@ -565,15 +567,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ useBulkGitStatusSubscription(primarySessionIds); const sidebarActions = useSidebarActions(store); - const { - deletingTaskId, - preparingTaskId, - handleSelectTask, - handleArchiveTask, - handleDeleteTask, - handleMoveToStep, - handleRenameTask, - } = sidebarActions; + const { preparingTaskId } = sidebarActions; const taskLinkHandlers = useSidebarTaskLinking(workspaceId, sidebarActions); const repositories = useAppStore((state) => @@ -605,35 +599,32 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ (groupKey: string) => toggleSidebarGroupCollapsed(effectiveView.id, groupKey), [toggleSidebarGroupCollapsed, effectiveView.id], ); + const switcherProps = buildTaskSwitcherProps({ + grouped, + workflows, + stepsByWorkflowId, + highlightedTaskId, + highlightedSelectedTaskId, + effectiveView, + handleToggleGroup, + collapsedSubtaskParents, + toggleSubtaskCollapsed, + sidebarActions, + taskLinkHandlers, + pinnedTaskIds, + togglePinnedTask, + handleReorderGroup, + handleReorderSubtasks, + isLoadingWorkflow, + totalTaskCount: displayTasks.length, + selection, + }); return ( {!hideFilterBar && } - + + /dev/null || die "backend build failed" } +# Packages the plugin-fixture SDK plugin (cmd/plugin-fixture) into +# .build/kandev-plugin-e2e-1.0.0.tar.gz for tests/plugins/plugins.spec.ts. +# Always built on the host — like mock-agent, e2e/global-setup.ts only checks +# this file exists (not that it's current), so it must be rebuilt any time +# cmd/plugin-fixture changes (this runner always rebuilds it via build_fe's +# sibling call below, unless --no-build is set). +build_plugin_package() { + log "packaging e2e fixture plugin" + make -C "$BACKEND_DIR" e2e-plugin-package >/dev/null || die "e2e plugin package build failed" +} + backend_runs_in() { # $1=image — does the host-built binary run there? (glibc check, ~2s) # Mount to a distinct path, not /bin — overlaying /bin would shadow the # container's system utilities (/bin/sh etc.). @@ -157,7 +168,7 @@ STRICT_ENV=() # HOST mode # --------------------------------------------------------------------------- run_host() { - [[ "$DO_BUILD" == 1 ]] && { build_backend_host; build_fe; } + [[ "$DO_BUILD" == 1 ]] && { build_backend_host; build_fe; build_plugin_package; } local base_args=(playwright test --config e2e/playwright.config.ts --project="$PROJECT") if [[ "$SHARDS" -le 1 ]]; then ( cd "$WEB_DIR" && env ${STRICT_ENV[@]+"${STRICT_ENV[@]}"} pnpm exec "${base_args[@]}" ${PW_ARGS[@]+"${PW_ARGS[@]}"} ) @@ -187,7 +198,7 @@ run_docker() { local img; img="$(resolve_runtime_image)" log "runtime image: $img" clean_artifacts - [[ "$DO_BUILD" == 1 ]] && { build_backend_for_docker "$img"; build_fe; } + [[ "$DO_BUILD" == 1 ]] && { build_backend_for_docker "$img"; build_fe; build_plugin_package; } local strict_flag=() [[ "$STRICT" == 1 ]] && strict_flag=(-e KANDEV_E2E_WS_ASSERT=1) diff --git a/apps/web/e2e/tests/plugins/plugins.spec.ts b/apps/web/e2e/tests/plugins/plugins.spec.ts new file mode 100644 index 0000000000..3bb694cb53 --- /dev/null +++ b/apps/web/e2e/tests/plugins/plugins.spec.ts @@ -0,0 +1,265 @@ +/** + * E2E: the real kandev gRPC plugin system, end to end. + * + * Supersedes the old HTTP+HMAC "native JS plugin" spec (docs/plans/plugins/ + * GRPC-CONTRACT.md froze the new transport: plugin backends are Go binaries + * spawned by kandev via hashicorp/go-plugin, talking gRPC over a unix + * socket — no HTTP server, no webhook_secret, no in-process Node fixture). + * + * The "plugin process" here is the real `plugin-fixture` Go binary + * (apps/backend/cmd/plugin-fixture), packaged by `make -C apps/backend + * e2e-plugin-package` into apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz + * (id `kandev-plugin-e2e`). `e2e/global-setup.ts` checks that file exists + * before the suite runs (same pattern as the kandev/mock-agent binary + * checks) — see that file and the Makefile's `build-e2e-plugin-package` / + * `e2e-plugin-package` targets for how to (re)build it. + * + * Flow: + * 1. Install the package through the real Settings > Plugins upload UI + * (POST /api/plugins/install, multipart) — the backend extracts it to + * the worker's isolated `/plugins/kandev-plugin-e2e/` + * and spawns it synchronously, so it comes back `active` in the same + * response. No signature was attached, so the unsigned badge shows. + * 2. Reload the SPA — the boot payload now carries the active plugin — and + * confirm its nav item, top-level route, and `task-sidebar` slot render + * via the real `/api/plugins/:id/bundle` static-file proxy. + * 3. Create a task while the plugin's own page stays mounted (no + * navigation in between) and prove BOTH real gRPC paths at once: + * - task.created -> Deliverer -> plugin subprocess OnEvent RPC, + * which appends a deliveries.jsonl line under the plugin's real + * KANDEV_PLUGIN_DATA_DIR (polled directly off disk — the strongest + * evidence that a delivery crossed the real transport, not a mock). + * - task.created -> WS -> registry.registerWsHandler -> the page's + * own live counter. + * 4. Disable/enable from the UI: nav item disappears/reappears live (no + * reload — registry unregister/re-load). + * 5. Uninstall (with confirmation): the row disappears and the plugin's + * directory tree is removed from disk (process stopped, package + * extraction cleaned up). + * 6. Separately, uploading a corrupted (non-gzip) package surfaces + * `install-plugin-error` instead of silently failing. + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Page } from "@playwright/test"; +import { expect, test } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import type { ApiClient } from "../../helpers/api-client"; + +const PLUGIN_ID = "kandev-plugin-e2e"; +const NAV_ITEM_ID = "e2e-hello"; +const PLUGIN_ROUTE = "/plugins/e2e-hello"; + +const PACKAGE_PATH = path.resolve( + __dirname, + "../../../../../apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz", +); + +/** Every deliveries.jsonl `event_type` recorded so far, read straight off + * disk from the plugin's real KANDEV_PLUGIN_DATA_DIR (no in-process mock — + * this is the fixture Go binary's own gRPC OnEvent handler writing to its + * data dir, per apps/backend/cmd/plugin-fixture/plugin.go). */ +function deliveredEventTypes(pluginsDir: string): string[] { + const deliveriesPath = path.join(pluginsDir, PLUGIN_ID, "data", "deliveries.jsonl"); + if (!fs.existsSync(deliveriesPath)) return []; + return fs + .readFileSync(deliveriesPath, "utf8") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => (JSON.parse(line) as { event_type: string }).event_type); +} + +async function openInstallDialog(page: Page) { + await page.goto("/settings/plugins"); + await page.getByTestId("install-plugin-trigger").click(); + await expect(page.getByTestId("install-plugin-dialog")).toBeVisible(); +} + +async function uploadPackage(page: Page, filePath: string) { + await page.getByTestId("install-plugin-tab-upload").click(); + await page.getByTestId("install-plugin-file-input").setInputFiles(filePath); + await page.getByTestId("install-plugin-upload-submit").click(); +} + +async function uninstallViaApi(apiClient: ApiClient) { + await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); +} + +/** + * Stages a filesystem sideload: extracts the same fixture package the + * upload tests use directly into `///`, with no + * `{id}.yml` record — the on-disk shape `Service.Sync`'s directory-sideload + * step looks for (docs/specs/plugins/spec.md "Filesystem sideloading & + * sync"). `checksums.txt` lands in the directory alongside the rest of the + * package; the sideload path ignores it (only the tarball-install path + * verifies checksums). + */ +function stageDirSideload(pluginsDir: string): string { + const versionDir = path.join(pluginsDir, PLUGIN_ID, "1.0.0"); + fs.mkdirSync(versionDir, { recursive: true }); + execFileSync("tar", ["-xzf", PACKAGE_PATH, "-C", versionDir]); + return versionDir; +} + +test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () => { + // Repeat-each safety: the plugin id is fixed, and Install rejects a + // duplicate / with pkgtar.ErrVersionExists (409). Whether the + // test's own UI-driven uninstall ran or not, always clean up via the API + // so the next iteration starts from a clean slate. + test.afterEach(async ({ apiClient }) => { + await uninstallViaApi(apiClient); + }); + + test("installs via upload, loads the UI, live-updates via WS+gRPC, and uninstalls", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(90_000); + + const pluginsDir = path.join(backend.tmpDir, ".kandev", "plugins"); + + // --- 1. Install via the real upload UI --- + await openInstallDialog(testPage); + await uploadPackage(testPage, PACKAGE_PATH); + + const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`); + await expect(pluginRow).toBeVisible({ timeout: 15_000 }); + await expect(pluginRow.getByText("Active", { exact: true })).toBeVisible(); + await expect(pluginRow.getByTestId("plugin-unsigned-badge")).toBeVisible(); + // Successful install closes the dialog (use-plugin-actions.ts afterInstall). + await expect(testPage.getByTestId("install-plugin-dialog")).toBeHidden(); + + // --- 2. Navigate off the Settings takeover (its sidebar mode hides the + // main nav — see app-sidebar.tsx's `settingsMode` branch) and reload: + // boot payload now carries the active plugin. --- + await testPage.goto("/"); + await testPage.reload(); + const navItem = testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`); + await expect(navItem).toBeVisible({ timeout: 15_000 }); + await expect(navItem).toHaveText("Hello E2E"); + + await navItem.click(); + await expect(testPage).toHaveURL(new RegExp(`${PLUGIN_ROUTE}$`)); + const pluginPage = testPage.locator("#hello-plugin-page"); + await expect(pluginPage).toBeVisible(); + await expect(pluginPage).toHaveText("Hello E2E"); + + // --- 3. task-sidebar slot renders on a real task detail page --- + const seedTask = await apiClient.createTask(seedData.workspaceId, "Plugin sidebar seed task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await testPage.goto(`/t/${seedTask.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.sidebar).toBeVisible({ timeout: 10_000 }); + await expect(testPage.locator("#hello-sidebar")).toBeVisible(); + + // --- 4. Back on the plugin's own page (mounted, no navigation from here + // on): create a task and prove BOTH the live WS path (counter) and the + // real gRPC delivery path (deliveries.jsonl) together. Count deliveries + // rather than asserting absence — the seed task in step 3 already + // triggered one (the plugin was active for it too). --- + await testPage.goto(PLUGIN_ROUTE); + const counter = testPage.locator("#hello-task-counter"); + await expect(counter).toBeVisible(); + const counterBefore = Number((await counter.textContent()) ?? "0"); + const deliveriesBefore = deliveredEventTypes(pluginsDir).filter( + (t) => t === "task.created", + ).length; + + await apiClient.createTask(seedData.workspaceId, "Plugin live task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + await expect + .poll(() => deliveredEventTypes(pluginsDir).filter((t) => t === "task.created").length, { + timeout: 15_000, + intervals: [250, 500, 1000], + }) + .toBe(deliveriesBefore + 1); + await expect(counter).toHaveText(String(counterBefore + 1), { timeout: 15_000 }); + + // --- 5. Disable from Settings > Plugins: registry unregisters live, so + // the nav item is gone as soon as we're back on a page that renders the + // main sidebar (Settings itself replaces that sidebar with its own + // takeover — see the `settingsMode` branch in app-sidebar.tsx). --- + await testPage.goto("/settings/plugins"); + await expect(pluginRow.getByText("Active", { exact: true })).toBeVisible(); + await pluginRow.getByRole("button", { name: "Disable" }).click(); + await expect(pluginRow.getByText("Disabled", { exact: true })).toBeVisible({ timeout: 10_000 }); + await testPage.goto("/"); + await expect(testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`)).toHaveCount(0); + + // --- 6. Re-enable: nav item reappears live (no reload needed) --- + await testPage.goto("/settings/plugins"); + await pluginRow.getByRole("button", { name: "Enable" }).click(); + await expect(pluginRow.getByText("Active", { exact: true })).toBeVisible({ timeout: 10_000 }); + await testPage.goto("/"); + await expect(testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`)).toBeVisible(); + + await testPage.goto("/settings/plugins"); + + // --- 7. Uninstall via UI (with confirmation): row disappears, package + // directory tree is removed from disk. --- + await pluginRow.getByRole("button", { name: "Uninstall" }).click(); + await testPage.getByRole("button", { name: "Confirm uninstall" }).click(); + await expect(pluginRow).toHaveCount(0, { timeout: 10_000 }); + + const pluginDir = path.join(pluginsDir, PLUGIN_ID); + await expect.poll(() => fs.existsSync(pluginDir), { timeout: 10_000 }).toBe(false); + }); + + 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)); + + try { + await openInstallDialog(testPage); + await uploadPackage(testPage, junkPath); + + await expect(testPage.getByTestId("install-plugin-error")).toBeVisible({ timeout: 10_000 }); + // The failed upload never installed anything — no row should appear. + await expect(testPage.getByTestId(`plugin-row-${PLUGIN_ID}`)).toHaveCount(0); + } finally { + fs.rmSync(junkPath, { force: true }); + } + }); + + test("filesystem sideload: Sync discovers a directory drop as disabled, enabling activates it", async ({ + testPage, + backend, + }) => { + test.setTimeout(60_000); + const pluginsDir = path.join(backend.tmpDir, ".kandev", "plugins"); + stageDirSideload(pluginsDir); + + // --- Before syncing, the sideload sits on disk with no record. --- + await testPage.goto("/settings/plugins"); + await expect(testPage.getByTestId(`plugin-row-${PLUGIN_ID}`)).toHaveCount(0); + + // --- Sync discovers it and registers it disabled — never auto-spawned. --- + await testPage.getByTestId("plugins-sync-button").click(); + const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`); + await expect(pluginRow).toBeVisible({ timeout: 15_000 }); + await expect(pluginRow.getByText("Disabled", { exact: true })).toBeVisible(); + + // --- Enable it via the UI: the record transitions to active and the + // real subprocess is spawned/handshaken. --- + await pluginRow.getByRole("button", { name: "Enable" }).click(); + await expect(pluginRow.getByText("Active", { exact: true })).toBeVisible({ timeout: 15_000 }); + + // --- The nav item appears once the boot payload/store reflect the now- + // active plugin (reload, matching the upload flow's own assertion). --- + await testPage.goto("/"); + await testPage.reload(); + await expect(testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`)).toBeVisible({ + timeout: 15_000, + }); + }); +}); diff --git a/apps/web/hooks/domains/plugins/use-plugins.ts b/apps/web/hooks/domains/plugins/use-plugins.ts new file mode 100644 index 0000000000..cace2eafc5 --- /dev/null +++ b/apps/web/hooks/domains/plugins/use-plugins.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect } from "react"; +import { listPlugins } from "@/lib/api/domains/plugins-api"; +import { useAppStore } from "@/components/state-provider"; + +/** + * Loads the plugin registry into the store on mount. Mirrors + * hooks/domains/settings/use-secrets.ts: the slice only holds + * setPlugins/setPluginsLoading/setPluginsError — the fetch + dispatch lives + * here, per the "Never fetch data directly in components" data-flow rule. + */ +export function usePlugins() { + const items = useAppStore((state) => state.plugins.items); + const loaded = useAppStore((state) => state.plugins.loaded); + const loading = useAppStore((state) => state.plugins.loading); + const error = useAppStore((state) => state.plugins.error); + const setPlugins = useAppStore((state) => state.setPlugins); + const setPluginsLoading = useAppStore((state) => state.setPluginsLoading); + const setPluginsError = useAppStore((state) => state.setPluginsError); + + useEffect(() => { + if (loading) return; + setPluginsLoading(true); + listPlugins({ cache: "no-store" }) + .then((response) => { + setPlugins(response); + }) + .catch((err) => { + setPluginsError(err instanceof Error ? err.message : "Failed to load plugins"); + }) + .finally(() => { + setPluginsLoading(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { items, loaded, loading, error }; +} diff --git a/apps/web/lib/api/domains/plugins-api.test.ts b/apps/web/lib/api/domains/plugins-api.test.ts new file mode 100644 index 0000000000..acb97f3781 --- /dev/null +++ b/apps/web/lib/api/domains/plugins-api.test.ts @@ -0,0 +1,334 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/config", () => ({ + getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }), +})); + +import { + disablePlugin, + enablePlugin, + getPlugin, + installPluginFromUrl, + installPluginUpload, + listPlugins, + listPluginTools, + syncPlugins, + uninstallPlugin, + updatePluginConfig, +} from "./plugins-api"; +import { ApiError } from "../client"; + +type FetchInput = Parameters[0]; +type FetchInit = Parameters[1]; + +const fetchSpy = vi.fn<(...args: [FetchInput, FetchInit?]) => Promise>(); + +beforeEach(() => { + fetchSpy.mockReset(); + vi.stubGlobal("fetch", fetchSpy); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const PLUGIN_ID = "acme-tools"; +const PLUGIN_URL = "http://api.test/api/plugins/acme-tools"; +const PARTIAL_INSTALL_WARNING = "plugin installed but failed to start: handshake timed out"; + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function plugin(overrides: Partial> = {}) { + return { + id: PLUGIN_ID, + api_version: 1, + version: "1.0.0", + display_name: "Acme Tools", + description: "desc", + author: "acme", + categories: ["productivity"], + capabilities: {}, + status: "registered", + install_path: "/home/user/.kandev/plugins/acme-tools/1.0.0", + signed: true, + installed_at: "2026-01-01T00:00:00Z", + restart_count: 0, + ...overrides, + }; +} + +describe("listPlugins", () => { + it("fetches GET /api/plugins and unwraps the plugins array", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ plugins: [plugin()] })); + + const result = await listPlugins(); + + const [url, init] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe("http://api.test/api/plugins"); + expect(init?.method ?? "GET").toBe("GET"); + expect(result).toEqual([plugin()]); + }); + + it("returns an empty array when the backend omits plugins", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({})); + + const result = await listPlugins(); + + expect(result).toEqual([]); + }); +}); + +describe("getPlugin", () => { + it("fetches GET /api/plugins/:id", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(plugin())); + + const result = await getPlugin(PLUGIN_ID); + + const [url] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe(PLUGIN_URL); + expect(result).toEqual(plugin()); + }); + + it("propagates a 404 as an ApiError", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ error: "plugin not found" }, 404)); + + await expect(getPlugin("missing")).rejects.toBeInstanceOf(ApiError); + }); +}); + +describe("installPluginFromUrl", () => { + it("POSTs {url} as JSON to /api/plugins/install and unwraps the plugin record", async () => { + // Backend wraps the record as {"plugin": {...}} (internal/plugins/dto.go + // InstallResponse) — not the bare record. + fetchSpy.mockResolvedValueOnce(jsonResponse({ plugin: plugin({ status: "active" }) }, 201)); + + const result = await installPluginFromUrl("https://example.test/acme-tools-1.0.0.tar.gz"); + + const [url, init] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe("http://api.test/api/plugins/install"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ + url: "https://example.test/acme-tools-1.0.0.tar.gz", + }); + expect((init?.headers as Record)?.["Content-Type"]).toBe("application/json"); + expect(result.plugin.id).toBe(PLUGIN_ID); + expect(result.plugin.status).toBe("active"); + expect(result.warning).toBeUndefined(); + }); + + it("surfaces a partial-install warning alongside the stored plugin record", async () => { + // Package installed but its initial spawn/handshake failed — backend + // leaves Plugin.Status as "error" and adds a "warning" alongside "plugin". + fetchSpy.mockResolvedValueOnce( + jsonResponse( + { + plugin: plugin({ status: "error" }), + warning: PARTIAL_INSTALL_WARNING, + }, + 201, + ), + ); + + const result = await installPluginFromUrl("https://example.test/acme-tools-1.0.0.tar.gz"); + + expect(result.plugin.status).toBe("error"); + expect(result.warning).toBe(PARTIAL_INSTALL_WARNING); + }); + + it("propagates a 400 invalid-package error as an ApiError", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ error: "bad checksum for server/plugin-linux-amd64" }, 400), + ); + + await expect(installPluginFromUrl("https://example.test/bad.tar.gz")).rejects.toMatchObject({ + status: 400, + message: "bad checksum for server/plugin-linux-amd64", + }); + }); + + it("propagates a 409 duplicate-version error as an ApiError", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ error: "version 1.0.0 already installed" }, 409)); + + await expect(installPluginFromUrl("https://example.test/dup.tar.gz")).rejects.toMatchObject({ + status: 409, + message: "version 1.0.0 already installed", + }); + }); +}); + +describe("installPluginUpload", () => { + it("POSTs the file as multipart/form-data under the 'package' field and unwraps the plugin record", async () => { + let captured: { method?: string; isFormData: boolean; hasPackageField: boolean } = { + isFormData: false, + hasPackageField: false, + }; + fetchSpy.mockImplementationOnce(async (_url, init) => { + const body = init?.body; + captured = { + method: init?.method, + isFormData: body instanceof FormData, + hasPackageField: body instanceof FormData && body.get("package") !== null, + }; + // Backend wraps the record as {"plugin": {...}} — not the bare record. + return jsonResponse({ plugin: plugin({ status: "active" }) }, 201); + }); + const file = new File([new Uint8Array([1, 2, 3])], "acme-tools-1.0.0.tar.gz", { + type: "application/gzip", + }); + + const result = await installPluginUpload(file); + + expect(captured.method).toBe("POST"); + expect(captured.isFormData).toBe(true); + expect(captured.hasPackageField).toBe(true); + expect(result.plugin.id).toBe(PLUGIN_ID); + expect(result.plugin.status).toBe("active"); + expect(result.warning).toBeUndefined(); + }); + + it("surfaces a partial-install warning alongside the stored plugin record", async () => { + fetchSpy.mockImplementationOnce(async () => + jsonResponse( + { + plugin: plugin({ status: "error" }), + warning: PARTIAL_INSTALL_WARNING, + }, + 201, + ), + ); + const file = new File([new Uint8Array([1, 2, 3])], "acme-tools-1.0.0.tar.gz", { + type: "application/gzip", + }); + + const result = await installPluginUpload(file); + + expect(result.plugin.status).toBe("error"); + expect(result.warning).toBe(PARTIAL_INSTALL_WARNING); + }); + + it("does not set a Content-Type header, so the browser sets the multipart boundary", async () => { + let headers: HeadersInit | undefined; + fetchSpy.mockImplementationOnce(async (_url, init) => { + headers = init?.headers; + return jsonResponse({ plugin: plugin() }, 201); + }); + const file = new File([new Uint8Array([1])], "plugin.tar.gz"); + + await installPluginUpload(file); + + expect(headers).toBeUndefined(); + }); + + it("propagates a 400 unsupported-platform error as an ApiError", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ error: "no executable for platform linux-arm64" }, 400), + ); + const file = new File([new Uint8Array([1])], "plugin.tar.gz"); + + await expect(installPluginUpload(file)).rejects.toMatchObject({ + status: 400, + message: "no executable for platform linux-arm64", + }); + }); + + it("propagates a 409 duplicate-version error as an ApiError", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ error: "version 1.0.0 already installed" }, 409)); + const file = new File([new Uint8Array([1])], "plugin.tar.gz"); + + await expect(installPluginUpload(file)).rejects.toMatchObject({ status: 409 }); + }); +}); + +describe("updatePluginConfig", () => { + it("PATCHes /api/plugins/:id with a config body", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ updated: true })); + + const result = await updatePluginConfig(PLUGIN_ID, { apiKey: "x" }); + + const [url, init] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe(PLUGIN_URL); + expect(init?.method).toBe("PATCH"); + expect(JSON.parse(String(init?.body))).toEqual({ config: { apiKey: "x" } }); + expect(result).toEqual({ updated: true }); + }); +}); + +describe("enablePlugin / disablePlugin / uninstallPlugin", () => { + it("POSTs /api/plugins/:id/enable", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ enabled: true })); + + await enablePlugin(PLUGIN_ID); + + const [url, init] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe(`${PLUGIN_URL}/enable`); + expect(init?.method).toBe("POST"); + }); + + it("POSTs /api/plugins/:id/disable", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ disabled: true })); + + await disablePlugin(PLUGIN_ID); + + const [url, init] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe(`${PLUGIN_URL}/disable`); + expect(init?.method).toBe("POST"); + }); + + it("DELETEs /api/plugins/:id", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ deleted: true })); + + await uninstallPlugin(PLUGIN_ID); + + const [url, init] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe(PLUGIN_URL); + expect(init?.method).toBe("DELETE"); + }); +}); + +describe("listPluginTools", () => { + it("fetches GET /api/plugins/tools and unwraps the tools array", async () => { + const tool = { + plugin_id: PLUGIN_ID, + name: "search", + display_name: "Search", + description: "desc", + }; + fetchSpy.mockResolvedValueOnce(jsonResponse({ tools: [tool] })); + + const result = await listPluginTools(); + + const [url] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe("http://api.test/api/plugins/tools"); + expect(result).toEqual([tool]); + }); +}); + +describe("syncPlugins", () => { + it("POSTs /api/plugins/sync and returns the SyncResult", async () => { + const body = { + added: ["kandev-plugin-side"], + installed: [], + missing: ["kandev-plugin-gone"], + errors: [{ path: "/plugins/junk.tar.gz", reason: "invalid gzip stream" }], + }; + fetchSpy.mockResolvedValueOnce(jsonResponse(body)); + + const result = await syncPlugins(); + + const [url, init] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe("http://api.test/api/plugins/sync"); + expect(init?.method).toBe("POST"); + expect(result).toEqual(body); + }); + + it("propagates a backend error as an ApiError", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ error: "sync failed" }, 500)); + + await expect(syncPlugins()).rejects.toMatchObject({ status: 500, message: "sync failed" }); + }); +}); diff --git a/apps/web/lib/api/domains/plugins-api.ts b/apps/web/lib/api/domains/plugins-api.ts new file mode 100644 index 0000000000..c63ac843ff --- /dev/null +++ b/apps/web/lib/api/domains/plugins-api.ts @@ -0,0 +1,148 @@ +import { ApiError, fetchJson, type ApiRequestOptions } from "../client"; +import { getBackendConfig } from "@/lib/config"; +import type { PluginRecord, PluginToolDTO, SyncResult } from "@/lib/types/plugins"; + +const BASE = "/api/plugins"; + +// listPlugins fetches every registered plugin (GET /api/plugins). +export async function listPlugins(options?: ApiRequestOptions) { + const res = await fetchJson<{ plugins?: PluginRecord[] }>(BASE, options); + return res.plugins ?? []; +} + +// getPlugin fetches a single plugin record by id. Rejects with a 404 +// ApiError when the id is unknown. +export async function getPlugin(id: string, options?: ApiRequestOptions) { + return fetchJson(`${BASE}/${encodeURIComponent(id)}`, options); +} + +// InstallResult mirrors the backend's InstallResponse +// (internal/plugins/dto.go): the install endpoint always wraps the stored +// record under "plugin" (plus an optional "warning" when the package +// installed but its initial spawn/handshake failed — Plugin.Status is left +// as "error" in that case). Callers must surface `warning` — a plugin can +// install successfully yet still fail to come up. +export type InstallResult = { + plugin: PluginRecord; + warning?: string; +}; + +// installPluginFromUrl asks the backend to download, verify, and install a +// plugin package from a remote URL (POST /api/plugins/install, JSON body). +// Returns the stored plugin record plus an optional partial-install warning. +// Throws ApiError on 400 (invalid package/manifest — message names the +// problem, e.g. bad checksum or missing platform executable) or 409 (that +// version is already installed). +export async function installPluginFromUrl( + url: string, + options?: ApiRequestOptions, +): Promise { + return fetchJson(`${BASE}/install`, { + ...options, + init: { + ...(options?.init ?? {}), + method: "POST", + body: JSON.stringify({ url }), + }, + }); +} + +// installPluginUpload uploads a plugin package (.tar.gz) directly as +// multipart/form-data under the "package" field (POST /api/plugins/install). +// Bypasses fetchJson, which always forces a JSON Content-Type header — that +// would stop the browser from setting the multipart boundary itself. Same +// return shape and error semantics as installPluginFromUrl. +export async function installPluginUpload( + file: File, + options?: ApiRequestOptions, +): Promise { + const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl; + const formData = new FormData(); + formData.append("package", file); + + // Spread caller init *first* so method/body always win, matching the + // convention in lib/api/domains/voice-api.ts. + const response = await fetch(`${baseUrl}${BASE}/install`, { + ...options?.init, + method: "POST", + body: formData, + }); + + if (!response.ok) await throwInstallError(response); + return (await response.json()) as InstallResult; +} + +async function throwInstallError(response: Response): Promise { + let body: unknown = null; + let message = `Plugin install failed: ${response.status} ${response.statusText}`; + try { + body = await response.json(); + } catch { + // body remains null + } + if (body && typeof body === "object" && "error" in body) { + const errVal = (body as { error?: unknown }).error; + if (typeof errVal === "string") message = errVal; + } + throw new ApiError(message, response.status, body); +} + +// updatePluginConfig replaces a plugin's operator-editable config +// (PATCH /api/plugins/:id). +export async function updatePluginConfig( + id: string, + config: Record, + options?: ApiRequestOptions, +) { + return fetchJson<{ updated: boolean }>(`${BASE}/${encodeURIComponent(id)}`, { + ...options, + init: { + ...(options?.init ?? {}), + method: "PATCH", + body: JSON.stringify({ config }), + }, + }); +} + +// enablePlugin transitions a plugin to active (POST /api/plugins/:id/enable). +export async function enablePlugin(id: string, options?: ApiRequestOptions) { + return fetchJson<{ enabled: boolean }>(`${BASE}/${encodeURIComponent(id)}/enable`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST" }, + }); +} + +// disablePlugin transitions a plugin to disabled (POST /api/plugins/:id/disable). +export async function disablePlugin(id: string, options?: ApiRequestOptions) { + return fetchJson<{ disabled: boolean }>(`${BASE}/${encodeURIComponent(id)}/disable`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST" }, + }); +} + +// uninstallPlugin removes a plugin's registration (DELETE /api/plugins/:id). +export async function uninstallPlugin(id: string, options?: ApiRequestOptions) { + return fetchJson<{ deleted: boolean }>(`${BASE}/${encodeURIComponent(id)}`, { + ...options, + init: { ...(options?.init ?? {}), method: "DELETE" }, + }); +} + +// listPluginTools aggregates the declared tools of every active plugin +// (GET /api/plugins/tools). +export async function listPluginTools(options?: ApiRequestOptions) { + const res = await fetchJson<{ tools?: PluginToolDTO[] }>(`${BASE}/tools`, options); + return res.tools ?? []; +} + +// syncPlugins reconciles the plugin registry with the plugins directory on +// disk (POST /api/plugins/sync): registers unrecorded directory sideloads +// as disabled, installs and deletes dropped *.tar.gz packages, and marks +// records with a missing install path as errored. Returns the SyncResult +// summarizing what changed this run. +export async function syncPlugins(options?: ApiRequestOptions): Promise { + return fetchJson(`${BASE}/sync`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST" }, + }); +} diff --git a/apps/web/lib/plugins/active-plugin.test.ts b/apps/web/lib/plugins/active-plugin.test.ts new file mode 100644 index 0000000000..de94a4e4e4 --- /dev/null +++ b/apps/web/lib/plugins/active-plugin.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { toActivePlugin } from "./active-plugin"; +import type { PluginRecord } from "@/lib/types/plugins"; + +function record(overrides: Partial = {}): PluginRecord { + return { + id: "acme-tools", + api_version: 1, + version: "1.0.0", + display_name: "Acme Tools", + description: "", + author: "", + categories: [], + capabilities: {}, + status: "active", + install_path: "/home/user/.kandev/plugins/acme-tools/1.0.0", + signed: true, + installed_at: "2026-01-01T00:00:00Z", + restart_count: 0, + ui: { bundle: "/ui/bundle.js" }, + ...overrides, + }; +} + +describe("toActivePlugin", () => { + it("builds the bundle URL from the plugin id, mirroring the backend's boot payload mapping", () => { + const result = toActivePlugin(record()); + expect(result).not.toBeNull(); + expect(result?.bundleUrl).toBe("/api/plugins/acme-tools/bundle"); + expect(result?.id).toBe("acme-tools"); + expect(result?.name).toBe("Acme Tools"); + }); + + it("maps ui.styles to /api/plugins/:id/ui/:style, mirroring pluginStyleURLs on the backend", () => { + const result = toActivePlugin( + record({ ui: { bundle: "/ui/bundle.js", styles: ["/ui/a.css", "/ui/b.css"] } }), + ); + expect(result?.styleUrls).toEqual([ + "/api/plugins/acme-tools/ui/ui/a.css", + "/api/plugins/acme-tools/ui/ui/b.css", + ]); + }); + + it("omits styleUrls when the plugin declares no styles", () => { + const result = toActivePlugin(record({ ui: { bundle: "/ui/bundle.js" } })); + expect(result?.styleUrls).toBeUndefined(); + }); + + it("returns null when the plugin declares no UI bundle", () => { + const result = toActivePlugin(record({ ui: undefined })); + expect(result).toBeNull(); + }); + + it("returns null when ui.bundle is an empty string", () => { + const result = toActivePlugin(record({ ui: { bundle: "" } })); + expect(result).toBeNull(); + }); +}); diff --git a/apps/web/lib/plugins/active-plugin.ts b/apps/web/lib/plugins/active-plugin.ts new file mode 100644 index 0000000000..15840c7fcb --- /dev/null +++ b/apps/web/lib/plugins/active-plugin.ts @@ -0,0 +1,32 @@ +/** + * Builds the `ActivePlugin` shape (docs/plans/plugins/PLUGIN-API.md) + * for a plugin the operator just enabled, so the settings page can trigger a + * runtime `loadPlugins` call without a full page reload. Mirrors the + * backend's boot-payload mapping in + * apps/backend/internal/backendapp/helpers.go (bundleActivePlugins / + * pluginStyleURLs) — keep both in sync. + */ +import type { ActivePlugin } from "./types"; +import type { PluginRecord } from "@/lib/types/plugins"; + +/** + * Returns the `ActivePlugin` for a plugin record, or null when the plugin + * declares no UI bundle (nothing to load). + */ +export function toActivePlugin(record: PluginRecord): ActivePlugin | null { + const bundle = record.ui?.bundle; + if (!bundle) return null; + + return { + id: record.id, + name: record.display_name, + bundleUrl: `/api/plugins/${record.id}/bundle`, + styleUrls: pluginStyleURLs(record), + }; +} + +function pluginStyleURLs(record: PluginRecord): string[] | undefined { + const styles = record.ui?.styles; + if (!styles || styles.length === 0) return undefined; + return styles.map((style) => `/api/plugins/${record.id}/ui${style}`); +} diff --git a/apps/web/lib/plugins/boot.test.ts b/apps/web/lib/plugins/boot.test.ts new file mode 100644 index 0000000000..128d787e28 --- /dev/null +++ b/apps/web/lib/plugins/boot.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import type { ActivePlugin } from "./types"; + +const hostMocks = vi.hoisted(() => ({ + installPluginGlobal: vi.fn(), + loadPlugins: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./host", () => ({ + installPluginGlobal: hostMocks.installPluginGlobal, + loadPlugins: hostMocks.loadPlugins, +})); + +import { bootPlugins } from "./boot"; + +function fakeStore(): StoreApi { + return {} as StoreApi; +} + +function plugin(id: string): ActivePlugin { + return { id, name: id, bundleUrl: `/api/plugins/${id}/bundle` }; +} + +describe("bootPlugins", () => { + beforeEach(() => { + hostMocks.installPluginGlobal.mockClear(); + hostMocks.loadPlugins.mockClear(); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("does nothing when the plugins feature flag is off", () => { + bootPlugins({ plugins: [plugin("a")] }, fakeStore(), false); + + expect(hostMocks.installPluginGlobal).not.toHaveBeenCalled(); + expect(hostMocks.loadPlugins).not.toHaveBeenCalled(); + }); + + it("does nothing when the boot payload has no plugins", () => { + bootPlugins({ plugins: [] }, fakeStore(), true); + bootPlugins({}, fakeStore(), true); + + expect(hostMocks.installPluginGlobal).not.toHaveBeenCalled(); + expect(hostMocks.loadPlugins).not.toHaveBeenCalled(); + }); + + it("installs the global and loads boot payload plugins once enabled", () => { + const store = fakeStore(); + bootPlugins({ plugins: [plugin("a")] }, store, true); + + expect(hostMocks.installPluginGlobal).toHaveBeenCalledTimes(1); + expect(hostMocks.loadPlugins).toHaveBeenCalledTimes(1); + expect(hostMocks.loadPlugins).toHaveBeenCalledWith([plugin("a")], expect.any(Function)); + }); + + it("is idempotent across repeated calls for the same store", () => { + const store = fakeStore(); + const payload = { plugins: [plugin("a")] }; + + bootPlugins(payload, store, true); + bootPlugins(payload, store, true); + bootPlugins(payload, store, true); + + expect(hostMocks.installPluginGlobal).toHaveBeenCalledTimes(1); + expect(hostMocks.loadPlugins).toHaveBeenCalledTimes(1); + }); + + it("boots independently for distinct store instances", () => { + const payload = { plugins: [plugin("a")] }; + + bootPlugins(payload, fakeStore(), true); + bootPlugins(payload, fakeStore(), true); + + expect(hostMocks.loadPlugins).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/lib/plugins/boot.ts b/apps/web/lib/plugins/boot.ts new file mode 100644 index 0000000000..e45423ae55 --- /dev/null +++ b/apps/web/lib/plugins/boot.ts @@ -0,0 +1,43 @@ +/** + * Boots the plugin host from the app's boot payload + * (docs/plans/plugins/PLUGIN-API.md). Called once from the app + * bootstrap once the store + boot payload are both available. + */ +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import type { BootPayload } from "@/src/boot-payload"; +import { buildHostApi } from "./host-api"; +import { installPluginGlobal, loadPlugins } from "./host"; + +/** Store instances that have already had `loadPlugins` triggered — boot is a one-shot per store. */ +const bootedStores = new WeakSet>(); + +/** Reads the resolved light/dark theme applied to the document by AppThemeProvider. */ +function readResolvedTheme(win: Window = window): "light" | "dark" { + return win.document.documentElement.classList.contains("dark") ? "dark" : "light"; +} + +/** + * Installs `window.registerKandevPlugin` and loads every plugin in + * `bootPayload.plugins`, building each one's `PluginHostApi` from `storeApi`. + * + * No-op when `feature` (the `plugins` feature flag) is off, or when the boot + * payload carries no plugins. Idempotent per `storeApi` instance — repeated + * calls (re-renders, StrictMode double-invoke, HMR remounts of the calling + * component) never re-install the global or re-import a bundle for a store + * that has already booted. + */ +export function bootPlugins( + bootPayload: Pick, + storeApi: StoreApi, + feature: boolean, +): void { + if (!feature) return; + if (!bootPayload.plugins || bootPayload.plugins.length === 0) return; + if (bootedStores.has(storeApi)) return; + bootedStores.add(storeApi); + + installPluginGlobal(); + const theme = readResolvedTheme(); + void loadPlugins(bootPayload.plugins, (pluginId) => buildHostApi(pluginId, storeApi, theme)); +} diff --git a/apps/web/lib/plugins/host-api.test.ts b/apps/web/lib/plugins/host-api.test.ts new file mode 100644 index 0000000000..86a58398e4 --- /dev/null +++ b/apps/web/lib/plugins/host-api.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import * as React from "react"; +import { createAppStore } from "@/lib/state/store"; +import { buildHostApi } from "./host-api"; + +describe("buildHostApi", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + vi.unstubAllEnvs(); + }); + + it("scopes api.fetch to /api/plugins/{pluginId}/... and forwards init", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + global.fetch = fetchMock as unknown as typeof fetch; + + const host = buildHostApi("jira", createAppStore(), "light"); + await host.api.fetch("/issues", { method: "POST" }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toContain("/api/plugins/jira/issues"); + expect(init).toEqual({ method: "POST" }); + }); + + it("normalizes a path that doesn't start with a slash", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + global.fetch = fetchMock as unknown as typeof fetch; + + const host = buildHostApi("jira", createAppStore(), "light"); + await host.api.fetch("issues"); + + const [url] = fetchMock.mock.calls[0]; + expect(url).toContain("/api/plugins/jira/issues"); + }); + + it("exposes the host React instance and a jsx alias for React.createElement", () => { + const host = buildHostApi("jira", createAppStore(), "dark"); + + expect(host.React).toBe(React); + expect(host.jsx).toBe(React.createElement); + }); + + it("wires store.getState/setState/subscribe to the passed StoreApi", () => { + const store = createAppStore(); + const getStateSpy = vi.spyOn(store, "getState"); + const setStateSpy = vi.spyOn(store, "setState"); + const subscribeSpy = vi.spyOn(store, "subscribe"); + + const host = buildHostApi("jira", store, "dark"); + + expect(host.store.getState()).toBe(store.getState()); + host.store.setState({}); + const listener = vi.fn(); + const unsubscribe = host.store.subscribe(listener); + + expect(getStateSpy).toHaveBeenCalled(); + expect(setStateSpy).toHaveBeenCalled(); + expect(subscribeSpy).toHaveBeenCalled(); + unsubscribe(); + }); + + it("exposes the requested theme and a curated ui component subset", () => { + const host = buildHostApi("jira", createAppStore(), "dark"); + + expect(host.theme).toBe("dark"); + expect(host.ui.Button).toBeDefined(); + expect(host.ui.Card).toBeDefined(); + expect(host.ui.Badge).toBeDefined(); + }); + + it("sets pluginId on the returned host api", () => { + const host = buildHostApi("jira", createAppStore(), "light"); + expect(host.pluginId).toBe("jira"); + }); +}); diff --git a/apps/web/lib/plugins/host-api.ts b/apps/web/lib/plugins/host-api.ts new file mode 100644 index 0000000000..06d7e3e3be --- /dev/null +++ b/apps/web/lib/plugins/host-api.ts @@ -0,0 +1,73 @@ +/** + * Builds the `PluginHostApi` (docs/plans/plugins/PLUGIN-API.md) passed + * into a plugin's `initialize(registry, host)`. + */ +import * as React from "react"; +import type { StoreApi } from "zustand"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@kandev/ui/card"; +import { Separator } from "@kandev/ui/separator"; +import { Skeleton } from "@kandev/ui/skeleton"; +import { TaskCreateDialog } from "@/components/task-create-dialog"; +import { getBackendConfig } from "@/lib/config"; +import type { AppState } from "@/lib/state/store"; +import type { PluginHostApi } from "./types"; + +/** Curated `@kandev/ui` subset exposed on `host.ui`. */ +const PLUGIN_UI: Record = { + Button, + Badge, + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + Separator, + Skeleton, + // First-party dialog: lets a plugin open kandev's real create-task modal, + // prefilled via initialValues. Unlike the primitives above this is app UI, + // not a shadcn primitive — exposed so plugins hand off task creation to the + // native flow instead of POSTing directly. + TaskCreateDialog, +}; + +export function buildHostApi( + pluginId: string, + storeApi: StoreApi, + theme: "light" | "dark", +): PluginHostApi { + return { + pluginId, + React, + jsx: React.createElement, + store: { + getState: storeApi.getState, + setState: storeApi.setState, + subscribe: storeApi.subscribe, + }, + api: { + fetch: (path, init) => fetchPluginApi(pluginId, path, init), + }, + ui: PLUGIN_UI, + theme, + }; +} + +/** fetch scoped to `/api/plugins/{pluginId}/...` via the kandev reverse proxy. */ +function fetchPluginApi(pluginId: string, path: string, init?: RequestInit): Promise { + const { apiBaseUrl } = getBackendConfig(); + const suffix = path.startsWith("/") ? path : `/${path}`; + const url = `${apiBaseUrl}/api/plugins/${encodeURIComponent(pluginId)}${suffix}`; + return fetch(url, init); +} diff --git a/apps/web/lib/plugins/host.test.ts b/apps/web/lib/plugins/host.test.ts new file mode 100644 index 0000000000..224c55239e --- /dev/null +++ b/apps/web/lib/plugins/host.test.ts @@ -0,0 +1,422 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import type { Window as HappyDOMWindow } from "happy-dom"; +import { loadPlugins, unloadPlugin } from "./host"; +import { pluginRegistry } from "./registry"; +import type { ActivePlugin, PluginHostApi, PluginRegistry } from "./types"; + +let mockApiBaseUrl = ""; +vi.mock("@/lib/config", () => ({ + getBackendConfig: () => ({ apiBaseUrl: mockApiBaseUrl }), +})); + +const PLUGIN_SCOPE_A_ID = "plugin-scope-a"; +const BUNDLE_JS_URL = "/bundle.js"; +const PLUGIN_UNLOAD_A_ID = "plugin-unload-a"; +const PLUGIN_UNLOAD_THROW_A_ID = "plugin-unload-throw-a"; +const PLUGIN_UNLOAD_STYLE_A_ID = "plugin-unload-style-a"; +const PLUGIN_REENABLE_A_ID = "plugin-reenable-a"; +const PLUGIN_REENABLE_A_PATH = "/plugin-reenable-a"; +const NAV_REENABLE_A_ID = "nav-reenable-a"; +const PLUGIN_HANG_A_ID = "plugin-hang-a"; +const PLUGIN_HANG_B_ID = "plugin-hang-b"; + +function makeHostFactory(pluginId: string): PluginHostApi { + return { + pluginId, + React: {} as PluginHostApi["React"], + jsx: {} as PluginHostApi["jsx"], + store: { + getState: () => ({}) as never, + setState: () => {}, + subscribe: () => () => {}, + }, + api: { fetch: async () => new Response() }, + ui: {}, + theme: "light", + }; +} + +type FakeWindow = Window & { + registerKandevPlugin: (id: string, plugin: unknown) => void; +}; + +/** Fake importer that synchronously invokes window.registerKandevPlugin, no real dynamic import. */ +function fakeImporterFor( + bundles: Record void>, +): (url: string) => Promise { + return async (url: string) => { + const run = bundles[url]; + if (!run) throw new Error(`no fake bundle for ${url}`); + run(window); + return {}; + }; +} + +function activePlugin(overrides: Partial = {}): ActivePlugin { + return { + id: "plugin-a", + name: "Plugin A", + bundleUrl: "/api/plugins/plugin-a/bundle", + ...overrides, + }; +} + +function registerFake(id: string, plugin: unknown) { + (window as unknown as FakeWindow).registerKandevPlugin(id, plugin); +} + +afterEach(() => { + mockApiBaseUrl = ""; +}); + +describe("loadPlugins", () => { + afterEach(() => { + pluginRegistry.unregisterPlugin(PLUGIN_SCOPE_A_ID); + pluginRegistry.unregisterPlugin("plugin-style-a"); + pluginRegistry.unregisterPlugin("plugin-throw-a"); + pluginRegistry.unregisterPlugin("plugin-throw-b"); + pluginRegistry.unregisterPlugin("plugin-silent-a"); + document.head.querySelectorAll("link[rel='stylesheet']").forEach((el) => el.remove()); + }); + + it("imports the bundle, then calls initialize(registry, host) with a registry scoped to the plugin", async () => { + const initialize = vi.fn((registry: PluginRegistry, _host: PluginHostApi) => { + registry.registerNavItem({ id: "nav-scope-a", label: "A", path: "/plugin-scope-a" }); + }); + const importer = fakeImporterFor({ + "/api/plugins/plugin-scope-a/bundle": (win) => + (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_SCOPE_A_ID, { initialize }), + }); + + await loadPlugins( + [activePlugin({ id: PLUGIN_SCOPE_A_ID, bundleUrl: "/api/plugins/plugin-scope-a/bundle" })], + makeHostFactory, + importer, + ); + + expect(initialize).toHaveBeenCalledTimes(1); + const [, host] = initialize.mock.calls[0]; + expect(host.pluginId).toBe(PLUGIN_SCOPE_A_ID); + expect(pluginRegistry.getNavItems()).toContainEqual({ + id: "nav-scope-a", + label: "A", + path: "/plugin-scope-a", + }); + }); + + it("injects styleUrls as elements before importing the bundle", async () => { + // happy-dom eagerly loads real hrefs over the network; + // disable that for this test so it doesn't attempt (and 404-log) a real fetch. + const happyDOMWindow = window as unknown as HappyDOMWindow; + happyDOMWindow.happyDOM.settings.disableCSSFileLoading = true; + const importer = fakeImporterFor({ + [BUNDLE_JS_URL]: (win) => + (win as unknown as FakeWindow).registerKandevPlugin("plugin-style-a", { + initialize: () => {}, + }), + }); + + await loadPlugins( + [ + activePlugin({ + id: "plugin-style-a", + bundleUrl: BUNDLE_JS_URL, + styleUrls: ["/plugin-a.css"], + }), + ], + makeHostFactory, + importer, + ); + + const link = document.head.querySelector("link[href='/plugin-a.css']"); + expect(link).not.toBeNull(); + happyDOMWindow.happyDOM.settings.disableCSSFileLoading = false; + }); + + it("isolates a throwing plugin: logs and does not stop other plugins from loading", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const goodInitialize = vi.fn(); + const importer = fakeImporterFor({ + "/bad-bundle.js": (win) => + (win as unknown as FakeWindow).registerKandevPlugin("plugin-throw-a", { + initialize: () => { + throw new Error("boom"); + }, + }), + "/good-bundle.js": (win) => + (win as unknown as FakeWindow).registerKandevPlugin("plugin-throw-b", { + initialize: goodInitialize, + }), + }); + + await loadPlugins( + [ + activePlugin({ id: "plugin-throw-a", bundleUrl: "/bad-bundle.js" }), + activePlugin({ id: "plugin-throw-b", bundleUrl: "/good-bundle.js" }), + ], + makeHostFactory, + importer, + ); + + expect(errorSpy).toHaveBeenCalled(); + expect(goodInitialize).toHaveBeenCalledTimes(1); + errorSpy.mockRestore(); + }); + + it("logs and continues when a bundle never calls registerKandevPlugin", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const importer = fakeImporterFor({ "/silent-bundle.js": () => {} }); + + await loadPlugins( + [activePlugin({ id: "plugin-silent-a", bundleUrl: "/silent-bundle.js" })], + makeHostFactory, + importer, + ); + + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); +}); + +describe("loadPlugins — asset URL prefixing", () => { + afterEach(() => { + pluginRegistry.unregisterPlugin("plugin-prefix-a"); + pluginRegistry.unregisterPlugin("plugin-bare-a"); + document.head.querySelectorAll("link[rel='stylesheet']").forEach((el) => el.remove()); + }); + + it("prefixes a root-relative bundleUrl and style href with the backend apiBaseUrl when set (split-origin dev, Tauri)", async () => { + mockApiBaseUrl = "http://localhost:38429"; + const happyDOMWindow = window as unknown as HappyDOMWindow; + happyDOMWindow.happyDOM.settings.disableCSSFileLoading = true; + const importedUrls: string[] = []; + const importer = async (url: string) => { + importedUrls.push(url); + registerFake("plugin-prefix-a", { initialize: () => {} }); + return {}; + }; + + await loadPlugins( + [ + activePlugin({ + id: "plugin-prefix-a", + bundleUrl: "/api/plugins/plugin-prefix-a/bundle", + styleUrls: ["/api/plugins/plugin-prefix-a/ui/style.css"], + }), + ], + makeHostFactory, + importer, + ); + + expect(importedUrls).toEqual(["http://localhost:38429/api/plugins/plugin-prefix-a/bundle"]); + const link = document.head.querySelector( + "link[href='http://localhost:38429/api/plugins/plugin-prefix-a/ui/style.css']", + ); + expect(link).not.toBeNull(); + happyDOMWindow.happyDOM.settings.disableCSSFileLoading = false; + }); + + it("leaves a root-relative bundleUrl unprefixed when apiBaseUrl is empty (same-origin production)", async () => { + mockApiBaseUrl = ""; + const importedUrls: string[] = []; + const importer = async (url: string) => { + importedUrls.push(url); + registerFake("plugin-bare-a", { initialize: () => {} }); + return {}; + }; + + await loadPlugins( + [ + activePlugin({ + id: "plugin-bare-a", + bundleUrl: "/api/plugins/plugin-bare-a/bundle", + }), + ], + makeHostFactory, + importer, + ); + + expect(importedUrls).toEqual(["/api/plugins/plugin-bare-a/bundle"]); + }); +}); + +describe("unloadPlugin", () => { + afterEach(() => { + pluginRegistry.unregisterPlugin(PLUGIN_UNLOAD_A_ID); + pluginRegistry.unregisterPlugin(PLUGIN_UNLOAD_THROW_A_ID); + pluginRegistry.unregisterPlugin(PLUGIN_UNLOAD_STYLE_A_ID); + document.head.querySelectorAll("link[rel='stylesheet']").forEach((el) => el.remove()); + }); + + it("calls destroy() and bulk-revokes the plugin's registrations", async () => { + const destroy = vi.fn(); + const importer = fakeImporterFor({ + [BUNDLE_JS_URL]: (win) => + (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_UNLOAD_A_ID, { + initialize: (registry: { registerNavItem: (item: unknown) => void }) => { + registry.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" }); + }, + destroy, + }), + }); + await loadPlugins( + [activePlugin({ id: PLUGIN_UNLOAD_A_ID, bundleUrl: BUNDLE_JS_URL })], + makeHostFactory, + importer, + ); + expect(pluginRegistry.getNavItems()).toContainEqual({ + id: "nav-a", + label: "A", + path: "/plugin-a", + }); + + unloadPlugin(PLUGIN_UNLOAD_A_ID); + + expect(destroy).toHaveBeenCalledTimes(1); + expect(pluginRegistry.getNavItems().find((item) => item.id === "nav-a")).toBeUndefined(); + }); + + it("swallows a throwing destroy() and still bulk-revokes registrations", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const importer = fakeImporterFor({ + [BUNDLE_JS_URL]: (win) => + (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_UNLOAD_THROW_A_ID, { + initialize: (registry: { registerNavItem: (item: unknown) => void }) => { + registry.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" }); + }, + destroy: () => { + throw new Error("destroy boom"); + }, + }), + }); + await loadPlugins( + [activePlugin({ id: PLUGIN_UNLOAD_THROW_A_ID, bundleUrl: BUNDLE_JS_URL })], + makeHostFactory, + importer, + ); + + expect(() => unloadPlugin(PLUGIN_UNLOAD_THROW_A_ID)).not.toThrow(); + expect(errorSpy).toHaveBeenCalled(); + expect(pluginRegistry.getNavItems().find((item) => item.id === "nav-a")).toBeUndefined(); + errorSpy.mockRestore(); + }); + + it("removes the plugin's injected stylesheet tags so disable/enable cycles don't accumulate duplicates", async () => { + const happyDOMWindow = window as unknown as HappyDOMWindow; + happyDOMWindow.happyDOM.settings.disableCSSFileLoading = true; + const importer = fakeImporterFor({ + [BUNDLE_JS_URL]: (win) => + (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_UNLOAD_STYLE_A_ID, { + initialize: () => {}, + }), + }); + await loadPlugins( + [ + activePlugin({ + id: PLUGIN_UNLOAD_STYLE_A_ID, + bundleUrl: BUNDLE_JS_URL, + styleUrls: ["/plugin-unload-style-a.css"], + }), + ], + makeHostFactory, + importer, + ); + expect(document.head.querySelector("link[href='/plugin-unload-style-a.css']")).not.toBeNull(); + + unloadPlugin(PLUGIN_UNLOAD_STYLE_A_ID); + + expect(document.head.querySelector("link[href='/plugin-unload-style-a.css']")).toBeNull(); + happyDOMWindow.happyDOM.settings.disableCSSFileLoading = false; + }); +}); + +describe("loadPlugins — initialize() timeout isolation", () => { + afterEach(() => { + pluginRegistry.unregisterPlugin(PLUGIN_HANG_A_ID); + pluginRegistry.unregisterPlugin(PLUGIN_HANG_B_ID); + }); + + it("does not let a plugin whose initialize() never resolves block a subsequent plugin", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const secondInitialize = vi.fn((registry: PluginRegistry) => { + registry.registerNavItem({ id: "nav-hang-b", label: "B", path: "/plugin-hang-b" }); + }); + const importer = fakeImporterFor({ + "/hang-bundle.js": (win) => + (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_HANG_A_ID, { + // Never resolves — simulates a hung plugin initialize(). + initialize: () => new Promise(() => {}), + }), + "/second-bundle.js": (win) => + (win as unknown as FakeWindow).registerKandevPlugin(PLUGIN_HANG_B_ID, { + initialize: secondInitialize, + }), + }); + + await loadPlugins( + [ + activePlugin({ id: PLUGIN_HANG_A_ID, bundleUrl: "/hang-bundle.js" }), + activePlugin({ id: PLUGIN_HANG_B_ID, bundleUrl: "/second-bundle.js" }), + ], + makeHostFactory, + importer, + window, + 10, // short per-test timeout instead of the 10s default + ); + + expect(secondInitialize).toHaveBeenCalledTimes(1); + expect(pluginRegistry.getNavItems()).toContainEqual({ + id: "nav-hang-b", + label: "B", + path: "/plugin-hang-b", + }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(PLUGIN_HANG_A_ID)); + warnSpy.mockRestore(); + }); +}); + +describe("disable then re-enable in the same session", () => { + afterEach(() => { + pluginRegistry.unregisterPlugin(PLUGIN_REENABLE_A_ID); + }); + + it("re-initializes from the cached registration when the bundle's module-eval side effect only fires once (ESM import caching)", async () => { + const initialize = vi.fn((registry: PluginRegistry) => { + registry.registerNavItem({ id: NAV_REENABLE_A_ID, label: "A", path: PLUGIN_REENABLE_A_PATH }); + }); + let importCount = 0; + const importer = vi.fn(async (_url: string) => { + importCount += 1; + // The real browser only runs a module's top-level side effect on the + // *first* resolution of a given specifier — a second `import(url)` + // resolves from the module cache without re-executing + // `window.registerKandevPlugin(...)`. + if (importCount === 1) { + registerFake(PLUGIN_REENABLE_A_ID, { initialize }); + } + return {}; + }); + + await loadPlugins([activePlugin({ id: PLUGIN_REENABLE_A_ID })], makeHostFactory, importer); + expect(initialize).toHaveBeenCalledTimes(1); + expect(pluginRegistry.getNavItems()).toContainEqual({ + id: NAV_REENABLE_A_ID, + label: "A", + path: PLUGIN_REENABLE_A_PATH, + }); + + unloadPlugin(PLUGIN_REENABLE_A_ID); + expect( + pluginRegistry.getNavItems().find((item) => item.id === NAV_REENABLE_A_ID), + ).toBeUndefined(); + + await loadPlugins([activePlugin({ id: PLUGIN_REENABLE_A_ID })], makeHostFactory, importer); + + expect(initialize).toHaveBeenCalledTimes(2); + expect(pluginRegistry.getNavItems()).toContainEqual({ + id: NAV_REENABLE_A_ID, + label: "A", + path: PLUGIN_REENABLE_A_PATH, + }); + }); +}); diff --git a/apps/web/lib/plugins/host.ts b/apps/web/lib/plugins/host.ts new file mode 100644 index 0000000000..74ff9611a7 --- /dev/null +++ b/apps/web/lib/plugins/host.ts @@ -0,0 +1,193 @@ +/** + * Plugin host: the `window.registerKandevPlugin` global + the loader that + * imports plugin bundles from the boot payload (docs/plans/plugins/PLUGIN-API.md). + * + * Loading sequence per bundle: inject `styleUrls` as `` tags, dynamically + * `import(/* @vite-ignore *\/ bundleUrl)` the bundle (module-level side effect + * calls `window.registerKandevPlugin`), then call the registered plugin's + * `initialize(registry, host)`. A bad plugin (throwing bundle, missing + * registration, or throwing `initialize`) is logged and swallowed — it never + * breaks boot or blocks other plugins. + * + * `registeredPlugins` is never cleared on disable — only on a fresh import + * (see `resolveRegistration`). The browser's ES module cache means a repeat + * `import(bundleUrl)` after disable would resolve without re-running the + * bundle's top-level `registerKandevPlugin` call, so re-enabling in the same + * tab must reuse the cached registration instead of relying on re-import. + */ +import { getBackendConfig } from "@/lib/config"; +import { pluginRegistry } from "./registry"; +import type { ActivePlugin, KandevPlugin, PluginHostApi } from "./types"; + +/** Builds the per-plugin `PluginHostApi` for a given pluginId. */ +export type PluginHostFactory = (pluginId: string) => PluginHostApi; + +/** Injectable bundle loader — defaults to a real dynamic import. Tests pass a fake. */ +export type BundleImporter = (url: string) => Promise; + +const defaultImporter: BundleImporter = (url) => import(/* @vite-ignore */ url); + +/** + * How long `loadPlugin` waits for a single plugin's `initialize(registry, host)` + * to settle before giving up on it and moving on to the next plugin in the + * boot list. A plugin whose `initialize()` never resolves must not be able to + * stall every plugin queued behind it. + */ +const DEFAULT_INITIALIZE_TIMEOUT_MS = 10_000; + +/** + * Races `promise` against a `timeoutMs` timer. Resolves with `promise`'s + * value (or rejects with its error) if it settles first; otherwise calls + * `onTimeout` and resolves with `undefined` once the timer fires — a timeout + * is deliberately not a rejection, so the caller's loop can continue to the + * next plugin instead of routing a hang through the same error-handling path + * as a thrown/rejected `initialize()`. The original promise is not + * cancelled; if it eventually settles nothing observes it. + */ +function raceTimeout( + promise: Promise, + timeoutMs: number, + onTimeout: () => void, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + onTimeout(); + resolve(); + }, timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error as Error); + }, + ); + }); +} + +type PluginGlobalWindow = Window & { + registerKandevPlugin?: (id: string, plugin: KandevPlugin) => void; +}; + +/** Bundles registered via `window.registerKandevPlugin`, keyed by pluginId. */ +const registeredPlugins = new Map(); + +/** Defines `window.registerKandevPlugin` before any bundle loads. Idempotent. */ +export function installPluginGlobal(win: Window = window): void { + (win as PluginGlobalWindow).registerKandevPlugin = (id, plugin) => { + registeredPlugins.set(id, plugin); + }; +} + +/** + * Loads every plugin from the boot payload: injects styles, imports the + * bundle, then runs `initialize(registry, host)`. Each plugin is isolated — + * a failure anywhere in its load path is logged and does not affect the + * others or the boot sequence. `initTimeoutMs` (default + * `DEFAULT_INITIALIZE_TIMEOUT_MS`) bounds how long a single plugin's + * `initialize()` can block the (sequential) loop before the loader gives up + * on it and moves on — tests inject a short value instead of waiting out the + * real default. + */ +export async function loadPlugins( + bootPlugins: ActivePlugin[], + hostFactory: PluginHostFactory, + importer: BundleImporter = defaultImporter, + win: Window = window, + initTimeoutMs: number = DEFAULT_INITIALIZE_TIMEOUT_MS, +): Promise { + installPluginGlobal(win); + for (const plugin of bootPlugins) { + await loadPlugin(plugin, hostFactory, importer, initTimeoutMs); + } +} + +async function loadPlugin( + plugin: ActivePlugin, + hostFactory: PluginHostFactory, + importer: BundleImporter, + initTimeoutMs: number, +): Promise { + const { apiBaseUrl } = getBackendConfig(); + try { + injectStyles(plugin.id, plugin.styleUrls, apiBaseUrl); + const registered = await resolveRegistration(plugin, importer, apiBaseUrl); + if (!registered) { + console.error(`[plugins] "${plugin.id}" bundle did not call registerKandevPlugin`); + return; + } + const host = hostFactory(plugin.id); + const registry = pluginRegistry.forPlugin(plugin.id); + await raceTimeout(Promise.resolve(registered.initialize(registry, host)), initTimeoutMs, () => { + console.warn( + `[plugins] "${plugin.id}" initialize() timed out after ${initTimeoutMs}ms; continuing without it`, + ); + }); + } catch (error) { + console.error(`[plugins] failed to load plugin "${plugin.id}"`, error); + } +} + +/** + * Returns the plugin's registration, importing the bundle only when it + * isn't already cached from a prior load in this tab (see module doc for + * why re-enable must not blindly re-import). + */ +async function resolveRegistration( + plugin: ActivePlugin, + importer: BundleImporter, + apiBaseUrl: string, +): Promise { + const cached = registeredPlugins.get(plugin.id); + if (cached) return cached; + await importer(resolvePluginUrl(plugin.bundleUrl, apiBaseUrl)); + return registeredPlugins.get(plugin.id); +} + +/** + * Prefixes a root-relative plugin asset URL with the backend origin. Plain + * root-relative URLs only resolve correctly when the SPA and the API share + * an origin (same-origin production); split-origin dev and the Tauri + * desktop shell need the explicit `apiBaseUrl`, same as `host.api.fetch`. + */ +function resolvePluginUrl(url: string, apiBaseUrl: string): string { + if (!apiBaseUrl || !url.startsWith("/")) return url; + return `${apiBaseUrl}${url}`; +} + +function injectStyles(pluginId: string, styleUrls: string[] | undefined, apiBaseUrl: string): void { + if (!styleUrls) return; + for (const href of styleUrls) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = resolvePluginUrl(href, apiBaseUrl); + link.dataset.pluginId = pluginId; + document.head.appendChild(link); + } +} + +/** Removes every `` this plugin injected via `injectStyles`. */ +function removeStyles(pluginId: string): void { + document.querySelectorAll(`link[data-plugin-id="${pluginId}"]`).forEach((link) => link.remove()); +} + +/** + * Disables a plugin: calls `destroy?.()`, bulk-revokes its registry + * registrations, and removes its injected stylesheets. Deliberately keeps + * the `registeredPlugins` entry — see module doc — so a later re-enable in + * the same tab can re-run `initialize` without depending on the browser + * re-executing the bundle's module-eval side effect. + */ +export function unloadPlugin(id: string): void { + const plugin = registeredPlugins.get(id); + try { + plugin?.destroy?.(); + } catch (error) { + console.error(`[plugins] error destroying plugin "${id}"`, error); + } finally { + pluginRegistry.unregisterPlugin(id); + removeStyles(id); + } +} diff --git a/apps/web/lib/plugins/plugin-boot-bridge.tsx b/apps/web/lib/plugins/plugin-boot-bridge.tsx new file mode 100644 index 0000000000..2ab1f52582 --- /dev/null +++ b/apps/web/lib/plugins/plugin-boot-bridge.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect } from "react"; +import { useAppStoreApi } from "@/components/state-provider"; +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { bootPlugins } from "./boot"; +import type { ActivePlugin } from "./types"; + +type PluginBootBridgeProps = { + plugins?: ActivePlugin[]; +}; + +/** + * Mounted once at the app root (inside `StateProvider`, see `src/main.tsx`). + * Triggers `bootPlugins` as soon as the store + boot payload plugins are + * available. Renders nothing — `bootPlugins` itself guards against + * double-loading a store that has already booted, so repeated effect runs + * (StrictMode double-invoke, re-renders) are safe no-ops. + */ +export function PluginBootBridge({ plugins }: PluginBootBridgeProps) { + const store = useAppStoreApi(); + const pluginsEnabled = useFeature("plugins"); + + useEffect(() => { + bootPlugins({ plugins }, store, pluginsEnabled); + }, [plugins, store, pluginsEnabled]); + + return null; +} diff --git a/apps/web/lib/plugins/registry.test.ts b/apps/web/lib/plugins/registry.test.ts new file mode 100644 index 0000000000..ecc14ab9bd --- /dev/null +++ b/apps/web/lib/plugins/registry.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { pluginRegistry } from "./registry"; + +const TASK_SIDEBAR_SLOT = "task-sidebar"; +const TASK_CREATED_ACTION = "task.created"; + +function cleanup(...pluginIds: string[]) { + pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id)); +} + +describe("pluginRegistry", () => { + afterEach(() => { + cleanup("plugin-a", "plugin-b"); + }); + + it("registers and returns a route via the scoped registry view", () => { + const scoped = pluginRegistry.forPlugin("plugin-a"); + function Page() { + return null; + } + + scoped.registerRoute("/plugin-a", Page); + + const routes = pluginRegistry.getRoutes(); + expect(routes).toContainEqual({ path: "/plugin-a", Component: Page }); + }); + + it("registers and returns a nav item", () => { + const scoped = pluginRegistry.forPlugin("plugin-a"); + + scoped.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" }); + + expect(pluginRegistry.getNavItems()).toContainEqual({ + id: "nav-a", + label: "A", + path: "/plugin-a", + }); + }); + + it("registers and returns a settings route", () => { + const scoped = pluginRegistry.forPlugin("plugin-a"); + function Settings() { + return null; + } + + scoped.registerSettingsRoute("/settings/plugins/plugin-a", Settings); + + expect(pluginRegistry.getSettingsRoutes()).toContainEqual({ + path: "/settings/plugins/plugin-a", + Component: Settings, + }); + }); + + it("registers a slot component and only returns it for the matching slot", () => { + const scoped = pluginRegistry.forPlugin("plugin-a"); + function Sidebar() { + return null; + } + + scoped.registerComponent(TASK_SIDEBAR_SLOT, Sidebar); + + expect(pluginRegistry.getSlotComponents(TASK_SIDEBAR_SLOT)).toEqual([Sidebar]); + expect(pluginRegistry.getSlotComponents("settings-nav")).toEqual([]); + }); + + it("registers a WS handler and only returns it for the matching action", () => { + const scoped = pluginRegistry.forPlugin("plugin-a"); + const handler = () => {}; + + scoped.registerWsHandler(TASK_CREATED_ACTION, handler); + + expect(pluginRegistry.getWsHandlers(TASK_CREATED_ACTION)).toEqual([handler]); + expect(pluginRegistry.getWsHandlers("task.deleted")).toEqual([]); + }); + + it("bulk-revokes every registration owned by a plugin on unregisterPlugin", () => { + const scopedA = pluginRegistry.forPlugin("plugin-a"); + const scopedB = pluginRegistry.forPlugin("plugin-b"); + function PageA() { + return null; + } + function PageB() { + return null; + } + + scopedA.registerRoute("/plugin-a", PageA); + scopedA.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" }); + scopedA.registerComponent(TASK_SIDEBAR_SLOT, PageA); + scopedA.registerWsHandler(TASK_CREATED_ACTION, () => {}); + scopedB.registerRoute("/plugin-b", PageB); + + pluginRegistry.unregisterPlugin("plugin-a"); + + expect(pluginRegistry.getRoutes()).toEqual([{ path: "/plugin-b", Component: PageB }]); + expect(pluginRegistry.getNavItems().find((item) => item.id === "nav-a")).toBeUndefined(); + expect(pluginRegistry.getSlotComponents(TASK_SIDEBAR_SLOT)).toEqual([]); + expect(pluginRegistry.getWsHandlers(TASK_CREATED_ACTION)).toEqual([]); + }); + + it("notifies subscribers when a registration is added", () => { + const scoped = pluginRegistry.forPlugin("plugin-a"); + let notified = 0; + const unsubscribe = pluginRegistry.subscribe(() => { + notified += 1; + }); + + scoped.registerNavItem({ id: "nav-a", label: "A", path: "/plugin-a" }); + + unsubscribe(); + expect(notified).toBe(1); + }); + + it("does not notify subscribers when unregistering a plugin with no registrations", () => { + let notified = 0; + const unsubscribe = pluginRegistry.subscribe(() => { + notified += 1; + }); + + pluginRegistry.unregisterPlugin("plugin-with-nothing-registered"); + + unsubscribe(); + expect(notified).toBe(0); + }); +}); diff --git a/apps/web/lib/plugins/registry.ts b/apps/web/lib/plugins/registry.ts new file mode 100644 index 0000000000..02dace9528 --- /dev/null +++ b/apps/web/lib/plugins/registry.ts @@ -0,0 +1,157 @@ +/** + * Reactive singleton `PluginRegistry` (docs/plans/plugins/PLUGIN-API.md). + * + * Holds every registration made by every loaded plugin, tracks the owning + * pluginId so a disabled/uninstalled plugin can be bulk-revoked, and exposes + * a tiny external-store subscription so React components re-render on + * registration changes (`usePluginRegistry()`). + * + * `pluginRegistry.forPlugin(pluginId)` returns the exact `PluginRegistry` + * shape from the frozen contract (no pluginId param on the register* + * methods) — this is what `host.ts` passes into a plugin's `initialize()`. + */ +import { useSyncExternalStore } from "react"; +import type { NavItem, PluginRegistry, SlotComponent, WsHandler } from "./types"; +import type { ComponentType } from "react"; + +interface Owned { + pluginId: string; + value: T; +} + +interface RouteRegistration { + path: string; + Component: ComponentType; +} + +interface SlotRegistration { + slot: string; + Component: SlotComponent; +} + +interface WsHandlerRegistration { + action: string; + handler: WsHandler; +} + +function removeByPlugin(list: Owned[], pluginId: string): Owned[] { + return list.filter((entry) => entry.pluginId !== pluginId); +} + +class PluginRegistryStore { + private routes: Owned[] = []; + private settingsRoutes: Owned[] = []; + private navItems: Owned[] = []; + private slotComponents: Owned[] = []; + private wsHandlers: Owned[] = []; + private listeners = new Set<() => void>(); + private version = 0; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getVersion = (): number => this.version; + + registerRoute(pluginId: string, path: string, Component: ComponentType): void { + this.routes.push({ pluginId, value: { path, Component } }); + this.notify(); + } + + registerSettingsRoute(pluginId: string, path: string, Component: ComponentType): void { + this.settingsRoutes.push({ pluginId, value: { path, Component } }); + this.notify(); + } + + registerNavItem(pluginId: string, item: NavItem): void { + this.navItems.push({ pluginId, value: item }); + this.notify(); + } + + registerComponent(pluginId: string, slot: string, Component: SlotComponent): void { + this.slotComponents.push({ pluginId, value: { slot, Component } }); + this.notify(); + } + + registerWsHandler(pluginId: string, action: string, handler: WsHandler): void { + this.wsHandlers.push({ pluginId, value: { action, handler } }); + this.notify(); + } + + /** Bulk-revoke every registration owned by `pluginId` (disable/uninstall). */ + unregisterPlugin(pluginId: string): void { + const before = this.totalCount(); + this.routes = removeByPlugin(this.routes, pluginId); + this.settingsRoutes = removeByPlugin(this.settingsRoutes, pluginId); + this.navItems = removeByPlugin(this.navItems, pluginId); + this.slotComponents = removeByPlugin(this.slotComponents, pluginId); + this.wsHandlers = removeByPlugin(this.wsHandlers, pluginId); + if (this.totalCount() !== before) this.notify(); + } + + getRoutes(): RouteRegistration[] { + return this.routes.map((entry) => entry.value); + } + + getSettingsRoutes(): RouteRegistration[] { + return this.settingsRoutes.map((entry) => entry.value); + } + + getNavItems(): NavItem[] { + return this.navItems.map((entry) => entry.value); + } + + getSlotComponents(slot: string): SlotComponent[] { + return this.slotComponents + .filter((entry) => entry.value.slot === slot) + .map((entry) => entry.value.Component); + } + + getWsHandlers(action: string): WsHandler[] { + return this.wsHandlers + .filter((entry) => entry.value.action === action) + .map((entry) => entry.value.handler); + } + + /** Registry view scoped to one plugin — matches the frozen `PluginRegistry` contract. */ + forPlugin(pluginId: string): PluginRegistry { + return { + registerRoute: (path, Component) => this.registerRoute(pluginId, path, Component), + registerNavItem: (item) => this.registerNavItem(pluginId, item), + registerSettingsRoute: (path, Component) => + this.registerSettingsRoute(pluginId, path, Component), + registerComponent: (slot, Component) => this.registerComponent(pluginId, slot, Component), + registerWsHandler: (action, handler) => this.registerWsHandler(pluginId, action, handler), + }; + } + + private totalCount(): number { + return ( + this.routes.length + + this.settingsRoutes.length + + this.navItems.length + + this.slotComponents.length + + this.wsHandlers.length + ); + } + + private notify(): void { + this.version += 1; + this.listeners.forEach((listener) => listener()); + } +} + +export const pluginRegistry = new PluginRegistryStore(); + +/** Snapshot hook: re-renders the caller whenever any plugin registration changes. */ +export function usePluginRegistry(): PluginRegistryStore { + useSyncExternalStore( + pluginRegistry.subscribe, + pluginRegistry.getVersion, + pluginRegistry.getVersion, + ); + return pluginRegistry; +} diff --git a/apps/web/lib/plugins/sync-summary.test.ts b/apps/web/lib/plugins/sync-summary.test.ts new file mode 100644 index 0000000000..e4f2fda818 --- /dev/null +++ b/apps/web/lib/plugins/sync-summary.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { summarizeSyncResult } from "./sync-summary"; +import type { SyncResult } from "@/lib/types/plugins"; + +function result(overrides: Partial = {}): SyncResult { + return { added: [], installed: [], missing: [], errors: [], ...overrides }; +} + +describe("summarizeSyncResult", () => { + it("returns 'Everything up to date' when nothing changed", () => { + expect(summarizeSyncResult(result())).toBe("Everything up to date"); + }); + + it("summarizes a single category", () => { + expect(summarizeSyncResult(result({ added: ["a"] }))).toBe("Sync: 1 sideloaded"); + }); + + it("summarizes multiple categories in added/installed/missing order", () => { + expect( + summarizeSyncResult(result({ added: ["a", "b"], installed: ["c"], missing: ["d"] })), + ).toBe("Sync: 2 sideloaded, 1 installed, 1 missing"); + }); + + it("ignores errors alone when nothing else changed", () => { + expect(summarizeSyncResult(result({ errors: [{ path: "/x.tar.gz", reason: "bad" }] }))).toBe( + "Everything up to date", + ); + }); + + it("counts installed and missing without added", () => { + expect(summarizeSyncResult(result({ installed: ["a"], missing: ["b", "c"] }))).toBe( + "Sync: 1 installed, 2 missing", + ); + }); +}); diff --git a/apps/web/lib/plugins/sync-summary.ts b/apps/web/lib/plugins/sync-summary.ts new file mode 100644 index 0000000000..d66f5d7c6a --- /dev/null +++ b/apps/web/lib/plugins/sync-summary.ts @@ -0,0 +1,18 @@ +/** + * Formats a POST /api/plugins/sync SyncResult into the one-line toast + * summary the Plugins settings page shows after a sync + * (docs/specs/plugins/spec.md "Filesystem sideloading & sync"). Errors are + * surfaced separately (an inline `plugins-sync-errors` region) — they do + * not affect this summary line. + */ +import type { SyncResult } from "@/lib/types/plugins"; + +export function summarizeSyncResult(result: SyncResult): string { + const parts: string[] = []; + if (result.added.length > 0) parts.push(`${result.added.length} sideloaded`); + if (result.installed.length > 0) parts.push(`${result.installed.length} installed`); + if (result.missing.length > 0) parts.push(`${result.missing.length} missing`); + + if (parts.length === 0) return "Everything up to date"; + return `Sync: ${parts.join(", ")}`; +} diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts new file mode 100644 index 0000000000..1affcbaac1 --- /dev/null +++ b/apps/web/lib/plugins/types.ts @@ -0,0 +1,83 @@ +/** + * TS mirror of docs/plans/plugins/PLUGIN-API.md — the frozen contract. + * Do not diverge without updating that document. + */ +import type * as ReactType from "react"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; + +/** Entry in the boot payload's `plugins` array (backend `ActivePlugin`). */ +export interface ActivePlugin { + id: string; + name: string; + bundleUrl: string; + styleUrls?: string[]; +} + +/** Sidebar/main nav entry registered by a plugin. */ +export interface NavItem { + id: string; + label: string; + path: string; + icon?: string; + section?: "main" | "settings"; +} + +/** + * Named slot the host renders via ``. Initial slots: + * "task-sidebar", "settings-nav", "main-nav-footer". Not a closed union — + * hosts may register additional slot names. + */ +export type PluginSlotName = string; + +/** Component registered for a named slot; receives host-provided `slotProps`. */ +export type SlotComponent = ReactType.ComponentType<{ slotProps?: unknown }>; + +/** WS action payload handler registered by a plugin. */ +export type WsHandler = (payload: unknown) => void; + +/** + * API surface passed as the second argument to `KandevPlugin.initialize`. + * Plugins must render with `host.React` / `host.jsx` — no bundled React. + */ +export interface PluginHostApi { + pluginId: string; + /** Host React instance (shared) — plugins must not bundle their own React. */ + React: typeof ReactType; + /** Convenience alias for `React.createElement`. */ + jsx: typeof ReactType.createElement; + /** Kandev app store (zustand `StoreApi`), curated to these 3 methods. */ + store: Pick, "getState" | "setState" | "subscribe">; + api: { + /** fetch scoped to `/api/plugins/{id}/...` via the kandev reverse proxy. */ + fetch(path: string, init?: RequestInit): Promise; + }; + /** Curated subset of `@kandev/ui` components (Button, Card, Badge, ...). */ + ui: Record; + theme: "light" | "dark"; +} + +/** + * Registry surface passed as the first argument to `KandevPlugin.initialize`. + * Each plugin receives an instance scoped to its own pluginId — the + * registrations are tracked internally so the host can bulk-revoke them on + * disable (see `apps/web/lib/plugins/registry.ts`). + */ +export interface PluginRegistry { + /** Top-level SPA route, e.g. "/jira". Exact-match against window.location path. */ + registerRoute(path: string, Component: ReactType.ComponentType): void; + /** Sidebar/main nav entry, rendered by ``. */ + registerNavItem(item: NavItem): void; + /** Route under `/settings/plugins/{id}/...`, rendered inside the settings shell. */ + registerSettingsRoute(path: string, Component: ReactType.ComponentType): void; + /** Named slot injection, rendered by ``. */ + registerComponent(slot: PluginSlotName, Component: SlotComponent): void; + /** WS action handler, bridged into the existing `lib/ws` dispatch. */ + registerWsHandler(action: string, handler: WsHandler): void; +} + +/** Shape every plugin bundle registers via `window.registerKandevPlugin(id, plugin)`. */ +export interface KandevPlugin { + initialize(registry: PluginRegistry, host: PluginHostApi): void | Promise; + destroy?(): void; +} diff --git a/apps/web/lib/state/slices/features/features-slice.test.ts b/apps/web/lib/state/slices/features/features-slice.test.ts index c4f907f973..3d6fa39ebf 100644 --- a/apps/web/lib/state/slices/features/features-slice.test.ts +++ b/apps/web/lib/state/slices/features/features-slice.test.ts @@ -21,13 +21,16 @@ describe("features slice", () => { expect(value, `default of features.${name}`).toBe(false); } expect(store.getState().features.office).toBe(false); + expect(store.getState().features.plugins).toBe(false); }); it("setFeatures replaces the whole flag map", () => { const store = makeStore(); - store.getState().setFeatures({ office: true }); + store.getState().setFeatures({ office: true, plugins: true }); expect(store.getState().features.office).toBe(true); - store.getState().setFeatures({ office: false }); + expect(store.getState().features.plugins).toBe(true); + store.getState().setFeatures({ office: false, plugins: false }); expect(store.getState().features.office).toBe(false); + expect(store.getState().features.plugins).toBe(false); }); }); diff --git a/apps/web/lib/state/slices/features/features-slice.ts b/apps/web/lib/state/slices/features/features-slice.ts index 39083660a6..39163baf9c 100644 --- a/apps/web/lib/state/slices/features/features-slice.ts +++ b/apps/web/lib/state/slices/features/features-slice.ts @@ -7,6 +7,7 @@ export const defaultFeaturesState: FeaturesSliceState = { // The SSR layer overwrites this with whatever the backend reports. features: { office: false, + plugins: false, }, }; diff --git a/apps/web/lib/state/slices/features/types.ts b/apps/web/lib/state/slices/features/types.ts index 39f67dd65f..32f3c2117d 100644 --- a/apps/web/lib/state/slices/features/types.ts +++ b/apps/web/lib/state/slices/features/types.ts @@ -6,6 +6,7 @@ export type FeatureFlags = { office: boolean; + plugins: boolean; }; export type FeatureName = keyof FeatureFlags; diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts index 819ef7fd53..968a81f9b2 100644 --- a/apps/web/lib/state/slices/index.ts +++ b/apps/web/lib/state/slices/index.ts @@ -16,6 +16,7 @@ export { createOfficeSlice, defaultOfficeState } from "./office/office-slice"; export { createFeaturesSlice, defaultFeaturesState } from "./features/features-slice"; export { createAutomationsSlice, defaultAutomationsState } from "./automations/automations-slice"; export { createSystemSlice, defaultSystemState } from "./system/system-slice"; +export { createPluginsSlice, defaultPluginsState } from "./plugins/plugins-slice"; // Export types export type { KanbanSlice, KanbanSliceState, KanbanSliceActions } from "./kanban/types"; @@ -75,6 +76,12 @@ export type { SystemLogsState, SystemJobsMap, } from "./system/types"; +export type { + PluginsSlice, + PluginsSliceState, + PluginsSliceActions, + PluginsState, +} from "./plugins/types"; // Re-export commonly used types from each domain export type { diff --git a/apps/web/lib/state/slices/plugins/plugins-slice.test.ts b/apps/web/lib/state/slices/plugins/plugins-slice.test.ts new file mode 100644 index 0000000000..51f813d8ef --- /dev/null +++ b/apps/web/lib/state/slices/plugins/plugins-slice.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { createPluginsSlice } from "./plugins-slice"; +import type { PluginsSlice } from "./types"; +import type { PluginRecord } from "@/lib/types/plugins"; + +function makeStore() { + return create()( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + immer((...a) => ({ ...(createPluginsSlice as any)(...a) })), + ); +} + +function plugin(id: string, overrides: Partial = {}): PluginRecord { + return { + id, + api_version: 1, + version: "1.0.0", + display_name: `Plugin ${id}`, + description: "", + author: "", + categories: [], + capabilities: {}, + status: "registered", + install_path: `/home/user/.kandev/plugins/${id}/1.0.0`, + signed: true, + installed_at: "2026-01-01T00:00:00Z", + restart_count: 0, + ...overrides, + }; +} + +describe("plugins slice", () => { + it("starts empty, not loading, not loaded, no error", () => { + const store = makeStore(); + const s = store.getState(); + expect(s.plugins.items).toEqual([]); + expect(s.plugins.loading).toBe(false); + expect(s.plugins.loaded).toBe(false); + expect(s.plugins.error).toBeNull(); + }); + + it("setPlugins replaces items, flips loaded=true, and clears error", () => { + const store = makeStore(); + store.getState().setPluginsError("boom"); + store.getState().setPlugins([plugin("a"), plugin("b")]); + const s = store.getState(); + expect(s.plugins.items.map((p) => p.id)).toEqual(["a", "b"]); + expect(s.plugins.loaded).toBe(true); + expect(s.plugins.error).toBeNull(); + }); + + it("setPluginsLoading toggles the loading flag", () => { + const store = makeStore(); + store.getState().setPluginsLoading(true); + expect(store.getState().plugins.loading).toBe(true); + store.getState().setPluginsLoading(false); + expect(store.getState().plugins.loading).toBe(false); + }); + + it("setPluginsError sets the error message", () => { + const store = makeStore(); + store.getState().setPluginsError("network error"); + expect(store.getState().plugins.error).toBe("network error"); + store.getState().setPluginsError(null); + expect(store.getState().plugins.error).toBeNull(); + }); + + it("upsertPlugin appends a new plugin", () => { + const store = makeStore(); + store.getState().setPlugins([plugin("a")]); + store.getState().upsertPlugin(plugin("b")); + expect(store.getState().plugins.items.map((p) => p.id)).toEqual(["a", "b"]); + }); + + it("upsertPlugin replaces an existing plugin by id in place", () => { + const store = makeStore(); + store.getState().setPlugins([plugin("a", { status: "registered" }), plugin("b")]); + store.getState().upsertPlugin(plugin("a", { status: "active" })); + const s = store.getState(); + expect(s.plugins.items.map((p) => p.id)).toEqual(["a", "b"]); + expect(s.plugins.items[0].status).toBe("active"); + }); + + it("removePlugin filters by id", () => { + const store = makeStore(); + store.getState().setPlugins([plugin("a"), plugin("b"), plugin("c")]); + store.getState().removePlugin("b"); + expect(store.getState().plugins.items.map((p) => p.id)).toEqual(["a", "c"]); + }); +}); diff --git a/apps/web/lib/state/slices/plugins/plugins-slice.ts b/apps/web/lib/state/slices/plugins/plugins-slice.ts new file mode 100644 index 0000000000..4a6c606c91 --- /dev/null +++ b/apps/web/lib/state/slices/plugins/plugins-slice.ts @@ -0,0 +1,46 @@ +import type { StateCreator } from "zustand"; +import type { PluginsSlice, PluginsSliceState } from "./types"; + +export const defaultPluginsState: PluginsSliceState = { + plugins: { items: [], loading: false, loaded: false, error: null }, +}; + +type ImmerSet = Parameters< + StateCreator +>[0]; + +export const createPluginsSlice: StateCreator< + PluginsSlice, + [["zustand/immer", never]], + [], + PluginsSlice +> = (set: ImmerSet) => ({ + ...defaultPluginsState, + setPlugins: (plugins) => + set((draft) => { + draft.plugins.items = plugins; + draft.plugins.loaded = true; + draft.plugins.error = null; + }), + setPluginsLoading: (loading) => + set((draft) => { + draft.plugins.loading = loading; + }), + setPluginsError: (error) => + set((draft) => { + draft.plugins.error = error; + }), + upsertPlugin: (plugin) => + set((draft) => { + const idx = draft.plugins.items.findIndex((p) => p.id === plugin.id); + if (idx >= 0) { + draft.plugins.items[idx] = plugin; + } else { + draft.plugins.items.push(plugin); + } + }), + removePlugin: (id) => + set((draft) => { + draft.plugins.items = draft.plugins.items.filter((p) => p.id !== id); + }), +}); diff --git a/apps/web/lib/state/slices/plugins/types.ts b/apps/web/lib/state/slices/plugins/types.ts new file mode 100644 index 0000000000..ba0be35a4d --- /dev/null +++ b/apps/web/lib/state/slices/plugins/types.ts @@ -0,0 +1,22 @@ +import type { PluginRecord } from "@/lib/types/plugins"; + +export type PluginsState = { + items: PluginRecord[]; + loading: boolean; + loaded: boolean; + error: string | null; +}; + +export type PluginsSliceState = { + plugins: PluginsState; +}; + +export type PluginsSliceActions = { + setPlugins: (plugins: PluginRecord[]) => void; + setPluginsLoading: (loading: boolean) => void; + setPluginsError: (error: string | null) => void; + upsertPlugin: (plugin: PluginRecord) => void; + removePlugin: (id: string) => void; +}; + +export type PluginsSlice = PluginsSliceState & PluginsSliceActions; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index d883cc5ba4..16e6e10b2d 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -30,6 +30,7 @@ import { createFeaturesSlice, createAutomationsSlice, createSystemSlice, + createPluginsSlice, defaultKanbanState, defaultWorkspaceState, defaultSettingsState, @@ -44,6 +45,7 @@ import { defaultFeaturesState, defaultAutomationsState, defaultSystemState, + defaultPluginsState, type WorkspaceState, type WorkflowsState, type ExecutorsState, @@ -71,6 +73,7 @@ import { type AutomationsSliceActions, type FeaturesSliceActions, type GitHubSliceActions, + type PluginsSliceActions, } from "./slices"; import type { AvailableCommand, @@ -218,6 +221,9 @@ export type AppState = { // System slice (actions merged via SystemSliceActions intersection on AppState) system: (typeof defaultSystemState)["system"]; + // Plugins slice (actions merged via PluginsSliceActions intersection on AppState) + plugins: (typeof defaultPluginsState)["plugins"]; + // UI slice previewPanel: (typeof defaultUIState)["previewPanel"]; rightPanel: (typeof defaultUIState)["rightPanel"]; @@ -591,7 +597,8 @@ export type AppState = { } & GitHubSliceActions & SystemSliceActions & FeaturesSliceActions & - AutomationsSliceActions; + AutomationsSliceActions & + PluginsSliceActions; export function createAppStore(initialState?: Partial) { const merged = mergeInitialState(initialState); @@ -627,6 +634,8 @@ export function createAppStore(initialState?: Partial) { ...createUISlice(set as any, get as any, api as any), // eslint-disable-next-line @typescript-eslint/no-explicit-any ...createAutomationsSlice(set as any, get as any, api as any), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...createPluginsSlice(set as any, get as any, api as any), // Re-assert merged initial state so caller-supplied values win over slice defaults. ...buildStateOverrides(merged), // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/web/lib/types/plugins.ts b/apps/web/lib/types/plugins.ts new file mode 100644 index 0000000000..d24475e6cc --- /dev/null +++ b/apps/web/lib/types/plugins.ts @@ -0,0 +1,110 @@ +/** + * TS mirror of the plugin registration types in + * apps/backend/internal/plugins/manifest/manifest.go and + * apps/backend/internal/plugins/store/store.go. Field names are snake_case + * to match the backend JSON tags verbatim (no camelCase transform layer — + * see lib/api/client.ts fetchJson). + */ + +export type PluginStatus = "registered" | "active" | "error" | "disabled" | "uninstalled"; + +export interface PluginCapabilities { + events?: string[]; + api_read?: string[]; + api_write?: string[]; + state?: boolean; + secrets?: boolean; +} + +export interface PluginTool { + name: string; + display_name: string; + description: string; + input_schema?: Record; +} + +export interface PluginWebhook { + key: string; + description?: string; + method?: string; +} + +export interface PluginUIPage { + key: string; + title: string; + path: string; + surface: string; +} + +export interface PluginUISection { + pages?: PluginUIPage[]; + bundle?: string; + styles?: string[]; +} + +/** + * A stored, installed plugin, as returned by GET/PATCH /api/plugins/... and + * by POST /api/plugins/install. Installation is package-based (tarball URL + * or upload) — there is no base_url/endpoints registration and no + * api_key/webhook_secret credential pair (see docs/plans/plugins/GRPC-CONTRACT.md + * §6-§7). `ui.bundle` is a package-relative path like "ui/bundle.js"; the + * bundle is always served by kandev from the extracted package dir at + * /api/plugins/{id}/bundle regardless of that path. + */ +export interface PluginRecord { + id: string; + api_version: number; + version: string; + display_name: string; + description: string; + author: string; + categories: string[]; + capabilities: PluginCapabilities; + tools?: PluginTool[]; + webhooks?: PluginWebhook[]; + config_schema?: Record; + ui?: PluginUISection; + status: PluginStatus; + /** Absolute path the package was extracted to: ~/.kandev/plugins/// */ + install_path: string; + /** false when checksums.txt.sig was missing/unverifiable at install time. */ + signed: boolean; + installed_at: string; + /** Crash-restart attempts since install (health-check backoff counter). */ + restart_count: number; + last_health_check?: string | null; +} + +/** One entry of GET /api/plugins/tools. */ +export interface PluginToolDTO { + plugin_id: string; + name: string; + display_name: string; + description: string; + input_schema?: Record; +} + +/** + * One entry of SyncResult.errors: a filesystem path the sync scan rejected + * (or skipped), plus a human-readable reason. Mirrors + * apps/backend/internal/plugins/dto.go's SyncError. + */ +export interface SyncError { + path: string; + reason: string; +} + +/** + * The response of POST /api/plugins/sync: what the filesystem scan under + * the plugins directory found and did this run. Mirrors + * apps/backend/internal/plugins/dto.go's SyncResult. + */ +export interface SyncResult { + /** Plugin ids of directory sideloads registered this run (always `disabled`). */ + added: string[]; + /** Plugin ids of dropped *.tar.gz packages installed this run. */ + installed: string[]; + /** Plugin ids whose install path no longer exists on disk (now `error`). */ + missing: string[]; + errors: SyncError[]; +} diff --git a/apps/web/lib/ws/client.ts b/apps/web/lib/ws/client.ts index ccda042cc1..20debeda44 100644 --- a/apps/web/lib/ws/client.ts +++ b/apps/web/lib/ws/client.ts @@ -2,6 +2,7 @@ import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend" import type { ConnectionStatus } from "@/lib/types/connection"; import { generateUUID } from "@/lib/utils"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; +import { dispatchToPluginWsHandlers } from "@/lib/ws/plugin-bridge"; const debugDispatch = createDebugLogger("ws:dispatch"); @@ -438,6 +439,9 @@ export class WebSocketClient { if (handlers) { handlers.forEach((handler) => handler(message)); } + // Plugin bridge: forward the same notification to any handlers a loaded + // plugin registered for this action (registry.registerWsHandler). + dispatchToPluginWsHandlers(action, message.payload); } private resolvePendingRequest(msgId: string, payload: unknown) { diff --git a/apps/web/lib/ws/plugin-bridge.test.ts b/apps/web/lib/ws/plugin-bridge.test.ts new file mode 100644 index 0000000000..3f078725f5 --- /dev/null +++ b/apps/web/lib/ws/plugin-bridge.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { pluginRegistry } from "@/lib/plugins/registry"; +import { dispatchToPluginWsHandlers } from "./plugin-bridge"; + +const ACTION = "task.created"; + +function cleanupPlugins(...pluginIds: string[]) { + pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id)); +} + +describe("dispatchToPluginWsHandlers", () => { + afterEach(() => cleanupPlugins("plugin-a", "plugin-b")); + + it("does nothing when no plugin registered a handler for the action", () => { + expect(() => dispatchToPluginWsHandlers(ACTION, { foo: 1 })).not.toThrow(); + }); + + it("forwards the payload to every handler registered for the action", () => { + const handlerA = vi.fn(); + const handlerB = vi.fn(); + pluginRegistry.forPlugin("plugin-a").registerWsHandler(ACTION, handlerA); + pluginRegistry.forPlugin("plugin-b").registerWsHandler(ACTION, handlerB); + + dispatchToPluginWsHandlers(ACTION, { taskId: "t-1" }); + + expect(handlerA).toHaveBeenCalledWith({ taskId: "t-1" }); + expect(handlerB).toHaveBeenCalledWith({ taskId: "t-1" }); + }); + + it("does not forward to a handler registered for a different action", () => { + const handler = vi.fn(); + pluginRegistry.forPlugin("plugin-a").registerWsHandler("task.deleted", handler); + + dispatchToPluginWsHandlers(ACTION, { taskId: "t-1" }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("isolates a throwing handler so a sibling handler still runs", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const handlerB = vi.fn(); + pluginRegistry.forPlugin("plugin-a").registerWsHandler(ACTION, () => { + throw new Error("boom"); + }); + pluginRegistry.forPlugin("plugin-b").registerWsHandler(ACTION, handlerB); + + expect(() => dispatchToPluginWsHandlers(ACTION, {})).not.toThrow(); + expect(handlerB).toHaveBeenCalled(); + + consoleError.mockRestore(); + }); + + it("logs a throwing handler's action as a data argument, never interpolated into the format string", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const boom = new Error("boom"); + pluginRegistry.forPlugin("plugin-a").registerWsHandler(ACTION, () => { + throw boom; + }); + + dispatchToPluginWsHandlers(ACTION, {}); + + expect(consoleError).toHaveBeenCalledTimes(1); + const [firstArg, ...rest] = consoleError.mock.calls[0]!; + expect(typeof firstArg).toBe("string"); + expect(firstArg as string).not.toContain(ACTION); + expect(rest).toContain(ACTION); + expect(rest).toContain(boom); + + consoleError.mockRestore(); + }); +}); diff --git a/apps/web/lib/ws/plugin-bridge.ts b/apps/web/lib/ws/plugin-bridge.ts new file mode 100644 index 0000000000..992ae3c69b --- /dev/null +++ b/apps/web/lib/ws/plugin-bridge.ts @@ -0,0 +1,19 @@ +/** + * Bridges decoded WS notifications into plugin-registered handlers + * (`registry.registerWsHandler(action, handler)`, PLUGIN-API.md). Called from + * `lib/ws/client.ts` after the built-in `handlers` dispatch for a message, so + * plugin handlers see the same notification vocabulary as the rest of the app. + */ +import { pluginRegistry } from "@/lib/plugins/registry"; + +/** Forwards `payload` to every plugin handler registered for `action`. Never throws. */ +export function dispatchToPluginWsHandlers(action: string, payload: unknown): void { + const handlers = pluginRegistry.getWsHandlers(action); + for (const handler of handlers) { + try { + handler(payload); + } catch (error) { + console.error("[plugins] ws handler threw for action:", action, error); + } + } +} diff --git a/apps/web/src/boot-payload.test.ts b/apps/web/src/boot-payload.test.ts index 1471165079..e8677a3f5e 100644 --- a/apps/web/src/boot-payload.test.ts +++ b/apps/web/src/boot-payload.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import { loadBootPayload, readBootPayload } from "./boot-payload"; +const JIRA_BUNDLE_URL = "/api/plugins/jira/bundle"; + describe("readBootPayload", () => { it("returns an empty initial state when Go has not injected boot data yet", () => { const win = {} as Window; @@ -72,6 +74,55 @@ describe("readBootPayload", () => { }); }); +describe("readBootPayload plugins", () => { + it("parses active plugins from the boot payload", () => { + const win = { + __KANDEV_BOOT_PAYLOAD__: { + plugins: [ + { + id: "jira", + name: "Jira", + bundleUrl: JIRA_BUNDLE_URL, + styleUrls: ["/api/plugins/jira/style.css"], + }, + { id: "hello", name: "Hello", bundleUrl: "/api/plugins/hello/bundle" }, + ], + }, + } as unknown as Window; + + expect(readBootPayload(win).plugins).toEqual([ + { + id: "jira", + name: "Jira", + bundleUrl: JIRA_BUNDLE_URL, + styleUrls: ["/api/plugins/jira/style.css"], + }, + { id: "hello", name: "Hello", bundleUrl: "/api/plugins/hello/bundle", styleUrls: undefined }, + ]); + }); + + it("drops plugin entries missing required fields and non-string styleUrls entries", () => { + const win = { + __KANDEV_BOOT_PAYLOAD__: { + plugins: [ + { id: "no-bundle-url", name: "Missing bundleUrl" }, + { id: "jira", name: "Jira", bundleUrl: JIRA_BUNDLE_URL, styleUrls: ["ok.css", 3] }, + ], + }, + } as unknown as Window; + + expect(readBootPayload(win).plugins).toEqual([ + { id: "jira", name: "Jira", bundleUrl: JIRA_BUNDLE_URL, styleUrls: ["ok.css"] }, + ]); + }); + + it("leaves plugins undefined when the boot payload has no plugins field", () => { + const win = { __KANDEV_BOOT_PAYLOAD__: { version: 1 } } as unknown as Window; + + expect(readBootPayload(win).plugins).toBeUndefined(); + }); +}); + describe("loadBootPayload", () => { it("uses the injected Go boot payload without fetching", async () => { const win = Object.assign(new Window(), { diff --git a/apps/web/src/boot-payload.ts b/apps/web/src/boot-payload.ts index 3c089c8276..2b43dafe55 100644 --- a/apps/web/src/boot-payload.ts +++ b/apps/web/src/boot-payload.ts @@ -2,6 +2,9 @@ import type { AppState } from "@/lib/state/store"; import { getBackendConfig } from "@/lib/config"; import type { FetchedSessionData } from "@/lib/ssr/session-page-state"; import type { Repository, Task, Workflow, WorkflowStep } from "@/lib/types/http"; +import type { ActivePlugin } from "@/lib/plugins/types"; + +export type { ActivePlugin }; export type BootRoute = { kind?: string; @@ -42,6 +45,7 @@ export type BootPayload = { runtime?: BootRuntime; initialState?: Partial; routeData?: BootRouteData; + plugins?: ActivePlugin[]; }; type BootWindow = Window & { @@ -63,9 +67,28 @@ export function readBootPayload(win: Window = window): BootPayload { runtime, initialState: isRecord(payload.initialState) ? (payload.initialState as Partial) : {}, routeData: isRecord(payload.routeData) ? (payload.routeData as BootRouteData) : undefined, + plugins: Array.isArray(payload.plugins) ? readPlugins(payload.plugins) : undefined, }; } +function readPlugins(value: unknown[]): ActivePlugin[] { + return value.filter(isRecord).flatMap((entry) => { + const plugin = readPlugin(entry); + return plugin ? [plugin] : []; + }); +} + +function readPlugin(value: Record): ActivePlugin | undefined { + const id = readString(value.id); + const name = readString(value.name); + const bundleUrl = readString(value.bundleUrl); + if (!id || !name || !bundleUrl) return undefined; + const styleUrls = Array.isArray(value.styleUrls) + ? value.styleUrls.filter((entry): entry is string => typeof entry === "string") + : undefined; + return { id, name, bundleUrl, styleUrls }; +} + export async function loadBootPayload( win: Window = window, fetcher: typeof fetch = fetch, diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index de08375420..d94d26f06f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "@/app/globals.css"; import { StateProvider } from "@/components/state-provider"; +import { PluginBootBridge } from "@/lib/plugins/plugin-boot-bridge"; import { AppShell } from "./app-shell"; import { loadBootPayload } from "./boot-payload"; import type { BootPayload } from "./boot-payload"; @@ -10,6 +11,7 @@ import { SpaRoutes } from "./spa-routes"; function App({ payload }: { payload: BootPayload }) { return ( + diff --git a/apps/web/src/settings-routes.plugin.test.ts b/apps/web/src/settings-routes.plugin.test.ts new file mode 100644 index 0000000000..88ffd0cdd4 --- /dev/null +++ b/apps/web/src/settings-routes.plugin.test.ts @@ -0,0 +1,71 @@ +import { isValidElement, type ReactElement } from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { pluginRegistry } from "@/lib/plugins/registry"; +import { renderSettingsRoute } from "./settings-routes"; + +const PLUGIN_ID = "plugin-a"; +const PLUGIN_SETTINGS_PATH = "/settings/plugins/plugin-a/config"; + +function cleanupPlugins(...pluginIds: string[]) { + pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id)); +} + +describe("renderSettingsRoute — plugin fallthrough", () => { + afterEach(() => cleanupPlugins(PLUGIN_ID)); + + it("falls back to the unported-route placeholder when no plugin owns the path", () => { + const route = renderSettingsRoute(PLUGIN_SETTINGS_PATH); + expect(isValidElement(route)).toBe(true); + // The fallback renders the raw pathname as text — assert via string search + // on the rendered props rather than a brittle component-type check. + expect((route as ReactElement<{ pathname?: string }>).props.pathname).toBe( + PLUGIN_SETTINGS_PATH, + ); + }); + + it("renders the plugin-registered settings route once a plugin registers it", () => { + function PluginSettingsPage() { + return "PluginSettingsPage:rendered"; + } + pluginRegistry + .forPlugin(PLUGIN_ID) + .registerSettingsRoute(PLUGIN_SETTINGS_PATH, PluginSettingsPage); + + const route = renderSettingsRoute(PLUGIN_SETTINGS_PATH); + + expect(isValidElement(route)).toBe(true); + render(route as ReactElement); + expect(screen.getByText("PluginSettingsPage:rendered")).not.toBeNull(); + cleanup(); + }); + + it("renders a fallback instead of throwing when the plugin settings route component throws", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + function ThrowingSettingsPage(): never { + throw new Error("boom"); + } + pluginRegistry + .forPlugin(PLUGIN_ID) + .registerSettingsRoute(PLUGIN_SETTINGS_PATH, ThrowingSettingsPage); + + const route = renderSettingsRoute(PLUGIN_SETTINGS_PATH); + + render(route as ReactElement); + expect(screen.getByText(/this plugin page failed to load/i)).not.toBeNull(); + cleanup(); + errorSpy.mockRestore(); + }); + + it("does not consult the registry for a path outside /settings/plugins/", () => { + function ShouldNotMatch() { + return null; + } + pluginRegistry.forPlugin(PLUGIN_ID).registerSettingsRoute("/settings/general", ShouldNotMatch); + + const route = renderSettingsRoute("/settings/general"); + + // "/settings/general" is a real static route (GeneralSettings), not the plugin's. + expect((route as ReactElement).type).not.toBe(ShouldNotMatch); + }); +}); diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index 22f38c0fc3..140f0462d8 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -18,6 +18,7 @@ import IntegrationsJiraPage from "@/app/settings/integrations/jira/page"; 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 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"; @@ -56,6 +57,11 @@ import { TerminalSettings } from "@/components/settings/terminal-settings"; import { VoiceModeSettings } from "@/components/settings/voice-mode-settings"; import licenses from "@/generated/licenses.json"; import { fetchJson } from "@/lib/api/client"; +import { + PluginErrorBoundary, + PluginRouteFallback, +} from "@/components/plugins/plugin-error-boundary"; +import { pluginRegistry, usePluginRegistry } from "@/lib/plugins/registry"; import { listWorkflows } from "@/lib/api/domains/kanban-api"; import { fetchUserSettings, @@ -138,6 +144,7 @@ const SETTINGS_ROUTES: Record = { "/settings/external-mcp": () => , "/settings/prompts": () => , "/settings/voice-mode": () => , + "/settings/plugins": () => , "/settings/integrations": () => renderIntegrationSettingsRoute(null), "/settings/integrations/github": () => renderIntegrationSettingsRoute("github"), "/settings/integrations/gitlab": () => renderIntegrationSettingsRoute("gitlab"), @@ -207,6 +214,9 @@ const SETTINGS_ROUTES: Record = { export function SettingsRoutes({ pathname }: { pathname: string }) { const normalizedPathname = normalizeSettingsPath(pathname); + // Subscribe so a plugin settings route registered after first paint + // (async bundle load) re-resolves without requiring a navigation. + usePluginRegistry(); return ( <> @@ -223,7 +233,30 @@ export function settingsRouteKey(pathname: string): string { export function renderSettingsRoute(pathname: string) { const dynamicRoute = renderDynamicSettingsRoute(pathname); if (dynamicRoute) return dynamicRoute; - return SETTINGS_ROUTES[pathname]?.() ?? ; + const staticRoute = SETTINGS_ROUTES[pathname]?.(); + if (staticRoute) return staticRoute; + const pluginRoute = renderPluginSettingsRoute(pathname); + if (pluginRoute) return pluginRoute; + return ; +} + +/** + * `/settings/plugins/{id}/...` routes registered by a plugin + * (`registry.registerSettingsRoute(path, Component)`). Scoped to the + * `/settings/plugins/` prefix so it never intercepts a first-party path. + */ +function renderPluginSettingsRoute(pathname: string) { + if (!pathname.startsWith("/settings/plugins/")) return null; + const match = pluginRegistry.getSettingsRoutes().find((route) => route.path === pathname); + if (!match) return null; + return ( + } + > + + + ); } function renderDynamicSettingsRoute(pathname: string) { diff --git a/apps/web/src/spa-routes.plugin.test.tsx b/apps/web/src/spa-routes.plugin.test.tsx new file mode 100644 index 0000000000..38708b6cac --- /dev/null +++ b/apps/web/src/spa-routes.plugin.test.tsx @@ -0,0 +1,89 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { pluginRegistry } from "@/lib/plugins/registry"; +import { resolveSpaRoute, SpaRoutes } from "./spa-routes"; + +const PLUGIN_ID = "plugin-a"; +const PLUGIN_PATH = "/plugins/hello"; + +function cleanupPlugins(...pluginIds: string[]) { + pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id)); +} + +describe("resolveSpaRoute — plugin fallthrough", () => { + afterEach(() => cleanupPlugins(PLUGIN_ID)); + + it("falls back to the kanban route when no plugin owns the path", () => { + expect(resolveSpaRoute("/plugins/hello", new URLSearchParams())).toEqual({ + kind: "kanban", + workspaceId: undefined, + workflowId: undefined, + taskId: undefined, + sessionId: undefined, + }); + }); + + it("resolves to the plugin route once a plugin registers the path", () => { + function HelloPage() { + return null; + } + pluginRegistry.forPlugin(PLUGIN_ID).registerRoute(PLUGIN_PATH, HelloPage); + + expect(resolveSpaRoute(PLUGIN_PATH, new URLSearchParams())).toEqual({ + kind: "plugin", + path: PLUGIN_PATH, + }); + }); + + it("does not shadow a first-class static route with the same-looking plugin path", () => { + function TasksOverride() { + return null; + } + pluginRegistry.forPlugin(PLUGIN_ID).registerRoute("/tasks", TasksOverride); + + expect(resolveSpaRoute("/tasks", new URLSearchParams())).toEqual({ kind: "tasks" }); + }); +}); + +describe("SpaRoutes — plugin route rendering", () => { + afterEach(() => { + cleanup(); + cleanupPlugins(PLUGIN_ID); + window.history.pushState({}, "", "/"); + }); + + it("renders the registered plugin component inside the app shell for a matching path", () => { + function HelloPage() { + return
Hello from plugin
; + } + pluginRegistry.forPlugin(PLUGIN_ID).registerRoute(PLUGIN_PATH, HelloPage); + window.history.pushState({}, "", PLUGIN_PATH); + + render( + + + , + ); + + expect(screen.getByTestId("plugin-hello-page")).not.toBeNull(); + }); + + it("renders a fallback instead of white-screening when the plugin route component throws", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + function ThrowingPage(): never { + throw new Error("boom"); + } + pluginRegistry.forPlugin(PLUGIN_ID).registerRoute(PLUGIN_PATH, ThrowingPage); + window.history.pushState({}, "", PLUGIN_PATH); + + render( + + + , + ); + + expect(screen.getByText(/this plugin page failed to load/i)).not.toBeNull(); + errorSpy.mockRestore(); + }); +}); diff --git a/apps/web/src/spa-routes.tsx b/apps/web/src/spa-routes.tsx index 75f1591fff..77fba540dc 100644 --- a/apps/web/src/spa-routes.tsx +++ b/apps/web/src/spa-routes.tsx @@ -22,6 +22,11 @@ import { listRepositories, listWorkspaces } from "@/lib/api/domains/workspace-ap import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow"; import { hasHydratedKanbanRouteState } from "@/lib/routing/kanban-route-hydration"; import { usePathname, useSearchParams } from "@/lib/routing/client-router"; +import { pluginRegistry, usePluginRegistry } from "@/lib/plugins/registry"; +import { + PluginErrorBoundary, + PluginRouteFallback, +} from "@/components/plugins/plugin-error-boundary"; import { mapWorkspaceItem, readActiveWorkspaceCookie } from "@/lib/routing/route-bootstrap"; import { resolveActiveId } from "@/lib/ssr/resolve-active-id"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; @@ -65,7 +70,8 @@ type SpaRoute = | { kind: "linear" } | { kind: "stats"; range?: RangeKey } | { kind: "settings"; pathname: string } - | { kind: "office"; pathname: string }; + | { kind: "office"; pathname: string } + | { kind: "plugin"; path: string }; type DataBackedSpaRoute = Exclude; @@ -82,10 +88,21 @@ export function resolveSpaRoute(pathname: string, searchParams: URLSearchParams) resolveTaskDetailRoute(normalized, searchParams) ?? resolveTopLevelRoute(normalized, searchParams) ?? resolveNestedRoute(normalized) ?? + resolvePluginRoute(normalized) ?? resolveKanbanRoute(searchParams) ); } +/** + * Dynamic plugin routes (`registry.registerRoute(path, Component)`) — consulted + * after every static/nested route and before the kanban catch-all, so a plugin + * can never shadow a first-class route but does own any otherwise-unmatched path. + */ +function resolvePluginRoute(normalized: string): SpaRoute | null { + const match = pluginRegistry.getRoutes().find((route) => route.path === normalized); + return match ? { kind: "plugin", path: normalized } : null; +} + function resolveTaskDetailRoute( normalized: string, searchParams: URLSearchParams, @@ -144,10 +161,16 @@ function resolveKanbanRoute(searchParams: URLSearchParams): SpaRoute { } export function SpaRoutes({ routeData }: { routeData?: BootRouteData }) { + // Subscribe so a plugin route registered after first paint (async bundle + // load) re-resolves without requiring a navigation. + usePluginRegistry(); const pathname = usePathname(); const searchParams = useSearchParams(); const route = resolveSpaRoute(pathname, searchParams); + if (route.kind === "plugin") { + return ; + } if (route.kind === "kanban") { return ; } @@ -181,6 +204,23 @@ export function SpaRoutes({ routeData }: { routeData?: BootRouteData }) { return ; } +/** + * Renders the plugin-registered component for a `kind: "plugin"` route, + * inside the normal app shell. Wrapped in a `PluginErrorBoundary` so a + * throwing plugin route renders a fallback instead of white-screening the + * rest of the SPA. + */ +function PluginRoute({ path }: { path: string }) { + const match = pluginRegistry.getRoutes().find((route) => route.path === path); + if (!match) return null; + const Component = match.Component; + return ( + }> + + + ); +} + function KanbanRoute({ route }: { route: Extract }) { useKanbanRouteBootstrap(route); return ; diff --git a/docs/plans/plugins/GRPC-CONTRACT.md b/docs/plans/plugins/GRPC-CONTRACT.md new file mode 100644 index 0000000000..13a20e4548 --- /dev/null +++ b/docs/plans/plugins/GRPC-CONTRACT.md @@ -0,0 +1,183 @@ +# kandev.plugin.v1 — gRPC plugin contract (FROZEN) + +Supersedes the HTTP+HMAC transport. Every task builds against this file; do not +diverge without updating it. The frontend contract (PLUGIN-API.md) is unchanged +except where noted in §7. + +## 1. Architecture + +- Plugin **backends are Go binaries** distributed in a release tarball and + **spawned by kandev as subprocesses** via `hashicorp/go-plugin`. +- Transport: gRPC over a unix domain socket (macOS/Linux) or loopback TCP + + AutoMTLS (Windows) — negotiated by go-plugin, invisible to authors. +- Auth: the spawn relationship + go-plugin handshake + AutoMTLS. **No api_key, + no webhook_secret, no HMAC** — all credential machinery is removed for managed + plugins. The remote/self-hosted tier (`base_url` registration) is REMOVED + (future work if ever needed). +- The `LISTENING ` stdout handshake is replaced by go-plugin's handshake. + +## 2. go-plugin handshake + +```go +var Handshake = plugin.HandshakeConfig{ + ProtocolVersion: 1, + MagicCookieKey: "KANDEV_PLUGIN", + MagicCookieValue: "kandev-plugin-v1", +} +// plugin map key: "plugin"; AutoMTLS: enabled on the client (kandev) side. +``` + +Env kandev injects into the subprocess: +- `KANDEV_PLUGIN_DATA_DIR` — per-plugin writable dir (`~/.kandev/plugins//data`). + +## 3. Proto (`apps/backend/proto/kandev/plugin/v1/plugin.proto`) + +```proto +syntax = "proto3"; +package kandev.plugin.v1; +import "google/protobuf/struct.proto"; + +// Implemented by the PLUGIN. kandev is the client. +service Plugin { + rpc DeliverEvent(Event) returns (EventAck); + rpc InvokeTool(ToolRequest) returns (ToolResponse); + rpc HandleWebhook(WebhookRequest) returns (WebhookResponse); +} + +// Implemented by KANDEV (served back over the go-plugin broker). +// Every RPC is capability-gated server-side (§5). +service Host { + rpc GetState(GetStateRequest) returns (GetStateResponse); + rpc SetState(SetStateRequest) returns (SetStateResponse); + rpc DeleteState(DeleteStateRequest) returns (DeleteStateResponse); + rpc ListState(ListStateRequest) returns (ListStateResponse); + rpc RevealSecret(RevealSecretRequest) returns (RevealSecretResponse); + rpc EmitEvent(EmitEventRequest) returns (EmitEventResponse); +} + +message Event { + string event_id = 1; // fresh uuid per delivery + string event_type = 2; // bus subject, e.g. "task.created" + string occurred_at = 3; // RFC3339 UTC + string workspace_id = 4; // empty if not derivable + google.protobuf.Struct payload = 5; // marshaled bus event.Data +} +message EventAck {} + +message ToolRequest { + string tool_call_id = 1; + string tool_name = 2; + google.protobuf.Struct input = 3; + ToolContext context = 4; +} +message ToolContext { string task_id = 1; string agent_instance_id = 2; string session_id = 3; } +message ToolResponse { google.protobuf.Struct output = 1; string error = 2; } + +message WebhookRequest { + string webhook_key = 1; + string method = 2; + string path = 3; // remainder after the key + string query = 4; + map headers = 5; // single-valued; multi joined by ", " + bytes body = 6; +} +message WebhookResponse { int32 status = 1; map headers = 2; bytes body = 3; } + +message GetStateRequest { string scope = 1; string scope_id = 2; string key = 3; } +message GetStateResponse { bool found = 1; google.protobuf.Struct value = 2; } +message SetStateRequest { string scope = 1; string scope_id = 2; string key = 3; google.protobuf.Struct value = 4; } +message SetStateResponse {} +message DeleteStateRequest { string scope = 1; string scope_id = 2; string key = 3; } +message DeleteStateResponse {} +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 RevealSecretRequest { string ref = 1; } +message RevealSecretResponse { string value = 1; } + +message EmitEventRequest { string event_name = 1; google.protobuf.Struct payload = 2; } +message EmitEventResponse {} +``` + +Notes: scope ∈ instance|workspace|task|agent (empty scope_id for instance — +matches the state store). The plugin never passes its own id; the Host service +instance is bound to the plugin's record at spawn time. + +## 4. SDK (`apps/backend/pkg/pluginsdk`) + +Public Go module surface (authors import only this): + +```go +type Plugin interface { + OnEvent(ctx context.Context, e *Event) error // return err → kandev retries + InvokeTool(ctx context.Context, req *ToolRequest) (*ToolResponse, error) + HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error) +} +type Host interface { // injected before Serve returns + GetState/SetState/DeleteState/ListState(...) + RevealSecret(ctx, ref string) (string, error) + EmitEvent(ctx, name string, payload map[string]any) error +} +func Serve(p Plugin, opts ...Option) // blocks; wires go-plugin server + broker +// Optional embeddable no-op base: sdk.UnimplementedPlugin +``` + +SDK types mirror proto but use `map[string]any` for Struct fields. The SDK owns +all go-plugin/grpc plumbing (handshake, broker for Host, conversions). + +## 5. Delivery / tools / webhooks semantics (unchanged from HTTP era) + +- **DeliverEvent**: unary. Per-plugin sequential queue, 10s timeout, 3 retries + (5s/15s/45s, injectable), ring buffer 100/5min while plugin unhealthy, flush + in order on recovery. Non-nil error or timeout counts as failure. +- **InvokeTool**: 30s timeout. +- **HandleWebhook**: kandev's HTTP endpoint `POST /api/plugins/{id}/webhooks/{key}` + converts the HTTP request to WebhookRequest and relays the WebhookResponse. +- **Health**: go-plugin client `Ping()` every 30s (injectable), 3 consecutive + failures → status `error` (+ restart attempt with backoff), recovery → `active` + + delivery flush. Crash (process exit) → immediate restart with backoff + (max 5 attempts, then `error`). +- **Capability gating**: a unary server interceptor on Host checks the plugin's + manifest capabilities (state, secrets; api_read/api_write reserved) and returns + PermissionDenied with `capability '' not declared`. + +## 6. Package format (`-.tar.gz`) + +``` +manifest.yaml # authoritative; read BEFORE any code runs +server/plugin--[.exe] # any subset; host platform key required at install +ui/bundle.js # optional (frontend half) +ui/*.css / assets/icon.svg # optional +checksums.txt # "sha256 path" for every other file +checksums.txt.sig # OPTIONAL ed25519 signature (unsigned → warn) +``` + +Manifest additions (replaces base_url; endpoints block is REMOVED): + +```yaml +runtime: + type: binary + executables: + linux-amd64: server/plugin-linux-amd64 + darwin-arm64: server/plugin-darwin-arm64 + # ... any subset +min_kandev_version: "0.78.0" # optional +``` + +Install pipeline: `POST /api/plugins/install` with JSON `{"url": "..."}` OR +multipart field `package` → verify checksums.txt covers all files & hashes match +→ parse+validate manifest (host platform key present; id pattern; capabilities) +→ extract to `~/.kandev/plugins///` → write record → status +`registered` → spawn → handshake OK → `active`. Record keeps `version` and +`install_path`. Uninstall stops the process and removes record + versions + data +(24h grace not required for v1). `POST /api/plugins/register` is REMOVED. + +## 7. Frontend deltas (PLUGIN-API.md otherwise unchanged) + +- `GET /api/plugins/{id}/bundle` and `/api/plugins/{id}/ui/*` are served by + kandev **from the extracted package dir** (no reverse proxy, no upstream). +- Management page: "Register plugin" (manifest paste) is replaced by "Install + plugin" (URL input + file upload). No credentials are ever displayed. +- Boot payload `plugins: [{id,name,bundleUrl,styleUrls}]` unchanged. +``` diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md new file mode 100644 index 0000000000..6a7891e7f9 --- /dev/null +++ b/docs/plans/plugins/PLUGIN-API.md @@ -0,0 +1,124 @@ +# Kandev Plugin API contract (native JS UI plugins — "option C") + +This is the frozen interface every frontend + example task builds against. Do not +diverge without updating this file. + +## Loading model + +1. Backend boot payload gains `plugins: ActivePlugin[]` where + `ActivePlugin = { id: string; name: string; bundleUrl: string; styleUrls?: string[] }`. + `bundleUrl` = `/api/plugins/{id}/bundle` — kandev serves this **directly from the + extracted package directory** on local disk + (`~/.kandev/plugins///ui/...`, per manifest `ui.bundle`). There is no + reverse proxy and no live upstream request: the plugin subprocess does not need to + be running to serve the UI bundle, since installation already extracted the file. +2. On SPA boot, the **plugin host** (`apps/web/lib/plugins/host.ts`) iterates + `bootPayload.plugins`, injects any `styleUrls` as ``, and dynamically + `import(/* @vite-ignore */ bundleUrl)` each bundle as a native ES module. +3. Each bundle, when evaluated, calls the global: + ```ts + window.registerKandevPlugin(pluginId, { + initialize(registry, host): void | Promise, + destroy?(): void, + }) + ``` +4. After the module resolves, the host calls `initialize(registry, host)`. On + plugin disable/uninstall the host calls `destroy?.()` and unregisters everything + that plugin added (registrations are tracked per pluginId). + +## Global entry point + +`window.registerKandevPlugin(id: string, plugin: KandevPlugin)` — defined by the +host before any bundle loads. Bundles are authored with React as an **external**; +they must use `host.React` (NOT bundle their own React) to share the host instance. + +## `host: PluginHostApi` + +```ts +interface PluginHostApi { + pluginId: string; + React: typeof import("react"); // host React instance (shared) + jsx: typeof React.createElement; // convenience alias (h) + store: { // kandev app store (zustand StoreApi) + getState(): AppState; + setState(partial): void; + subscribe(listener): () => void; + }; + api: { + // fetch scoped to this plugin's backend via kandev proxy: + // GET/POST {method} /api/plugins/{id}/... ; returns parsed JSON + fetch(path: string, init?: RequestInit): Promise; + }; + ui: Record; // curated @kandev/ui components (Button, Card, Badge...) + theme: "light" | "dark"; +} +``` + +## `registry: PluginRegistry` + +```ts +interface NavItem { id: string; label: string; path: string; icon?: string; section?: "main" | "settings"; } + +interface PluginRegistry { + // Top-level SPA route, e.g. "/jira". Component rendered by the SPA route resolver + // when window.location path === path (exact match; trailing segments via ":param" not + // required for v1 — exact + startsWith("/plugins/{id}") allowed). + registerRoute(path: string, Component: React.ComponentType): void; + + // Sidebar/main nav entry. Rendered by in the app sidebar. + registerNavItem(item: NavItem): void; + + // Route under /settings/plugins/{id}/... rendered inside settings shell. + registerSettingsRoute(path: string, Component: React.ComponentType): void; + + // Named slot injection. Host renders all components registered for a slot via + // . Initial slots: "task-sidebar", + // "settings-nav", "main-nav-footer". + registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void; + + // WS action handler. Bridged into the existing lib/ws dispatch; called with the + // decoded message payload for that action string. + registerWsHandler(action: string, handler: (payload: unknown) => void): void; +} +``` + +## Registry internals (host side) + +`apps/web/lib/plugins/registry.ts` holds a singleton `PluginRegistry` whose data +is reactive (a small zustand store or event emitter) so host React components +re-render when registrations change. Every registration records the owning +`pluginId` so the host can bulk-unregister on disable. Exposes read selectors: +`getRoutes()`, `getNavItems()`, `getSettingsRoutes()`, `getSlotComponents(slot)`, +`getWsHandlers(action)`. + +## Integration points the app must add (task-19) + +- `src/spa-routes.tsx`: after the static route switch, before the not-found + fallback, consult `registry.getRoutes()` for a matching path and render it inside + the normal app shell. +- `src/settings-routes.tsx`: consult `registry.getSettingsRoutes()` for + `/settings/plugins/{id}/*` paths. +- App sidebar (grep the nav list component): render `` reading + `registry.getNavItems()`. +- `lib/ws/router.ts` / `lib/ws/client.ts`: after built-in dispatch, forward the + message to any `registry.getWsHandlers(action)`. +- `components/plugins/plugin-slot.tsx`: `` renders all + slot components; drop into task detail sidebar + settings nav as initial hosts. + +## Security posture (documented, enforced where cheap) + +Plugin JS runs in the kandev origin with store access — this is the accepted +tradeoff of option C. v1 mitigations: only **active, operator-installed** plugins +load; bundles are served by kandev from the extracted package directory (same-origin, +no third-party CDN, no upstream network hop); host wraps `initialize` in try/catch so +a broken plugin can't crash boot; registrations namespaced + bulk-revocable per +plugin. No credentials are ever displayed to the operator — installing a plugin (via +URL or upload) has nothing to copy or reveal, unlike the old register flow's one-time +API key/webhook secret. Sandboxing plugin JS (worker/realms) is explicit future work. + +## Example plugin must (task-21) + +Ship a bundle that on `initialize` registers: a nav item "Hello" → route +`/plugins/hello` rendering a native page (uses `host.jsx` + `host.ui`), a +`task-sidebar` slot component, and a WS handler for `task.created` that updates a +counter in its page via the plugin's own module state. No bundled React. diff --git a/docs/plans/plugins/plan.md b/docs/plans/plugins/plan.md new file mode 100644 index 0000000000..13fadc48f5 --- /dev/null +++ b/docs/plans/plugins/plan.md @@ -0,0 +1,177 @@ +# Plan: Office Plugin System (Phase 1 + UI iframe proxy) + +**Spec:** `docs/specs/plugins/spec.md` +**Status:** in progress +**Owner:** jcfs + +## Goal + +Implement the plugin system end-to-end for an operator to register an +out-of-process HTTP plugin, have kandev deliver signed events to it, invoke its +tools, proxy external webhooks and iframe UI pages to it, expose plugin-scoped KV +state + secrets, monitor health, and manage it from a Plugins settings page — +all gated behind a new `plugins` feature flag. + +## In scope + +- Backend `internal/plugins/` package (provider pattern): manifest types + + validation, filesystem registration store, in-memory registry, lifecycle state + machine, health poller, event delivery (HMAC + retries + ring buffer), tool + invocation client, webhook + UI reverse proxies, `plugin_state` SQLite store, + auth middleware + capability checks, secrets resolution. +- `/api/plugins/*` management + plugin-facing API. +- `plugins` feature flag (profiles.yaml, `FeaturesConfig`, runtimeflags registry, + frontend `FeatureFlags`). +- Frontend Plugins settings page + plugin detail with sandboxed iframe pages. +- Fixture plugin binary for tests/e2e. +- Playwright e2e. + +## SCOPE REVISION — option C (native JS UI plugins) + +User chose the full Mattermost-webapp model: plugins ship JS bundles loaded into +the running SPA that register **native** routes/nav/components/reducers (goal: a +plugin can implement `/jira` as a native page). Iframe pages are dropped in favor +of this. The frozen frontend contract is `docs/plans/plugins/PLUGIN-API.md`. +Backend foundations (Wave 1 + task-05 service) are unchanged and reused. Waves +below are superseded by the "Option C waves" section. + +Still out of scope: marketplace/index, managed process runtime (kandev spawning +plugins), wiring plugin tools into live agent sessions (client + listing only), +rate limiting, plugin DB namespaces, hot reload, multi-instance, and plugin-JS +sandboxing (workers/realms — documented as future work; v1 loads only active +operator-registered plugins over same-origin proxy). + +## Architecture anchors (verified) + +- HTTP: gin; `registerRoutes(routeParams)` in + `apps/backend/internal/backendapp/helpers.go:478`; domain groups follow + `jira.RegisterRoutes(router, dispatcher, svc, log)` → `router.Group("/api/v1/jira")`. +- Service wiring: `apps/backend/internal/backendapp/services.go` `provideServices(...)` + with `initService(...)` helpers calling per-package `Provide(...)`; result + fields land on `Services` and `routeParams`. +- Event bus: `internal/events/bus.EventBus` (`Subscribe(subject, handler) (Subscription, error)`, + wildcard subjects supported); model delivery after + `internal/gateway/websocket/task_notifications.go` `RegisterTaskNotifications`. + Event names in `internal/events/types.go` (`events.TaskCreated="task.created"`, etc). +- Health: `internal/integrations/healthpoll` `Prober`/`Poller` (interval configurable). +- Secrets: `internal/secrets` + `internal/integrations/secretadapter.New(store)`. +- Persistence: SQLite; per-package `initSchema()` (see + `internal/task/repository/sqlite/base_schema.go`). No central migrations. +- UI proxy precedent: `internal/agentctl/server/api/` reverse proxy (header stripping). +- Feature flag pattern: `profiles.yaml` `features:` block → + `internal/common/config/config.go` `FeaturesConfig` (`config.go:174`) → + `internal/runtimeflags/registry.go` + `runtimeflags/config.go:31` → + frontend `apps/web/lib/state/slices/features/types.ts` + `hooks/domains/features/use-feature.ts`. + +## Waves + +### Wave 1 — backend foundations + fixture (parallel; disjoint files) +- **task-01** manifest types + validation (`internal/plugins/manifest/`) +- **task-02** filesystem registration store + credentials (`internal/plugins/store/`) +- **task-03** `plugin_state` SQLite store (`internal/plugins/state/`) +- **task-04** fixture plugin binary (`apps/backend/cmd/plugin-fixture/`) + +### Wave 2 — service, delivery, API, proxies (sequential within package) +- **task-05** registry + service + lifecycle state machine + `Provide` (`internal/plugins/service.go`, `registry.go`, `provider.go`) +- **task-06** event delivery subscriber (HMAC, retries, ring buffer) (`internal/plugins/delivery/`) +- **task-07** health poller integration (`internal/plugins/health.go`) +- **task-08** tool invocation client + webhook & UI reverse proxies (`internal/plugins/client.go`, `proxy.go`) +- **task-09** HTTP API + auth middleware + capability checks + backendapp wiring (`internal/plugins/handlers.go`, `middleware.go`; edits to `backendapp/services.go`, `helpers.go`) + +### Wave 3 — feature flag + frontend +- **task-10** `plugins` feature flag (backend + frontend plumbing) +- **task-11** plugins API client + Zustand slice (+ unit tests) +- **task-12** Plugins settings page (list/register/enable/disable/uninstall) + route +- **task-13** plugin detail with sandboxed iframe pages + postMessage bridge + +### Wave 4 — e2e + verify +- **task-14** Playwright e2e (register→active, enable/disable, iframe renders, event delivery) +- **task-15** spec update + docs + full `make fmt typecheck test lint` + web typecheck/lint + +## Option C waves (ACTIVE) + +Contract: `PLUGIN-API.md`. task-12/13 (iframe) superseded by task-19/20. + +### Wave A — foundations (parallel; disjoint) +- **task-06** event delivery (unchanged) — `internal/plugins/delivery/` +- **task-16** manifest `ui.bundle`/`ui.styles` fields — `internal/plugins/manifest/` +- **task-10** `plugins` feature flag (backend+frontend plumbing) +- **task-18** frontend Plugin API: globals + registry + host/loader — `apps/web/lib/plugins/` + +### Wave B — integration (after A) +- **task-17** backend API + auth/caps + state/secrets + webhook proxy + bundle proxy + + boot-payload `plugins` + health + backendapp wiring (merges old 07/08/09) — + needs task-06, task-16, task-10 +- **task-19** frontend dynamic integration: routes/nav/slots/ws bridge + PluginSlot — + needs task-18 +- **task-20** plugins management page (list/register/enable/disable) + API client + + slice — needs task-18, task-17 + +### Wave C — example + e2e + verify (after B) +- **task-21** example plugin repo (git init; backend + JS bundle + manifest + README) — + needs PLUGIN-API.md, task-16 +- **task-22** Playwright e2e loading the example plugin: native nav item + page + + slot + WS-driven update — needs task-17, task-19, task-20, task-21 +- **task-23** spec + docs update + full verify — needs all + +## gRPC transport waves (ACTIVE) + +Contract: `GRPC-CONTRACT.md` (frozen). Supersedes the HTTP+HMAC backend transport +from Wave 2 / Option C Wave B's task-17: the plugin **backend** now speaks gRPC over +a kandev-spawned `hashicorp/go-plugin` subprocess instead of HTTP with +API-key/HMAC-signed webhooks. Registration-by-manifest-paste +(`POST /api/plugins/register`, generated `api_key`/`webhook_secret`) is removed +outright — replaced by install-by-URL/upload of a release tarball. task-06 +(event delivery semantics), task-16 (manifest `ui.bundle`/`ui.styles`), task-10 +(feature flag), and all of Wave A's frontend plugin-host work (task-18/19/20's UI +layer) carry over unchanged; only the backend transport and the install flow change. + +- **G1 — proto + SDK.** Author `apps/backend/proto/kandev/plugin/v1/plugin.proto` + (`Plugin`/`Host` services, all messages per GRPC-CONTRACT.md §3), generate Go + stubs, and build `apps/backend/pkg/pluginsdk` (§4: `Plugin`/`Host` interfaces, + `Serve(p Plugin, opts...)`, `UnimplementedPlugin`). No transport wiring yet. +- **G2 — installer package.** Package format + install pipeline (§6): + checksum verification, optional ed25519 signature check (unsigned → warn), + manifest validation before any code runs, extraction to + `~/.kandev/plugins///`, and the on-disk install record + (id/version/install_path/status). No spawning yet. +- **G3 — runtime manager + host service + transport swap + install API + wiring.** + go-plugin process manager (spawn, handshake §2, `Ping`-based health, crash/backoff + restart with the state machine in the spec), the `Host` gRPC service + implementation (GetState/SetState/DeleteState/ListState/RevealSecret/EmitEvent) + with the capability-gating server interceptor, swap event delivery / tool + invocation / webhook proxy from HTTP calls to `DeliverEvent`/`InvokeTool`/ + `HandleWebhook` gRPC calls, `POST /api/plugins/install` handler (JSON `{url}` or + multipart `package`), removal of `POST /api/plugins/register` and all + api_key/webhook_secret/HMAC code paths, and `backendapp` wiring. +- **G4 — fixture + hello rewrite + frontend install UI.** Rewrite + `apps/backend/cmd/plugin-fixture/` as a `pluginsdk`-based Go binary (drop the + HTTP fixture server); rewrite the `kandev-plugin-hello` example repo's backend + half the same way (frontend bundle unchanged per PLUGIN-API.md §7); replace the + frontend "Register plugin" flow (manifest paste, credential display) with an + "Install plugin" flow (URL input + file upload, no credentials shown) per + PLUGIN-API.md's updated "Loading model"/"Security posture". +- **G5 — e2e + verify.** Playwright coverage for install-from-url, install-by-upload, + crash → restart → recovery (buffered event flush), and capability-denied + (gRPC `PermissionDenied`) scenarios; full `make fmt typecheck test lint` plus web + typecheck/lint/test. + +## Verification + +- Backend: `make -C apps/backend test`, `make -C apps/backend lint`, `make -C apps/backend build` +- Web: `cd apps/web && pnpm run typecheck && pnpm lint && pnpm test` +- E2E: `cd apps/web && pnpm e2e -g "plugin"` +- Final: `make -C apps/backend fmt` then `make -C apps/backend typecheck test lint` + +## Risks / open questions + +- Event `payload` shape: bus events carry `event.Data` as struct or + `map[string]interface{}`. Delivery must normalize to the spec's JSON envelope; + task-06 defines a `toEnvelope` that marshals `event.Data` under `payload`. +- `workspace_id` is not present on every event; envelope sets it when derivable, + else omits. Acceptable for Phase 1. +- Sequential-per-plugin delivery must not block the bus goroutine; task-06 uses a + per-plugin queue + worker. +- Feature-flag default must keep prod OFF; e2e/dev ON so tests run. + +**Status 2026-07-16:** all gRPC waves (G1–G5) completed. E2E green (2 specs, 3x repeat no flake); full backend+web gate green. One integration bug found by e2e and fixed (install response unwrapping in plugins-api.ts). diff --git a/docs/plans/plugins/task-01-manifest.md b/docs/plans/plugins/task-01-manifest.md new file mode 100644 index 0000000000..b0719fb780 --- /dev/null +++ b/docs/plans/plugins/task-01-manifest.md @@ -0,0 +1,50 @@ +--- +id: task-01 +title: Plugin manifest types + validation +status: done +wave: 1 +depends_on: [] +plan: docs/plans/plugins/plan.md +--- + +# Plugin manifest types + validation + +## Title +Define the plugin manifest Go types and a validator, parsed from YAML. + +## Inputs +- Spec `docs/specs/plugins/spec.md` → "Data model / Plugin registration" (the + YAML manifest: id, api_version, version, display_name, description, author, + categories, base_url, endpoints{health,events,tools,webhooks}, + capabilities{events,api_read,api_write,state,secrets}, tools[], webhooks[], + config_schema, ui.pages[] (NEW: `key`, `title`, `path`, `surface`)). +- id pattern: `^[a-z0-9][a-z0-9._-]*$`. +- categories enum: connector | automation | tools | analytics. +- `ui.pages[].surface` enum: settings | task-panel | main-nav. +- Follow `apps/backend/AGENTS.md` (package layout, small funcs, golangci limits). + +## Acceptance +1. `internal/plugins/manifest/manifest.go` defines `Manifest`, `Endpoints`, + `Capabilities`, `Tool`, `Webhook`, `UIPage` structs with yaml + json tags. +2. `Parse([]byte) (*Manifest, error)` parses YAML; `(*Manifest) Validate() error` + returns a joined/first error for: bad id pattern, missing base_url, missing + required endpoints, unknown category, unknown ui surface, duplicate tool/webhook + keys, `api_version != 1`. +3. Capability helpers: `HasEvent(name string) bool` (supports wildcard match so + `task.*` matches `task.created`), `CanRead(resource) bool`, `CanWrite(resource) bool`. + +## Files +- `apps/backend/internal/plugins/manifest/manifest.go` +- `apps/backend/internal/plugins/manifest/manifest_test.go` +- `apps/backend/internal/plugins/manifest/match.go` (wildcard subject match) + test + +## Verification +- `go test ./internal/plugins/manifest/...` from `apps/backend` +- `make -C apps/backend lint` + +## Output contract +Report: types added, validation rules covered by tests, wildcard match behavior, +any spec ambiguity encountered. Do not touch files outside `internal/plugins/manifest/`. + +## Dependencies +None. diff --git a/docs/plans/plugins/task-02-store.md b/docs/plans/plugins/task-02-store.md new file mode 100644 index 0000000000..9243af28d8 --- /dev/null +++ b/docs/plans/plugins/task-02-store.md @@ -0,0 +1,64 @@ +--- +id: task-02 +title: Filesystem registration store + credentials +status: done +wave: 1 +depends_on: [task-01] +plan: docs/plans/plugins/plan.md +--- + +# Filesystem registration store + credentials + +## Title +Persist plugin registrations to `~/.kandev/plugins/{id}.yml` with a bcrypt-hashed +api_key and an encrypted (recoverable) webhook secret, plus operator config at +`{id}.config.yml`. + +## Inputs +- Spec `docs/specs/plugins/spec.md` → "Plugin registration (filesystem-backed)" + and "Persistence guarantees". +- **Credential storage refinement (resolves a spec inconsistency — capture in the + task-15 spec update):** + - `api_key`: kandev only ever *verifies* inbound requests → store as **bcrypt + hash** only. Cleartext returned once at registration. + - `webhook_secret`: kandev must *HMAC-sign* outbound event deliveries → + bcrypt (one-way) is impossible. Store the secret in the **encrypted secrets + store** (`internal/secrets` via `internal/integrations/secretadapter.Adapter`, + which is reversible via `Reveal`). The `{id}.yml` keeps only a secret + *reference id*, not the value. Cleartext returned once at registration. +- Uses `Manifest` from task-01 (import `internal/plugins/manifest`). +- bcrypt: `golang.org/x/crypto/bcrypt`. Secret adapter interface: inject a small + interface `SecretVault { Set(ctx,id,name,value) error; Reveal(ctx,id) (string,error); Delete(ctx,id) error }` + so the store is testable with a fake (the real one is `secretadapter.Adapter`). + +## Acceptance +1. `Record` struct = manifest + runtime fields (status, api_key_hash, + webhook_secret_ref, registered_at, last_health_check). +2. `store.Store` interface: `List`, `Get(id)`, `Save(*Record)`, `Delete(id)`, + `GetConfig(id)`, `SetConfig(id, map)`. +3. `NewFSStore(dir string, vault SecretVault)`; `Register(ctx, manifest) (*Record, Credentials, error)` + generates random `api_key`+`webhook_secret` (crypto/rand), bcrypt-hashes the + api_key, stores the webhook_secret in the vault under ref + `plugin-webhook-secret:{id}`, writes `{id}.yml`, returns cleartext + `Credentials{APIKey, WebhookSecret}` once. +4. `VerifyAPIKey(id, key) (bool, error)` (bcrypt compare); + `RevealWebhookSecret(ctx, id) (string, error)` (vault Reveal). +5. Duplicate id on Register → typed `ErrAlreadyExists`. `Delete` also deletes the + vault secret. + +## Files +- `apps/backend/internal/plugins/store/store.go` +- `apps/backend/internal/plugins/store/fs_store.go` +- `apps/backend/internal/plugins/store/fs_store_test.go` (`t.TempDir()` + fake vault) + +## Verification +- `go test ./internal/plugins/store/...` from `apps/backend` +- `make -C apps/backend lint` + +## Output contract +Report: file format, the api_key(bcrypt)/webhook_secret(encrypted-ref) split, the +SecretVault interface shape (so task-05/06 can reveal the secret for signing). +Do not edit `internal/plugins/manifest/` (import only). Stay within `internal/plugins/store/`. + +## Dependencies +task-01 (imports manifest types). diff --git a/docs/plans/plugins/task-03-state-store.md b/docs/plans/plugins/task-03-state-store.md new file mode 100644 index 0000000000..6790775355 --- /dev/null +++ b/docs/plans/plugins/task-03-state-store.md @@ -0,0 +1,54 @@ +--- +id: task-03 +title: plugin_state SQLite store +status: done +wave: 1 +depends_on: [] +plan: docs/plans/plugins/plan.md +--- + +# plugin_state SQLite store + +## Title +SQLite-backed plugin-scoped KV state store following the per-package +`initSchema()` convention. + +## Inputs +- Spec `docs/specs/plugins/spec.md` → "`plugin_state` (SQLite)" and + "Plugin state API". Table: + ```sql + CREATE TABLE plugin_state ( + id TEXT PRIMARY KEY, plugin_id TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'instance', scope_id TEXT, + state_key TEXT NOT NULL, value_json TEXT NOT NULL, updated_at TEXT NOT NULL, + UNIQUE (plugin_id, scope, scope_id, state_key)); + ``` +- Scopes: instance | workspace | task | agent. Plugins cannot read others' state + (enforced by always filtering on plugin_id — the API layer passes it, task-09). +- Pattern reference: `internal/task/repository/sqlite/base_schema.go` (initSchema + with `CREATE TABLE IF NOT EXISTS`) and how repos take a `*sql.DB`/db handle. + Use the same db handle type the other stores use (`db.Pool` writer/reader or + `*sql.DB` — match sibling stores; inspect `internal/sentry/store.go`). + +## Acceptance +1. `StateStore` with `initSchema()` creating `plugin_state` (idempotent). +2. Methods: `Get(ctx, pluginID, scope, scopeID, key) (json.RawMessage, bool, error)`, + `Set(ctx, pluginID, scope, scopeID, key, value)` (upsert on the UNIQUE key), + `Delete(ctx, pluginID, scope, scopeID, key)`, + `List(ctx, pluginID, scope, scopeID) ([]StateEntry, error)`. +3. `updated_at` set to RFC3339 UTC on write. NULL `scope_id` handled for instance scope. + +## Files +- `apps/backend/internal/plugins/state/store.go` +- `apps/backend/internal/plugins/state/store_test.go` (in-memory or temp-file sqlite) + +## Verification +- `go test ./internal/plugins/state/...` from `apps/backend` +- `make -C apps/backend lint` + +## Output contract +Report: db handle type chosen (match siblings), upsert approach, scope_id NULL +handling. Stay within `internal/plugins/state/`. + +## Dependencies +None. diff --git a/docs/plans/plugins/task-04-fixture.md b/docs/plans/plugins/task-04-fixture.md new file mode 100644 index 0000000000..8d7660e799 --- /dev/null +++ b/docs/plans/plugins/task-04-fixture.md @@ -0,0 +1,53 @@ +--- +id: task-04 +title: Fixture plugin binary +status: done +wave: 1 +depends_on: [] +plan: docs/plans/plugins/plan.md +--- + +# Fixture plugin binary + +## Title +Standalone Go HTTP server implementing the plugin contract, for Go integration +tests and Playwright e2e. + +## Inputs +- Spec `docs/specs/plugins/spec.md` → event delivery headers/HMAC, tool + invocation, webhook proxy, UI page contract. +- Must be self-contained: only stdlib + `crypto/hmac`,`crypto/sha256`. NO imports + from `internal/plugins` (proves the contract is decoupled). + +## Acceptance +1. `apps/backend/cmd/plugin-fixture/main.go` starts an HTTP server on a port from + `-addr`/`PLUGIN_FIXTURE_ADDR` (default `127.0.0.1:0` → print chosen addr on + stdout as `LISTENING ` so tests can capture it). +2. Endpoints: + - `GET /health` → 200 `{"status":"ok"}`. + - `POST /events` → verifies `X-Kandev-Signature: sha256=` against + `HMAC-SHA256(secret, rawBody)` using secret from `-secret`/`PLUGIN_FIXTURE_SECRET`; + records delivery (event_type + event_id) in memory; returns 200. Bad sig → 401. + - `POST /tools/echo` → returns `{"output": }`. + - `POST /webhooks/test-hook` → records the call; returns 200. + - `GET /ui/dashboard` → returns a small HTML page with `

Fixture Plugin

` + and a `