From 6033d5717c587156d3f0ff5d90e76cd46a636952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Wed, 12 Aug 2026 11:14:50 +0200 Subject: [PATCH] feat(plugins): add voice extraction host APIs (#2548) Co-authored-by: Kandev Agent --- .../internal/auth/httpmw/middleware_test.go | 24 ++ apps/backend/internal/plugins/handlers.go | 41 ++- .../backend/internal/plugins/handlers_test.go | 72 ++++- .../internal/plugins/manifest/manifest.go | 29 +- .../plugins/manifest/manifest_test.go | 30 ++ .../internal/plugins/manifest/validate.go | 9 + apps/backend/pkg/pluginsdk/serve.go | 5 +- .../task-create-dialog-footer.test.ts | 15 + .../components/task-create-dialog-footer.tsx | 6 + .../task-create-dialog-form-body.test.tsx | 2 +- .../task-create-dialog-form-body.tsx | 2 +- .../task-create-dialog-selectors.test.tsx | 54 ++++ .../task-create-dialog-selectors.tsx | 69 ++++- .../components/task-create-dialog-types.ts | 2 +- .../components/task-create-dialog.test.tsx | 1 + apps/web/components/task-create-dialog.tsx | 30 +- .../components/task/chat/chat-input-body.tsx | 48 +++- .../task/chat/chat-input-plugin-actions.tsx | 38 ++- .../task/chat/chat-input-toolbar-desktop.tsx | 8 + .../task/chat/chat-input-toolbar-mobile.tsx | 9 + .../task/chat/chat-input-toolbar.tsx | 3 + .../components/task/chat/use-tiptap-editor.ts | 2 + .../components/task/new-session-dialog.tsx | 6 +- .../lib/plugins/composer-capability.test.ts | 32 +++ apps/web/lib/plugins/composer-capability.ts | 35 +++ apps/web/lib/plugins/types.ts | 26 ++ ...-composer-access-authenticated-webhooks.md | 50 ++++ docs/decisions/INDEX.md | 1 + .../plugin-voice-extraction-host/plan.md | 147 ++++++++++ .../task-01-publish-composer-contract.md | 44 +++ .../task-02-authenticated-webhook-manifest.md | 39 +++ .../task-03-chat-composer-adapters.md | 48 ++++ .../task-04-creation-composer-adapters.md | 50 ++++ .../task-05-authenticated-webhook-handler.md | 41 +++ .../task-06-docs-e2e-verification.md | 54 ++++ docs/plans/plugins/PLUGIN-API.md | 130 +++++++-- docs/public/plugins-authoring.md | 262 ++++++++++-------- docs/public/plugins-manifest.md | 226 ++++++++------- docs/specs/INDEX.md | 1 + docs/specs/plugins/voice-extraction-host.md | 201 ++++++++++++++ 40 files changed, 1609 insertions(+), 283 deletions(-) create mode 100644 apps/web/lib/plugins/composer-capability.test.ts create mode 100644 apps/web/lib/plugins/composer-capability.ts create mode 100644 docs/decisions/2026-08-11-composer-access-authenticated-webhooks.md create mode 100644 docs/plans/plugin-voice-extraction-host/plan.md create mode 100644 docs/plans/plugin-voice-extraction-host/task-01-publish-composer-contract.md create mode 100644 docs/plans/plugin-voice-extraction-host/task-02-authenticated-webhook-manifest.md create mode 100644 docs/plans/plugin-voice-extraction-host/task-03-chat-composer-adapters.md create mode 100644 docs/plans/plugin-voice-extraction-host/task-04-creation-composer-adapters.md create mode 100644 docs/plans/plugin-voice-extraction-host/task-05-authenticated-webhook-handler.md create mode 100644 docs/plans/plugin-voice-extraction-host/task-06-docs-e2e-verification.md create mode 100644 docs/specs/plugins/voice-extraction-host.md diff --git a/apps/backend/internal/auth/httpmw/middleware_test.go b/apps/backend/internal/auth/httpmw/middleware_test.go index 9e683c0ae0..232530219e 100644 --- a/apps/backend/internal/auth/httpmw/middleware_test.go +++ b/apps/backend/internal/auth/httpmw/middleware_test.go @@ -144,6 +144,30 @@ func TestEnabledModePATAuthenticates(t *testing.T) { } } +func TestEnabledModePATAuthenticatesPluginWebhook(t *testing.T) { + svc := newTestService(t, true) + setupAdmin(t, svc) + _, pat, err := svc.MintToken(context.Background(), userstore.DefaultUserID, "plugin-ui", 0) + if err != nil { + t.Fatal(err) + } + router := newTestRouter(svc) + router.POST("/api/plugins/:id/webhooks/:key", func(c *gin.Context) { + if _, ok := authn.FromGin(c); !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "no identity"}) + return + } + c.Status(http.StatusOK) + }) + + rec := doRequest(router, http.MethodPost, "/api/plugins/p1/webhooks/transcribe", func(r *http.Request) { + r.Header.Set("Authorization", "Bearer "+pat) + }) + if rec.Code != http.StatusOK { + t.Fatalf("authenticated plugin webhook: %d body=%s", rec.Code, rec.Body.String()) + } +} + // TestEnabledModeAllowlistMatrix pins the pass/deny policy for every path // class from the plan's allowlist spec. 404 = middleware passed through // (route unregistered), 401 = middleware denied. diff --git a/apps/backend/internal/plugins/handlers.go b/apps/backend/internal/plugins/handlers.go index 23a35b50e3..583c8bebd3 100644 --- a/apps/backend/internal/plugins/handlers.go +++ b/apps/backend/internal/plugins/handlers.go @@ -11,7 +11,9 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/plugins/manifest" "github.com/kandev/kandev/internal/plugins/pkgtar" "github.com/kandev/kandev/internal/plugins/store" "github.com/kandev/kandev/pkg/pluginsdk" @@ -24,7 +26,7 @@ import ( // 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 +const maxWebhookBodyBytes = manifest.DefaultWebhookMaxBodyBytes // Controller holds the plugin HTTP handlers: operator-facing management // (install/list/get/config/uninstall/enable/disable), the bundle/UI @@ -372,12 +374,19 @@ func (c *Controller) webhook(ctx *gin.Context) { } key := ctx.Param("key") - if !manifestDeclaresWebhookKey(record, key) { + declaration, ok := findWebhookDeclaration(record, key) + if !ok { ctx.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("plugin %q has no webhook %q", id, key)}) return } + if declaration.EffectiveAccess() == manifest.WebhookAccessAuthenticated { + if _, authenticated := authn.FromGin(ctx); !authenticated { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + } - body, err := readCappedWebhookBody(ctx) + body, err := readCappedWebhookBody(ctx, declaration.EffectiveMaxBodyBytes()) if err != nil { return // readCappedWebhookBody already wrote the error response } @@ -386,7 +395,7 @@ func (c *Controller) webhook(ctx *gin.Context) { WebhookKey: key, Method: ctx.Request.Method, Query: ctx.Request.URL.RawQuery, - Headers: flattenHeaders(ctx.Request.Header), + Headers: flattenWebhookHeaders(ctx.Request.Header), Body: body, } @@ -479,13 +488,18 @@ func (c *Controller) applyAuthLogin(ctx *gin.Context, record *store.Record, raw // manifestDeclaresWebhookKey reports whether record's manifest declares a // webhooks[] entry with the given key. -func manifestDeclaresWebhookKey(record *store.Record, key string) bool { +func findWebhookDeclaration(record *store.Record, key string) (manifest.Webhook, bool) { for _, wh := range record.Webhooks { if wh.Key == key { - return true + return wh, true } } - return false + return manifest.Webhook{}, false +} + +func manifestDeclaresWebhookKey(record *store.Record, key string) bool { + _, ok := findWebhookDeclaration(record, key) + return ok } // readCappedWebhookBody reads ctx.Request.Body bounded at @@ -493,14 +507,14 @@ func manifestDeclaresWebhookKey(record *store.Record, key string) bool { // 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) +func readCappedWebhookBody(ctx *gin.Context, maxBytes int64) ([]byte, error) { + ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxBytes) 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), + "error": fmt.Sprintf("webhook body exceeds max size of %d bytes", maxBytes), }) return nil, err } @@ -521,3 +535,10 @@ func flattenHeaders(h http.Header) map[string]string { } return out } + +func flattenWebhookHeaders(h http.Header) map[string]string { + safe := h.Clone() + safe.Del("Authorization") + safe.Del("Cookie") + return flattenHeaders(safe) +} diff --git a/apps/backend/internal/plugins/handlers_test.go b/apps/backend/internal/plugins/handlers_test.go index 0d61c13eed..d09ae60425 100644 --- a/apps/backend/internal/plugins/handlers_test.go +++ b/apps/backend/internal/plugins/handlers_test.go @@ -387,6 +387,33 @@ runtime: return &buf } +func authenticatedWebhookPackage(t *testing.T, id, key string, maxBodyBytes int64) *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 + access: authenticated + max_body_bytes: %d +runtime: + type: binary + executables: + %s: server/plugin +`, id, key, maxBodyBytes, platformKey) + var buf bytes.Buffer + if err := pkgtartest.WritePackage(&buf, map[string][]byte{ + "manifest.yaml": []byte(manifestYAML), + "server/plugin": []byte("#!/bin/sh\necho fake\n"), + }); 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) @@ -420,13 +447,56 @@ func TestWebhookHandlerOversizedBodyReturns413(t *testing.T) { t.Fatalf("Install: %v", err) } - oversized := strings.Repeat("a", maxWebhookBodyBytes+1) + oversized := strings.Repeat("a", int(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 TestAuthenticatedWebhookRejectsAnonymousRequest(t *testing.T) { + router, svc := newTestRouter(t) + if _, err := svc.Install(t.Context(), authenticatedWebhookPackage(t, "kandev-plugin-voice", "transcribe", 16<<20)); err != nil { + t.Fatalf("Install: %v", err) + } + rec := doRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-voice/webhooks/transcribe", "audio", nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401, body=%s", rec.Code, rec.Body.String()) + } +} + +func TestReadCappedWebhookBodyUsesDeclaredLimit(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader("12345")) + _, err := readCappedWebhookBody(ctx, 4) + if err == nil || rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("error = %v, status = %d, want size error and 413", err, rec.Code) + } +} + +func TestWebhookRelayHeadersStripHostCredentials(t *testing.T) { + headers := http.Header{ + "Authorization": []string{"Bearer kandev-pat"}, + "Cookie": []string{"kandev_session=secret"}, + "Content-Type": []string{"audio/webm"}, + "X-Plugin-Key": []string{"plugin-secret"}, + } + + got := flattenWebhookHeaders(headers) + + if _, ok := got["Authorization"]; ok { + t.Fatal("Authorization header was forwarded to plugin") + } + if _, ok := got["Cookie"]; ok { + t.Fatal("Cookie header was forwarded to plugin") + } + if got["Content-Type"] != "audio/webm" || got["X-Plugin-Key"] != "plugin-secret" { + t.Fatalf("relay-safe headers = %#v", got) + } +} + func TestWebhookHandlerNotRunningReturns503(t *testing.T) { router, svc := newTestRouter(t) if _, err := svc.Install(t.Context(), webhookPackage(t, "kandev-plugin-slack", "key1")); err != nil { diff --git a/apps/backend/internal/plugins/manifest/manifest.go b/apps/backend/internal/plugins/manifest/manifest.go index ab9ad71279..5bf52d0350 100644 --- a/apps/backend/internal/plugins/manifest/manifest.go +++ b/apps/backend/internal/plugins/manifest/manifest.go @@ -111,9 +111,32 @@ type AuthProvider struct { // 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"` + Key string `yaml:"key" json:"key"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Method string `yaml:"method,omitempty" json:"method,omitempty"` + Access string `yaml:"access,omitempty" json:"access,omitempty"` + MaxBodyBytes int64 `yaml:"max_body_bytes,omitempty" json:"max_body_bytes,omitempty"` +} + +const ( + WebhookAccessPublic = "public" + WebhookAccessAuthenticated = "authenticated" + DefaultWebhookMaxBodyBytes int64 = 4 << 20 + MaximumWebhookMaxBodyBytes int64 = 16 << 20 +) + +func (w Webhook) EffectiveAccess() string { + if w.Access == "" { + return WebhookAccessPublic + } + return w.Access +} + +func (w Webhook) EffectiveMaxBodyBytes() int64 { + if w.MaxBodyBytes == 0 { + return DefaultWebhookMaxBodyBytes + } + return w.MaxBodyBytes } // UISection declares UI pages the plugin contributes, and/or a native UI diff --git a/apps/backend/internal/plugins/manifest/manifest_test.go b/apps/backend/internal/plugins/manifest/manifest_test.go index 539dc068e2..51040dbd89 100644 --- a/apps/backend/internal/plugins/manifest/manifest_test.go +++ b/apps/backend/internal/plugins/manifest/manifest_test.go @@ -455,6 +455,36 @@ func TestParseKeybindingCombo_RejectsNoNonModifierKey(t *testing.T) { } } +func TestWebhookAccessAndBodyLimitValidation(t *testing.T) { + tests := []struct { + name string + webhook Webhook + wantErr string + }{ + {name: "legacy defaults", webhook: Webhook{Key: "transcribe"}}, + {name: "authenticated 16 MiB", webhook: Webhook{Key: "transcribe", Access: "authenticated", MaxBodyBytes: 16 << 20}}, + {name: "public over default ceiling", webhook: Webhook{Key: "transcribe", Access: "public", MaxBodyBytes: (4 << 20) + 1}, wantErr: "authenticated"}, + {name: "unknown access", webhook: Webhook{Key: "transcribe", Access: "private"}, wantErr: "access"}, + {name: "over host ceiling", webhook: Webhook{Key: "transcribe", MaxBodyBytes: (16 << 20) + 1}, wantErr: "max_body_bytes"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := validManifest(t) + m.Webhooks = []Webhook{tt.webhook} + err := m.Validate() + if tt.wantErr == "" { + if err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Validate() error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + func TestParseKeybindingCombo_RejectsUnknownModifier(t *testing.T) { err := parseKeybindingCombo("hyper+k") if err == nil { diff --git a/apps/backend/internal/plugins/manifest/validate.go b/apps/backend/internal/plugins/manifest/validate.go index ab4d6a3e09..8ff8bbf5af 100644 --- a/apps/backend/internal/plugins/manifest/validate.go +++ b/apps/backend/internal/plugins/manifest/validate.go @@ -395,6 +395,15 @@ func (m *Manifest) validateWebhooks() []error { seen := make(map[string]bool, len(m.Webhooks)) var errs []error for _, wh := range m.Webhooks { + if access := wh.EffectiveAccess(); access != WebhookAccessPublic && access != WebhookAccessAuthenticated { + errs = append(errs, fmt.Errorf("webhook %q access %q is invalid", wh.Key, wh.Access)) + } + if wh.MaxBodyBytes < 0 || wh.MaxBodyBytes > MaximumWebhookMaxBodyBytes { + errs = append(errs, fmt.Errorf("webhook %q max_body_bytes must be between 1 and %d when set", wh.Key, MaximumWebhookMaxBodyBytes)) + } + if wh.EffectiveAccess() == WebhookAccessPublic && wh.MaxBodyBytes > DefaultWebhookMaxBodyBytes { + errs = append(errs, fmt.Errorf("webhook %q must use authenticated access when max_body_bytes exceeds %d", wh.Key, DefaultWebhookMaxBodyBytes)) + } if seen[wh.Key] { errs = append(errs, fmt.Errorf("duplicate webhook key %q", wh.Key)) continue diff --git a/apps/backend/pkg/pluginsdk/serve.go b/apps/backend/pkg/pluginsdk/serve.go index 70e5c933c1..d4d99406fa 100644 --- a/apps/backend/pkg/pluginsdk/serve.go +++ b/apps/backend/pkg/pluginsdk/serve.go @@ -247,6 +247,9 @@ func Serve(p Plugin, opts ...Option) { hcplugin.Serve(&hcplugin.ServeConfig{ HandshakeConfig: Handshake, Plugins: map[string]hcplugin.Plugin{PluginMapKey: gp}, - GRPCServer: hcplugin.DefaultGRPCServer, + GRPCServer: func(options []grpc.ServerOption) *grpc.Server { + // The webhook HTTP body may be 16 MiB; leave room for protobuf metadata. + return grpc.NewServer(append(options, grpc.MaxRecvMsgSize(17<<20))...) + }, }) } diff --git a/apps/web/components/task-create-dialog-footer.test.ts b/apps/web/components/task-create-dialog-footer.test.ts index b0d585506f..8be6fac153 100644 --- a/apps/web/components/task-create-dialog-footer.test.ts +++ b/apps/web/components/task-create-dialog-footer.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { computeDisabledReason, + isNativeSubmitDisabled, resolveDisabledReason, REASON_NO_COMPATIBLE_AGENT, REASON_TITLE, @@ -47,6 +48,20 @@ function makeProps( }; } +describe("isNativeSubmitDisabled", () => { + it("matches pending uploads and other externally blocked native submissions", () => { + expect(isNativeSubmitDisabled(makeProps({ submitBlockedReason: "upload pending" }))).toBe(true); + }); + + it("matches missing-profile rejection for the start-task path", () => { + expect(isNativeSubmitDisabled(makeProps({ agentProfileId: "" }))).toBe(true); + }); + + it("allows a complete native submission", () => { + expect(isNativeSubmitDisabled(makeProps())).toBe(false); + }); +}); + describe("computeDisabledReason (start-task)", () => { it("returns null when nothing is missing", () => { expect(computeDisabledReason(makeProps(), KIND_START)).toBeNull(); diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index a5b22476d9..e2b78a97f1 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -357,6 +357,12 @@ function computeFooterState(props: TaskCreateDialogFooterProps) { return { showStartTask, splitDisabled, altDisabled, defaultDisabled, disabledReason }; } +export function isNativeSubmitDisabled(props: TaskCreateDialogFooterProps): boolean { + const { showStartTask, splitDisabled, defaultDisabled } = computeFooterState(props); + if (props.isTaskStarted) return props.isCreatingTask || !props.hasTitle; + return showStartTask ? splitDisabled : defaultDisabled; +} + export const TaskCreateDialogFooter = memo(function TaskCreateDialogFooter( props: TaskCreateDialogFooterProps, ) { diff --git a/apps/web/components/task-create-dialog-form-body.test.tsx b/apps/web/components/task-create-dialog-form-body.test.tsx index 884acc0f68..3bd0e3ea96 100644 --- a/apps/web/components/task-create-dialog-form-body.test.tsx +++ b/apps/web/components/task-create-dialog-form-body.test.tsx @@ -217,7 +217,7 @@ describe("DialogPromptSection (CLI-mode parity)", () => { it("forwards onVoiceAutoSend to TaskFormInputs", () => { taskFormInputsCalls.length = 0; - const onVoiceAutoSend = () => {}; + const onVoiceAutoSend = () => true; render( void; + onVoiceAutoSend?: () => boolean | Promise; }; // importBindings collapses the optional Jira/Linear import callbacks into the diff --git a/apps/web/components/task-create-dialog-selectors.test.tsx b/apps/web/components/task-create-dialog-selectors.test.tsx index ef62f092d0..34d40ec7c0 100644 --- a/apps/web/components/task-create-dialog-selectors.test.tsx +++ b/apps/web/components/task-create-dialog-selectors.test.tsx @@ -12,6 +12,7 @@ import { import { formatBytes } from "@/lib/utils/format-bytes"; import { TaskFormInputs } from "./task-create-dialog-selectors"; import type { TaskFormInputsHandle } from "./task-create-dialog-types"; +import type { PluginComposerSlotProps } from "@/lib/plugins/types"; const TOAST_MESSAGE_TEST_ID = "toast-message"; @@ -32,6 +33,7 @@ type VoiceProps = { disabled?: boolean; }; const voiceCalls: VoiceProps[] = []; +const pluginSlotCalls: PluginComposerSlotProps[] = []; vi.mock("@/components/task/chat/voice-input-button", () => ({ VoiceInputButton: (props: VoiceProps) => { voiceCalls.push(props); @@ -39,6 +41,13 @@ vi.mock("@/components/task/chat/voice-input-button", () => ({ }, })); +vi.mock("@/components/plugins/plugin-slot", () => ({ + PluginSlot: ({ slotProps }: { slotProps: PluginComposerSlotProps }) => { + pluginSlotCalls.push(slotProps); + return null; + }, +})); + // Inert mention popover — the real hook installs a `keydown` listener that // drains React's event queue across re-renders and adds noise to assertions. vi.mock("@/hooks/use-task-create-prompt-mention", () => ({ @@ -60,10 +69,17 @@ vi.mock("@/hooks/use-task-create-prompt-mention", () => ({ afterEach(() => { cleanup(); voiceCalls.length = 0; + pluginSlotCalls.length = 0; vi.restoreAllMocks(); vi.mocked(processFile).mockReset(); }); +function lastPluginSlotProps(): PluginComposerSlotProps { + const last = pluginSlotCalls.at(-1); + if (!last) throw new Error("PluginSlot was not rendered"); + return last; +} + function lastVoiceProps(): VoiceProps { const last = voiceCalls.at(-1); if (!last) throw new Error("VoiceInputButton was not rendered"); @@ -158,6 +174,44 @@ describe("TaskFormInputs voice-input wiring — rendering", () => { }); }); +describe("TaskFormInputs plugin composer submission", () => { + it("reports blocked when the native creation path rejects submission", async () => { + const ref = createRef(); + render( + {}} + onKeyDown={() => {}} + descriptionValueRef={ref} + onVoiceAutoSend={() => false} + />, + { wrapper: Wrapper }, + ); + + await expect(lastPluginSlotProps().composer.submit()).resolves.toEqual({ status: "blocked" }); + }); + + it("reports submitted only when the native creation path accepts submission", async () => { + const ref = createRef(); + render( + {}} + onKeyDown={() => {}} + descriptionValueRef={ref} + onVoiceAutoSend={() => true} + />, + { wrapper: Wrapper }, + ); + + await expect(lastPluginSlotProps().composer.submit()).resolves.toEqual({ status: "submitted" }); + }); +}); + describe("TaskFormInputs voice-input wiring — at-cursor splice", () => { it("splices the transcript at the caret with a leading space after a word", () => { const { textarea } = renderTaskFormInputs("hello world"); diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 6c457b5fb8..911efd839e 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -42,6 +42,9 @@ import { deleteAttachment, uploadAttachment } from "@/lib/api/domains/attachment import { ApiError } from "@/lib/api/client"; import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { createPluginComposerCapability } from "@/lib/plugins/composer-capability"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; const CURSOR_POINTER_CLASS = "cursor-pointer"; @@ -330,6 +333,7 @@ export const InlineTaskName = memo(function InlineTaskName({ // Memoized description input to prevent re-rendering the entire dialog on every keystroke type TaskFormInputsProps = { isSessionMode: boolean; + taskId?: string | null; workspaceId?: string | null; autoFocus?: boolean; initialDescription: string; @@ -357,7 +361,7 @@ type TaskFormInputsProps = { * when the user has voice auto-send enabled. The dialog wires this to a * programmatic form submit so dictation can create the task hands-free. */ - onVoiceAutoSend?: () => void; + onVoiceAutoSend?: () => boolean | Promise; }; // eslint-disable-next-line max-lines-per-function @@ -697,6 +701,7 @@ type FormInputsToolbarProps = { onTranscript: (text: string) => void; onAutoSend?: () => void; }; + pluginActions?: React.ReactNode; }; function FormInputsToolbar({ @@ -708,6 +713,7 @@ function FormInputsToolbar({ jiraImport, linearImport, voice, + pluginActions, }: FormInputsToolbarProps) { return (
@@ -733,6 +739,7 @@ function FormInputsToolbar({ onImport={linearImport.onImport} /> )} + {pluginActions} {voice && (
; + insertAtCursor: (text: string) => void; + submit?: () => boolean | Promise; +}) { + const { isMobile } = useResponsiveBreakpoint(); + const handle = useMemo( + () => + createPluginComposerCapability({ + insertText: (text) => { + args.insertAtCursor(text); + return true; + }, + focus: () => { + if (!args.textareaRef.current) return false; + args.textareaRef.current.focus(); + return true; + }, + submit: async () => { + if (args.disabled || !args.description.trim() || !args.submit) return false; + return await args.submit(); + }, + }), + [args], + ); + useEffect(() => () => handle.revoke(), [handle]); + return ( + 0, + composer: handle.api, + }} + /> + ); +} + function useTextareaHandlers( mention: ReturnType, onKeyDown: TaskFormInputsProps["onKeyDown"], @@ -830,6 +884,8 @@ function DraggingOverlay({ isDragging }: { isDragging: boolean }) { ); } +// The input coordinates existing attachment, mention, voice, and plugin controls in one field. +// eslint-disable-next-line max-lines-per-function export const TaskFormInputs = memo(function TaskFormInputs({ workspaceId, isSessionMode, @@ -847,6 +903,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({ jiraImport, linearImport, onVoiceAutoSend, + taskId = null, }: TaskFormInputsProps) { const { t } = useTranslation(); const { @@ -884,6 +941,15 @@ export const TaskFormInputs = memo(function TaskFormInputs({ () => ({ onTranscript: insertAtCursor, onAutoSend: onVoiceAutoSend }), [insertAtCursor, onVoiceAutoSend], ); + const pluginActions = useCreationComposerPluginActions({ + isSessionMode, + taskId, + disabled: Boolean(disabled), + description, + textareaRef, + insertAtCursor, + submit: onVoiceAutoSend, + }); return (
diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index c9d576bd8a..e06855342b 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -505,5 +505,5 @@ export type DialogFormBodyProps = { * routes this to a programmatic form submit so dictation can create the task * hands-free. */ - onVoiceAutoSend?: () => void; + onVoiceAutoSend?: () => boolean | Promise; }; diff --git a/apps/web/components/task-create-dialog.test.tsx b/apps/web/components/task-create-dialog.test.tsx index 6f3dc983ab..28dd22e99d 100644 --- a/apps/web/components/task-create-dialog.test.tsx +++ b/apps/web/components/task-create-dialog.test.tsx @@ -64,6 +64,7 @@ vi.mock("@/hooks/use-keyboard-shortcut", () => ({ })); vi.mock("@/components/task-create-dialog-footer", () => ({ + isNativeSubmitDisabled: () => false, TaskCreateDialogFooter: () => null, })); diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 9b8ad8f07a..447052cf78 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -5,7 +5,10 @@ import { useTranslation } from "react-i18next"; import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog"; import type { Task } from "@/lib/types/http"; import type { TaskCreateLastUsedState } from "@/lib/state/slices/settings/types"; -import { TaskCreateDialogFooter } from "@/components/task-create-dialog-footer"; +import { + isNativeSubmitDisabled, + TaskCreateDialogFooter, +} from "@/components/task-create-dialog-footer"; import { DiscardLocalChangesDialog } from "@/components/discard-local-changes-dialog"; import { DialogHeaderContent } from "@/components/task-create-dialog-header"; import { @@ -286,14 +289,19 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) { } resetQueuedLastUsedOnClose(); }, [props.open, resetQueuedLastUsedOnClose]); - // Voice auto-send invokes the same submit handler as the in-form Submit - // button. Every existing validation gate (missing title/repo/branch/agent, - // `submitBlockedReason`, in-flight create) still applies because they live - // inside `handleSubmit` itself, so a dictation with incomplete fields - // silently no-ops rather than creating a malformed task. + // Programmatic submissions use the native control's preflight before + // entering the same guarded submit handler as the form button. + const pendingAttachmentReason = setup.fs.hasPendingAttachmentUploads + ? t("chat:attachmentUploadPendingSubmit") + : null; + const nativeSubmitDisabled = isNativeSubmitDisabled( + buildDialogFooterProps(setup, props, pendingAttachmentReason), + ); const handleVoiceAutoSend = useCallback(() => { + if (nativeSubmitDisabled) return false; guardedHandleSubmit(VOICE_SUBMIT_EVENT); - }, [guardedHandleSubmit]); + return true; + }, [guardedHandleSubmit, nativeSubmitDisabled]); return ( diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index b11d6b1b94..764aba4ba4 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; @@ -13,6 +13,8 @@ import type { ContextItem } from "@/lib/types/context"; import type { ContextFile } from "@/lib/state/context-files-store"; import type { ImagePasteIssue } from "./clipboard-attachments"; import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types"; +import { createPluginComposerCapability } from "@/lib/plugins/composer-capability"; +import type { PluginComposerCapability } from "@/lib/plugins/types"; export type ChatInputEditorAreaProps = { inputRef: React.RefObject; @@ -129,6 +131,41 @@ function FileInput({ ); } +function useChatPluginComposer(p: { + inputRef: ChatInputEditorAreaProps["inputRef"]; + submitDisabled: boolean; + isEnhancingPrompt: boolean; + onSubmit: () => void; +}): PluginComposerCapability { + const handle = useMemo( + () => + createPluginComposerCapability({ + insertText: (text) => { + const editor = p.inputRef.current; + if (!editor) return false; + const from = editor.getSelectionStart(); + const prefix = from > 0 && !/\s/.test(editor.getValue().charAt(from - 1)) ? " " : ""; + editor.insertText(prefix + text.trim(), from, editor.getSelectionEnd()); + editor.focus(); + return true; + }, + focus: () => { + if (!p.inputRef.current) return false; + p.inputRef.current.focus(); + return true; + }, + submit: async () => { + if (p.isEnhancingPrompt || p.submitDisabled) return false; + p.onSubmit(); + return true; + }, + }), + [p.inputRef, p.isEnhancingPrompt, p.onSubmit, p.submitDisabled], + ); + useEffect(() => () => handle.revoke(), [handle]); + return handle.api; +} + export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { const { t } = useTranslation("chat"); const { inputRef, value, handleChange, handleSubmitWithReset, inputPlaceholder } = p; @@ -148,6 +185,13 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { const hasContent = value.trim().length > 0 || userContextCount > 0; // Block submit while enhancing prompt, but keep editor editable for programmatic updates const wrappedSubmit = isEnhancingPrompt || p.submitDisabled ? () => {} : handleSubmitWithReset; + + const composerCapability = useChatPluginComposer({ + inputRef, + submitDisabled: p.submitDisabled, + isEnhancingPrompt: Boolean(isEnhancingPrompt), + onSubmit: handleSubmitWithReset, + }); const submitDisabledReason = p.hasPendingAttachmentUploads ? t("chat:attachmentUploadPendingSubmit") : p.submitDisabledReason; @@ -200,6 +244,8 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { isSending={isSending} onCancel={onCancel} onSubmit={wrappedSubmit} + composerCapability={composerCapability} + composerSurface={taskId ? "task-chat" : "quick-chat"} submitKey={submitKey} contextCount={contextCount} contextPopoverOpen={contextPopoverOpen} diff --git a/apps/web/components/task/chat/chat-input-plugin-actions.tsx b/apps/web/components/task/chat/chat-input-plugin-actions.tsx index 0f4fc7f33e..0255293680 100644 --- a/apps/web/components/task/chat/chat-input-plugin-actions.tsx +++ b/apps/web/components/task/chat/chat-input-plugin-actions.tsx @@ -5,6 +5,17 @@ import { useOptionalAppStore } from "@/components/state-provider"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import type { AppState } from "@/lib/state/store"; import type { TaskSession } from "@/lib/types/http"; +import type { + PluginComposerCapability, + PluginComposerSurface, + PluginPresentation, +} from "@/lib/plugins/types"; + +const UNAVAILABLE_COMPOSER: PluginComposerCapability = { + insertText: () => ({ status: "unavailable" }), + focus: () => ({ status: "unavailable" }), + submit: async () => ({ status: "unavailable" }), +}; /** * Props forwarded to every plugin component registered for the @@ -27,6 +38,12 @@ export type ChatInputActionsSlotProps = { activeSessionId: string | null; /** Every kandev session id on the task (includes `activeSessionId`). */ sessionIds: string[]; + surface: PluginComposerSurface; + presentation: PluginPresentation; + disabled: boolean; + submittable: boolean; + disabledReason?: string; + composer: PluginComposerCapability; }; const EMPTY_SESSIONS: TaskSession[] = []; @@ -42,6 +59,12 @@ export function ChatInputPluginActions(props: { sessionId: string | null; taskId: string | null; taskTitle?: string; + surface?: "task-chat" | "quick-chat"; + presentation?: PluginPresentation; + disabled?: boolean; + submittable?: boolean; + disabledReason?: string; + composer?: PluginComposerCapability; }) { const { sessionId, taskId, taskTitle } = props; // itemsByTaskId holds a stable per-task array reference (updated only when @@ -60,8 +83,19 @@ export function ChatInputPluginActions(props: { // The active session may not yet be in the store list (freshly prepared); // make sure the plugin always receives it. if (sessionId && !sessionIds.includes(sessionId)) sessionIds.unshift(sessionId); - return { taskId, taskTitle, activeSessionId: sessionId, sessionIds }; - }, [taskSessions, sessionId, taskId, taskTitle]); + return { + taskId, + taskTitle, + activeSessionId: sessionId, + sessionIds, + surface: props.surface ?? (taskId ? "task-chat" : "quick-chat"), + presentation: props.presentation ?? "desktop", + disabled: props.disabled ?? false, + submittable: props.submittable ?? false, + disabledReason: props.disabledReason, + composer: props.composer ?? UNAVAILABLE_COMPOSER, + }; + }, [taskSessions, sessionId, taskId, taskTitle, props]); return ; } diff --git a/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx b/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx index e9b064dd96..f9dee9936a 100644 --- a/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx @@ -161,6 +161,8 @@ function DesktopRightSection(props: { submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; onVoiceTranscript?: (text: string) => void; onVoiceAutoSend?: () => void; + composerCapability?: ChatInputToolbarProps["composerCapability"]; + composerSurface?: ChatInputToolbarProps["composerSurface"]; }) { return (
@@ -176,6 +178,12 @@ function DesktopRightSection(props: { sessionId={props.sessionId} taskId={props.taskId} taskTitle={props.taskTitle} + surface={props.composerSurface ?? (props.taskId ? "task-chat" : "quick-chat")} + presentation="desktop" + disabled={props.isDisabled} + submittable={!props.isDisabled && props.hasContent} + disabledReason={props.submitDisabledReason} + composer={props.composerCapability} /> )}
diff --git a/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx b/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx index dcb6ba1838..4e63bf6c86 100644 --- a/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx @@ -21,6 +21,7 @@ import { import type { ContextFile } from "@/lib/state/context-files-store"; import type { SHORTCUTS } from "@/lib/keyboard/constants"; import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types"; +import type { PluginComposerCapability } from "@/lib/plugins/types"; import { t } from "@/lib/i18n"; type MobileToolbarProps = { @@ -57,6 +58,8 @@ type MobileToolbarProps = { submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; onVoiceTranscript?: (text: string) => void; onVoiceAutoSend?: () => void; + composerCapability?: PluginComposerCapability; + composerSurface?: "task-chat" | "quick-chat"; }; function mobileContextButton(contextCount: number) { @@ -177,6 +180,12 @@ export function MobileChatInputToolbar(props: MobileToolbarProps) { sessionId={props.sessionId} taskId={props.taskId} taskTitle={props.taskTitle} + surface={props.composerSurface ?? (props.taskId ? "task-chat" : "quick-chat")} + presentation="mobile" + disabled={props.isDisabled} + submittable={!props.isDisabled && props.hasContent} + disabledReason={props.submitDisabledReason} + composer={props.composerCapability} /> )} {props.onVoiceTranscript && ( diff --git a/apps/web/components/task/chat/chat-input-toolbar.tsx b/apps/web/components/task/chat/chat-input-toolbar.tsx index 55d0b314a3..73f31e88d0 100644 --- a/apps/web/components/task/chat/chat-input-toolbar.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar.tsx @@ -10,6 +10,7 @@ import { shouldUseCompactTaskChrome } from "@/hooks/use-compact-task-chrome"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import type { ContextFile } from "@/lib/state/context-files-store"; import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types"; +import type { PluginComposerCapability } from "@/lib/plugins/types"; export type ChatInputToolbarProps = { planModeEnabled: boolean; @@ -61,6 +62,8 @@ export type ChatInputToolbarProps = { hideAgentControls?: boolean; /** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */ hidePlanMode?: boolean; + composerCapability?: PluginComposerCapability; + composerSurface?: "task-chat" | "quick-chat"; }; function MinimalToolbar({ diff --git a/apps/web/components/task/chat/use-tiptap-editor.ts b/apps/web/components/task/chat/use-tiptap-editor.ts index c0a2b798e6..fe31bad663 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.ts @@ -40,6 +40,7 @@ export type TipTapInputHandle = { focus: () => void; blur: () => void; getSelectionStart: () => number; + getSelectionEnd: () => number; getValue: () => string; setValue: (value: string) => void; clear: () => void; @@ -497,6 +498,7 @@ function useEditorImperativeHandle( focus: () => editor?.commands.focus(), blur: () => editor?.commands.blur(), getSelectionStart: () => editor?.state.selection.from ?? 0, + getSelectionEnd: () => editor?.state.selection.to ?? 0, getValue: () => (editor ? getMarkdownText(editor) : ""), setValue: (v: string) => { if (!editor) return; diff --git a/apps/web/components/task/new-session-dialog.tsx b/apps/web/components/task/new-session-dialog.tsx index 2cb3aff5df..834e781027 100644 --- a/apps/web/components/task/new-session-dialog.tsx +++ b/apps/web/components/task/new-session-dialog.tsx @@ -444,6 +444,7 @@ function NewSessionForm({ /> { - if (isBusyState || hasPendingAttachmentUploads || !profileSelection.hasProfiles) return; + if (isBusyState || hasPendingAttachmentUploads || !profileSelection.hasProfiles) { + return false; + } void handleSubmit(VOICE_SUBMIT_EVENT); + return true; }} /> { + it("fails closed after revocation", async () => { + const insertText = vi.fn(() => true); + const focus = vi.fn(() => true); + const submit = vi.fn(async () => true); + const capability = createPluginComposerCapability({ insertText, focus, submit }); + + capability.revoke(); + + expect(capability.api.insertText("hello")).toEqual({ status: "unavailable" }); + expect(capability.api.focus()).toEqual({ status: "unavailable" }); + await expect(capability.api.submit()).resolves.toEqual({ status: "unavailable" }); + expect(insertText).not.toHaveBeenCalled(); + expect(focus).not.toHaveBeenCalled(); + expect(submit).not.toHaveBeenCalled(); + }); + + it("maps native operations to public outcomes", async () => { + const capability = createPluginComposerCapability({ + insertText: () => true, + focus: () => true, + submit: async () => false, + }); + + expect(capability.api.insertText("hello")).toEqual({ status: "inserted" }); + expect(capability.api.focus()).toEqual({ status: "focused" }); + await expect(capability.api.submit()).resolves.toEqual({ status: "blocked" }); + }); +}); diff --git a/apps/web/lib/plugins/composer-capability.ts b/apps/web/lib/plugins/composer-capability.ts new file mode 100644 index 0000000000..85577a8599 --- /dev/null +++ b/apps/web/lib/plugins/composer-capability.ts @@ -0,0 +1,35 @@ +import type { PluginComposerCapability } from "./types"; + +type NativeComposerOperations = { + insertText(text: string): boolean; + focus(): boolean; + submit(): Promise; +}; + +export function createPluginComposerCapability(operations: NativeComposerOperations): { + api: PluginComposerCapability; + revoke(): void; +} { + let active = true; + + return { + api: { + insertText(text) { + if (!active) return { status: "unavailable" }; + if (!text.trim()) return { status: "ignored" }; + return { status: operations.insertText(text) ? "inserted" : "unavailable" }; + }, + focus() { + if (!active) return { status: "unavailable" }; + return { status: operations.focus() ? "focused" : "unavailable" }; + }, + async submit() { + if (!active) return { status: "unavailable" }; + return (await operations.submit()) ? { status: "submitted" } : { status: "blocked" }; + }, + }, + revoke() { + active = false; + }, + }; +} diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts index 55b6e59ac2..f6a4238afe 100644 --- a/apps/web/lib/plugins/types.ts +++ b/apps/web/lib/plugins/types.ts @@ -128,6 +128,32 @@ export type WsHandler = (payload: unknown) => void; /** Presentation context a task panel or kanban menu action renders under. */ export type PluginPresentation = "desktop" | "mobile"; +export type PluginComposerSurface = "task-chat" | "quick-chat" | "task-create" | "new-session"; + +export type PluginComposerSubmitResult = + | { status: "submitted" } + | { status: "blocked"; reason?: string } + | { status: "unavailable" }; + +export interface PluginComposerCapability { + insertText(text: string): { status: "inserted" | "ignored" | "unavailable" }; + focus(): { status: "focused" | "unavailable" }; + submit(): Promise; +} + +export interface PluginComposerSlotProps { + surface: PluginComposerSurface; + presentation: PluginPresentation; + taskId: string | null; + taskTitle?: string; + activeSessionId: string | null; + sessionIds: string[]; + disabled: boolean; + submittable: boolean; + disabledReason?: string; + composer: PluginComposerCapability; +} + /** Props passed to a `TaskPanelRegistration.Component`. */ export interface PluginTaskPanelProps { /** This registration's panel id, so one Component can back multiple panels. */ diff --git a/docs/decisions/2026-08-11-composer-access-authenticated-webhooks.md b/docs/decisions/2026-08-11-composer-access-authenticated-webhooks.md new file mode 100644 index 0000000000..237d3278ba --- /dev/null +++ b/docs/decisions/2026-08-11-composer-access-authenticated-webhooks.md @@ -0,0 +1,50 @@ +# ADR-2026-08-11-composer-access-authenticated-webhooks: Composer Access and Authenticated Webhooks + +**Status:** accepted +**Date:** 2026-08-11 +**Area:** frontend, backend, protocol, security + +## Context + +Voice Mode currently reaches private editor handles and native submit callbacks in four composer +families, while plugin slots receive identifiers only. Plugin UI already calls its backend through +webhooks, but declarations cannot distinguish public integration ingress from authenticated Kandev UI +calls and all requests share a 4 MiB cap. Voice transcription needs authenticated UI access and at +least 10 MiB while keeping its OpenAI key in plugin-owned secret settings. + +## Decision + +Kandev exposes a typed, mounted-composer capability only through composer slot props. The capability +adapts insertion, focus, state, and submission to the owning surface's existing editor/form code; it +does not expose draft contents or let plugins synthesize outbound messages. Task chat, Quick Chat, +task creation, and new-session creation each supply an adapter to the same public shape. + +Kandev extends each webhook declaration with `access: public|authenticated` and +`max_body_bytes`. Existing declarations default to `public` and 4 MiB. Authenticated webhooks require +the normal Kandev identity and same-origin browser checks; their configured limit may be raised to a +16 MiB host ceiling. Both modes continue through the existing `HandleWebhook` RPC, with request +cancellation propagated through its context. + +Plugin localization is deferred to a separate prerequisite. It is required before core Voice Mode +can be removed, but it is independent of composer access and authenticated backend invocation. + +## Consequences + +Plugins can augment native composers without duplicating message construction or form behavior. A +plugin UI can use the existing webhook API for a larger authenticated upload without adding a route, +frontend API, protobuf method, or token lifecycle. Plugin authors must deliberately classify each +webhook; older manifests retain their current behavior. + +## Alternatives Considered + +- **Expose drafts and editor handles through `host.store`.** Rejected because drafts are local React + state, editor implementations differ, and a global mutable handle would outlive or target the wrong + composer during navigation. +- **Let plugins send messages through `api_write:messages`.** Rejected because it bypasses the native + submit and form behavior this extraction must preserve. +- **Add a separate browser-action route and RPC.** Rejected because access mode and body size are + properties of the existing UI-to-plugin webhook relay; a second transport duplicates it. +- **Raise every webhook to 16 MiB.** Rejected because existing public webhook callers do not need the + larger exposure. Limits remain declaration-specific and bounded by the host ceiling. +- **Add host localization in this package.** Rejected because locale/catalog ownership is independent + of the two host blockers addressed here and can be designed as a separate prerequisite. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 0b50732c9c..a17e8c4fc8 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -107,6 +107,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-01-share-artifact-locale | [Shared-Task Artifacts Render in the Creator's Locale](2026-08-01-share-artifact-locale.md) | accepted | backend | 2026-08-01 | | 2026-08-01-gitlab-mr-lifecycle-notifications | [GitLab MR Lifecycle Notifications](2026-08-01-gitlab-mr-lifecycle-notifications.md) | accepted | backend, frontend | 2026-08-01 | | 2026-08-01-per-user-plugin-storage | [Host-Provided Per-User Plugin Storage](2026-08-01-per-user-plugin-storage.md) | accepted | backend, frontend, security | 2026-08-01 | +| 2026-08-11-composer-access-authenticated-webhooks | [Composer Access and Authenticated Webhooks](2026-08-11-composer-access-authenticated-webhooks.md) | accepted | frontend, backend, protocol, security | 2026-08-11 | | 2026-08-01-plugin-task-panel-contributions | [Plugin Task Panel, Kanban Menu Action, and Card Indicator Contributions](2026-08-01-plugin-task-panel-contributions.md) | accepted | frontend | 2026-08-01 | | 2026-08-02-new-workspace-github-access-defaults | [Bootstrap New Workspaces From Host GitHub Access](2026-08-02-new-workspace-github-access-defaults.md) | accepted | backend, frontend, security | 2026-08-02 | | 2026-08-02-explicit-user-cancel-completion | [Explicit User Cancellation May Complete a Workflow Step](2026-08-02-explicit-user-cancel-completion.md) | accepted | workflow | 2026-08-02 | diff --git a/docs/plans/plugin-voice-extraction-host/plan.md b/docs/plans/plugin-voice-extraction-host/plan.md new file mode 100644 index 0000000000..843ddecf2b --- /dev/null +++ b/docs/plans/plugin-voice-extraction-host/plan.md @@ -0,0 +1,147 @@ +--- +spec: docs/specs/plugins/voice-extraction-host.md +created: 2026-08-11 +status: building +--- + +# Implementation Plan: Voice Plugin Host Prerequisites + +## Overview + +Add host-owned composer adapters to every Voice Mode surface, then extend existing webhooks with +per-declaration access and request-size policies. Publish the contract through frontend types, +manifest validation, the frozen plugin API, and public authoring documentation. +Keep core Voice Mode installed throughout; a separate plugin repository will consume these APIs and +only a later parity task may remove core code. + +## Contract Decisions + +- Composer state stays local to each mounted surface. Slot props expose current disabled/submittable + state plus insertion, focus, and native submit without exposing draft contents or private refs. +- `chat-input-actions` grows compatible props and covers task chat plus Quick Chat. Creation forms get + explicit `task-create-input-actions` and `new-session-input-actions` slots because their identifiers, + validation, and submit lifecycles differ. +- Plugin UI continues to use `host.api.fetch("webhooks/")` and the existing `HandleWebhook` RPC. + `webhooks[].access` selects public or authenticated invocation and `max_body_bytes` sets a bounded + per-key limit up to 16 MiB; old manifests retain public/4 MiB defaults. +- Plugin localization is a separate prerequisite. It must be resolved before core Voice Mode is + removed, but this package adds no host i18n API. + +## Frontend Composer Architecture + +- Add public types in `apps/web/lib/plugins/types.ts` and mirror them in + `docs/plans/plugins/PLUGIN-API.md`. Implement adapters guarded by the mounted composer generation; + normal React props update disabled/submittable presentation without draft state entering Zustand. +- Extend `TipTapInputHandle` only where required for the host adapter. Reuse its selection-aware + `insertText`, but centralize the Voice spacing rule so textarea and TipTap adapters behave identically. +- Adapt `useChatInputState`/`ChatInputArea` to expose native `handleSubmit` and the same disabled gate + to `ChatInputPluginActions`. Do not call `useMessageHandler` or construct `ChatSubmitPayload` from + plugin code. Quick Chat inherits the adapter through its existing `ChatInputArea` composition. +- Extend `TaskFormInputsHandle` with guarded focus/insertion operations or provide a sibling adapter + around `useDescriptionInput`. Task creation invokes its existing form submit handler; new-session + creation invokes its existing `handleSubmit`, including profile, upload, and busy-state gates. +- Keep slot controls in the existing toolbars on desktop. On native mobile, use the existing composer + toolbar/overflow composition and 44px touch targets; do not introduce a separate drawer solely for + plugin actions. + +## Mobile Design Contract + +- **Outcome and entry points:** Voice plugin actions remain beside native composer controls in task + chat and inside the prompt toolbar for task/new-session creation; Quick Chat uses its existing input + toolbar. All actions are reachable by touch. +- **Exemplars:** task chat reuses `chat-input-toolbar-mobile.tsx` and + `mobile-voice-mode.spec.ts`; new session reuses the full-height dialog in + `mobile-new-session-dialog.spec.ts`; task creation reuses the existing responsive Task Create dialog. +- **Hierarchy and action:** the plugin action is secondary to the native submit action and never hides + submit, model/profile selection, or cancellation. Recording/transcription state belongs + to the plugin control; the native composer remains the single draft and submit owner. +- **Surface rationale:** frequent, one-tap input augmentation belongs inline in the existing toolbar, + not behind navigation or a new drawer. Existing dialog and composer scroll owners remain unchanged; + overlays and toolbars keep safe-area clearance and no document horizontal overflow. +- **Shared behavior:** capability state and native adapters are shared across presentations. Only the + existing toolbar placement differs. Pixel 5 Playwright proves insertion plus native submission in + task chat, task creation, and new-session creation; Quick Chat mobile coverage is included if its + shipped mobile entry exposes the full toolbar. + +## Backend Authenticated Webhook Architecture + +- Extend manifest `Webhook` with `Access` and `MaxBodyBytes`. Validate `access` as + `public|authenticated`, default it to `public`, default the limit to 4 MiB, reject non-positive or + >16 MiB values, and preserve those values through install/registry serialization. +- Keep the existing webhook URL and `HandleWebhook` RPC. In the handler, resolve the declared key + before reading the body, apply its effective limit, and require normal Kandev identity plus accepted + Origin for authenticated cookie requests. Authentication-disabled mode uses the synthetic user. +- Propagate `Request.Context()` cancellation through `Service.InvokeWebhook`; bound and sanitize the + response as today. Logs may add access mode and byte count but never request content or secrets. +- Keep existing public webhooks byte-for-byte compatible: no authentication requirement and a 4 MiB + default. The Voice plugin stores its OpenAI key through existing secret config fields; no host key + storage or new secret API is introduced. + +## Localization And Compatibility + +- Add optional manifest fields without changing plugin `api_version: 1`. Document that consumers must + declare the first supporting `min_kandev_version`; older hosts fail installation instead of falling + back to webhooks. +- Update `docs/specs/plugins/spec.md`, `docs/plans/plugins/PLUGIN-API.md`, + `docs/public/plugins-authoring.md`, and `docs/public/plugins-manifest.md` in the same contract task. + +## Test Strategy + +- Frontend unit/component tests prove selection replacement, spacing, focus, live state props, + stale-adapter failure, native submit delegation, blocked submission, cleanup, Quick Chat props, and + desktop/mobile slot placement across all composer families. +- Backend manifest tests prove defaults, validation, and compatibility. Handler/service tests prove + authenticated/public access, Origin enforcement, 10 MiB success, >16 MiB rejection, cancellation, + disabled/runtime failure, and unchanged legacy webhook behavior. +- Desktop and mobile Playwright install or inject a fixture plugin action, insert a distinctive string + at a non-terminal selection, invoke native submit, and assert the resulting task/message/session. + A backend-integrated E2E action uploads >10 MiB only where the container/runtime fixture is available; + focused Go integration tests remain the deterministic size/security proof. + +## Implementation Waves And Tasks + +Wave 1: + +- [x] [Task 01: Publish composer contract](task-01-publish-composer-contract.md) +- [x] [Task 02: Extend webhook declarations](task-02-authenticated-webhook-manifest.md) + +Wave 2: + +- [x] [Task 03: Adapt chat and Quick Chat composers](task-03-chat-composer-adapters.md) (depends on Task 01) +- [x] [Task 04: Adapt creation composers](task-04-creation-composer-adapters.md) (depends on Task 01) +- [x] [Task 05: Enforce authenticated webhooks](task-05-authenticated-webhook-handler.md) (depends on Task 02) + +Wave 3: + +- [ ] [Task 06: Document and verify extraction prerequisites](task-06-docs-e2e-verification.md) (depends on Tasks 03-05) + +Tasks in the same wave are parallel-safe only where their owned files remain disjoint. Execution stays +sequential unless the user explicitly authorizes subagents. + +## Risks + +- A capability that snapshots React state can auto-submit after a gate changes; `submit()` must + revalidate the native gate at call time. +- React Strict Mode or a task/session switch can leave stale plugin closures alive; generation guards + must make them fail closed rather than target the newly mounted editor. +- Conditional authentication inside a route currently treated as public must fail closed per declared + key; direct handler and middleware-boundary tests are required. +- Raising gRPC message limits globally would unnecessarily affect other plugin traffic; configure only + the existing webhook call path sufficiently for the 16 MiB host ceiling. +- Core Voice removal in this package would make rollback and parity validation unsafe; it is explicitly + deferred to the plugin implementation/extraction task. + +## Verification Commands + +```bash +make -C apps/backend test +make -C apps/backend lint +cd apps && pnpm install --frozen-lockfile +cd apps && pnpm --filter @kandev/web test -- --run lib/plugins components/task/chat components/task-create-dialog-selectors.test.tsx components/task/new-session-dialog.test.tsx +cd apps/web && pnpm run typecheck +cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet +make build-web && make build-backend +cd apps/web && pnpm e2e:raw --project=chrome e2e/tests/plugins/composer-actions.spec.ts e2e/tests/chat/quick-chat.spec.ts e2e/tests/session/new-session-dialog.spec.ts +cd apps/web && pnpm e2e:raw --project=mobile-chrome e2e/tests/plugins/mobile-composer-actions.spec.ts e2e/tests/session/mobile-new-session-dialog.spec.ts +node --test scripts/validate-public-docs.test.mjs +``` diff --git a/docs/plans/plugin-voice-extraction-host/task-01-publish-composer-contract.md b/docs/plans/plugin-voice-extraction-host/task-01-publish-composer-contract.md new file mode 100644 index 0000000000..d7b1ba73bf --- /dev/null +++ b/docs/plans/plugin-voice-extraction-host/task-01-publish-composer-contract.md @@ -0,0 +1,44 @@ +--- +id: "01-publish-composer-contract" +title: "Publish composer contract" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/plugins/voice-extraction-host.md" +--- + +# Task 01: Publish Composer Contract + +## Acceptance + +- Public plugin types define composer surface/state/capability/results exactly as the + spec, with backward-compatible additions to existing slot props. +- Internal capability lifecycle utilities make stale/unmounted capabilities + fail closed without exposing draft values or editor handles. +- Host API tests cover teardown, prop updates, and stale capability behavior. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run lib/plugins/host-api.test.ts lib/plugins/host-lifecycle.test.ts lib/plugins/host.test.ts +cd apps/web && pnpm run typecheck +``` + +Follow RED-GREEN-REFACTOR: add contract/lifecycle failures first, then the smallest public types and +host wiring. Do not add composer surface adapters in this task. + +## Files Likely Touched + +- `apps/web/lib/plugins/types.ts` +- `apps/web/lib/plugins/host-api.ts` +- `apps/web/lib/plugins/host.ts` +- focused tests beside those files +- a new internal composer-capability utility and test +- `docs/plans/plugins/PLUGIN-API.md` + +## Inputs And Risks + +- Spec API Surface and ADR mounted-capability decision. +- Avoid a Zustand draft slice, public editor handle, or snapshot-only state. Strict Mode teardown must + not notify or mutate after revocation. diff --git a/docs/plans/plugin-voice-extraction-host/task-02-authenticated-webhook-manifest.md b/docs/plans/plugin-voice-extraction-host/task-02-authenticated-webhook-manifest.md new file mode 100644 index 0000000000..a377d84c5b --- /dev/null +++ b/docs/plans/plugin-voice-extraction-host/task-02-authenticated-webhook-manifest.md @@ -0,0 +1,39 @@ +--- +id: "02-authenticated-webhook-manifest" +title: "Extend webhook declarations" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/plugins/voice-extraction-host.md" +--- + +# Task 02: Extend Webhook Declarations + +## Acceptance + +- Manifest webhooks support optional `access: public|authenticated` and `max_body_bytes`, with legacy + declarations resolving to public access and a 4 MiB limit. +- Validation rejects unknown access modes, public limits above 4 MiB, and authenticated limits above + 16 MiB, while preserving existing key uniqueness and method behavior. +- Install, registry, and JSON/YAML round-trip tests preserve the new fields. Protobuf and the Go plugin + SDK remain unchanged. + +## Verification + +```bash +cd apps/backend && go test ./internal/plugins/manifest ./internal/plugins +``` + +Follow RED-GREEN-REFACTOR, beginning with legacy-default and invalid-limit cases. + +## Files Likely Touched + +- `apps/backend/internal/plugins/manifest/*` +- `apps/backend/internal/plugins/types.go` +- plugin install/registry serialization tests + +## Inputs And Risks + +- Spec authenticated-webhook API and compatibility section. +- Do not reinterpret omitted fields during YAML/JSON round trips in a way that breaks old packages. diff --git a/docs/plans/plugin-voice-extraction-host/task-03-chat-composer-adapters.md b/docs/plans/plugin-voice-extraction-host/task-03-chat-composer-adapters.md new file mode 100644 index 0000000000..a5169bdf80 --- /dev/null +++ b/docs/plans/plugin-voice-extraction-host/task-03-chat-composer-adapters.md @@ -0,0 +1,48 @@ +--- +id: "03-chat-composer-adapters" +title: "Adapt chat and Quick Chat composers" +status: done +wave: 2 +depends_on: ["01-publish-composer-contract"] +plan: "plan.md" +spec: "../../specs/plugins/voice-extraction-host.md" +--- + +# Task 03: Adapt Chat And Quick Chat Composers + +## Acceptance + +- Desktop/mobile task chat and Quick Chat pass a live composer capability through + `chat-input-actions`, with correct surface, presentation, task, and session metadata. +- Insertion replaces the active TipTap selection with native spacing/focus semantics; submit delegates + to the existing draft state machine and preserves native submission behavior. +- Unit tests cover blocked, stale, task/session-switch, and task-less Quick Chat cases. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run components/task/chat lib/plugins components/quick-chat +cd apps/web && pnpm run typecheck +``` + +Follow RED-GREEN-REFACTOR. Keep `useChatInputState` and `useSubmitHandler` as the only submit owners. + +## Files Likely Touched + +- `apps/web/components/task/chat/chat-input-plugin-actions.tsx` +- `apps/web/components/task/chat/chat-input-toolbar-desktop.tsx` +- `apps/web/components/task/chat/chat-input-toolbar-mobile.tsx` +- `apps/web/components/task/chat/chat-input-area.tsx` +- `apps/web/components/task/chat/use-chat-input-state.ts` +- `apps/web/components/task/chat/use-tiptap-editor.ts` +- focused chat and Quick Chat tests + +## Mobile Design Contract + +Reuse the native mobile toolbar and its scroll/overflow behavior. The plugin action remains secondary +to send/cancel, has a >=44px touch target, and receives the same capability semantics as desktop. + +## Risks + +- Fast insertion then submit must read the updated value ref before React effects. +- An old session capability must return `unavailable`, never insert into the newly active session. diff --git a/docs/plans/plugin-voice-extraction-host/task-04-creation-composer-adapters.md b/docs/plans/plugin-voice-extraction-host/task-04-creation-composer-adapters.md new file mode 100644 index 0000000000..4c6820efd0 --- /dev/null +++ b/docs/plans/plugin-voice-extraction-host/task-04-creation-composer-adapters.md @@ -0,0 +1,50 @@ +--- +id: "04-creation-composer-adapters" +title: "Adapt creation composers" +status: done +wave: 2 +depends_on: ["01-publish-composer-contract"] +plan: "plan.md" +spec: "../../specs/plugins/voice-extraction-host.md" +--- + +# Task 04: Adapt Creation Composers + +## Acceptance + +- Task creation and new-session creation render their declared plugin action slots on desktop and + native mobile with live prompt state and relevant identifiers. +- Selection insertion reuses one spacing algorithm and restores focus/caret; plugin submit invokes the + exact native form handler with existing busy and validation gates. +- Tests cover selected-range replacement, blocked submit, stale dialog handles, successful native + task/session creation delegation, and cleanup on close. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run components/task-create-dialog-selectors.test.tsx components/task-create-dialog.test.tsx components/task/new-session-dialog.test.tsx +cd apps/web && pnpm run typecheck +``` + +Follow RED-GREEN-REFACTOR. Do not make Task Create or New Session send a chat message directly. + +## Files Likely Touched + +- `apps/web/components/task-create-dialog-types.ts` +- `apps/web/components/task-create-dialog-selectors.tsx` +- `apps/web/components/task-create-dialog-form-body.tsx` +- `apps/web/components/task-create-dialog.tsx` +- `apps/web/components/task/new-session-dialog.tsx` +- new shared creation-composer plugin action component/helper +- focused tests beside these files + +## Mobile Design Contract + +Keep actions inside the existing prompt toolbar and full-height responsive dialogs. Preserve the +dialog's scroll owner, safe-area behavior, primary submit hierarchy, and >=44px touch targets. + +## Risks + +- Programmatic form submission must not bypass native validation or submit while the form is blocked. +- Task Create and New Session share input UI but have different submit ownership; avoid a false shared + callback that drops either form's gates. diff --git a/docs/plans/plugin-voice-extraction-host/task-05-authenticated-webhook-handler.md b/docs/plans/plugin-voice-extraction-host/task-05-authenticated-webhook-handler.md new file mode 100644 index 0000000000..8c3d297885 --- /dev/null +++ b/docs/plans/plugin-voice-extraction-host/task-05-authenticated-webhook-handler.md @@ -0,0 +1,41 @@ +--- +id: "05-authenticated-webhook-handler" +title: "Enforce authenticated webhooks" +status: done +wave: 2 +depends_on: ["02-authenticated-webhook-manifest"] +plan: "plan.md" +spec: "../../specs/plugins/voice-extraction-host.md" +--- + +# Task 05: Enforce Authenticated Webhooks + +## Acceptance + +- The existing webhook handler enforces each declared key's access mode and effective body limit before + invoking the existing `HandleWebhook` RPC. +- Authenticated webhooks require Kandev identity and accepted Origin for cookie-authenticated browser + calls; public webhooks retain their current unauthenticated behavior. +- Integration tests cover legacy 4 MiB behavior, authenticated denial/success, Origin rejection, + 10 MiB multipart success, >16 MiB rejection, cancellation, disabled plugin, and runtime failure. + +## Verification + +```bash +cd apps/backend && go test ./internal/plugins ./internal/auth/httpmw ./internal/backendapp +make -C apps/backend lint +``` + +Follow RED-GREEN-REFACTOR, beginning with direct handler and middleware-boundary security tests. + +## Files Likely Touched + +- `apps/backend/internal/plugins/handlers.go` +- `apps/backend/internal/plugins/invoke.go` and focused tests if cancellation/size wiring needs changes +- `apps/backend/internal/auth/httpmw/*` + +## Inputs And Risks + +- ADR conditional webhook access decision. +- Because the webhook path is currently public at middleware level, handler authentication must use + the canonical auth service and fail closed before reading or relaying the request body. diff --git a/docs/plans/plugin-voice-extraction-host/task-06-docs-e2e-verification.md b/docs/plans/plugin-voice-extraction-host/task-06-docs-e2e-verification.md new file mode 100644 index 0000000000..62817e6a31 --- /dev/null +++ b/docs/plans/plugin-voice-extraction-host/task-06-docs-e2e-verification.md @@ -0,0 +1,54 @@ +--- +id: "06-docs-e2e-verification" +title: "Document and verify extraction prerequisites" +status: pending +wave: 3 +depends_on: ["03-chat-composer-adapters", "04-creation-composer-adapters", "05-authenticated-webhook-handler"] +plan: "plan.md" +spec: "../../specs/plugins/voice-extraction-host.md" +--- + +# Task 06: Document And Verify Extraction Prerequisites + +## Acceptance + +- Public authoring/manifest docs and the plugin spec describe composer slots, webhook access/size + fields, min-version rules, cancellation, plugin-owned secret settings, and the separate localization + prerequisite. +- Desktop and Pixel 5 Playwright prove selection insertion plus native submit for task chat, task + creation, and new session; Quick Chat is covered wherever its toolbar is exposed. +- Core Voice Mode remains present and its focused unit/E2E coverage still passes, establishing a safe + prerequisite release for the later external plugin task. + +## Verification + +```bash +cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet +make build-web && make build-backend +cd apps/web && pnpm e2e:raw --project=chrome e2e/tests/plugins/composer-actions.spec.ts e2e/tests/chat/quick-chat.spec.ts e2e/tests/session/new-session-dialog.spec.ts +cd apps/web && pnpm e2e:raw --project=mobile-chrome e2e/tests/plugins/mobile-composer-actions.spec.ts e2e/tests/chat/mobile-voice-mode.spec.ts e2e/tests/session/mobile-new-session-dialog.spec.ts +node --test scripts/validate-public-docs.test.mjs +``` + +## Files Likely Touched + +- `docs/specs/plugins/spec.md` +- `docs/plans/plugins/PLUGIN-API.md` +- `docs/public/plugins-authoring.md` +- `docs/public/plugins-manifest.md` +- `apps/web/e2e/tests/plugins/composer-actions.spec.ts` +- `apps/web/e2e/tests/plugins/mobile-composer-actions.spec.ts` +- fixture/plugin E2E helpers and existing Quick Chat/New Session specs where needed + +## Mobile Design Contract + +Pixel 5 tests use the shipped task toolbar, Task Create dialog, and New Session full-height dialog. +They assert the action is touch-reachable, native submission completes, the intended surface owns +scrolling, and the document has no horizontal overflow. + +## Risks + +- A mock that calls the submit callback directly does not prove native behavior; E2E must interact + through a registered fixture plugin slot and observe the created message/task/session. +- Do not remove or weaken current Voice Mode tests. Extraction and core removal belong to the later + dedicated plugin task. diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md index 134e4d317f..c7361f584b 100644 --- a/docs/plans/plugins/PLUGIN-API.md +++ b/docs/plans/plugins/PLUGIN-API.md @@ -40,9 +40,10 @@ they must use `host.React` (NOT bundle their own React) to share the host instan ```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) + 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; @@ -56,7 +57,7 @@ interface PluginHostApi { // dev/desktop base URL from window internals. baseUrl: string; }; - ui: Record; // curated @kandev/ui components + app UI (see below) + ui: Record; // curated @kandev/ui components + app UI (see below) // The resolved light/dark theme, read live on every access. `host` is built // once per plugin load, so copying this into a variable that outlives a // render freezes it; read it during render, and pair it with onThemeChange @@ -89,7 +90,11 @@ interface PluginHostApi { storage: PluginStorageApi; } -interface PluginStorageEntry { key: string; value: unknown; updatedAt: string; } +interface PluginStorageEntry { + key: string; + value: unknown; + updatedAt: string; +} interface PluginStorageSetOptions { // Optimistic-concurrency guard: the updatedAt the caller last read. The @@ -115,20 +120,37 @@ interface PluginStorageSetOptions { } // Mirrors the backend's user-state route scopes. -type PluginStorageScope = "instance" | "workspace" | "task" | "session" | "repository"; +type PluginStorageScope = + | "instance" + | "workspace" + | "task" + | "session" + | "repository"; interface PluginStorageApi { - get(scope: PluginStorageScope, scopeId: string, key: string): Promise; + get( + scope: PluginStorageScope, + scopeId: string, + key: string, + ): Promise; set( - scope: PluginStorageScope, scopeId: string, key: string, value: unknown, + scope: PluginStorageScope, + scopeId: string, + key: string, + value: unknown, options?: PluginStorageSetOptions, ): Promise<{ updatedAt: string }>; delete( - scope: PluginStorageScope, scopeId: string, key: string, + scope: PluginStorageScope, + scopeId: string, + key: string, options?: Pick, ): Promise; // Every entry under (scope, scopeId), ordered by key. Not paginated. - list(scope: PluginStorageScope, scopeId: string): Promise; + list( + scope: PluginStorageScope, + scopeId: string, + ): Promise; // Subscribes to live updates for this plugin's own storage made from // another tab, device, or surface — e.g. the kanban Edit modal and the // task panel both editing the same document. filter.scope/scopeId/key @@ -145,7 +167,12 @@ interface PluginStorageApi { // it would incorrectly suppress each other's legitimate writes as if they // were one surface's own echo. subscribe( - filter: { scope?: PluginStorageScope; scopeId?: string; key?: string; writerId?: string }, + filter: { + scope?: PluginStorageScope; + scopeId?: string; + key?: string; + writerId?: string; + }, handler: (change: PluginUserStateChange) => void, ): () => void; } @@ -159,10 +186,10 @@ interface PluginUserStateChange { } interface PluginModalOptions { - title?: string; // rendered in a DialogHeader/DialogTitle; omit for no header title + title?: string; // rendered in a DialogHeader/DialogTitle; omit for no header title content: React.ComponentType<{ slotProps?: unknown }>; // reuses the slot-component contract - size?: "sm" | "md" | "lg" | "xl"; // maps to the host's Dialog width classes; default "md" - dismissible?: boolean; // overlay click / Escape close the modal; default true + size?: "sm" | "md" | "lg" | "xl"; // maps to the host's Dialog width classes; default "md" + dismissible?: boolean; // overlay click / Escape close the modal; default true } interface PluginModalHandle { @@ -261,7 +288,11 @@ interface RichTextEditorProps { testId?: string; } // RichTextReadOnly: renders markdown read-only, no taskId dependency. -interface RichTextReadOnlyProps { value: string; className?: string; testId?: string; } +interface RichTextReadOnlyProps { + value: string; + className?: string; + testId?: string; +} ``` Deliberately narrow — not the plan editor's `comments`, `onSelectionChange`, @@ -324,16 +355,22 @@ own write). // "integrations" renders inside the sidebar's Integrations section alongside // the first-party integration links (GitHub, Jira, ...). Hosts predating a // section value simply don't render items targeting it (additive change). -interface NavItem { id: string; label: string; path: string; icon?: string; section?: "main" | "settings" | "integrations"; } +interface NavItem { + id: string; + label: string; + path: string; + icon?: string; + section?: "main" | "settings" | "integrations"; +} // Configuration for the kandev-style title bar the host renders above a plugin // route. All fields optional; defaults are derived (see registerRoute below). interface PluginPageChrome { - title?: string; // default: nav-item label for the same path, else plugin name - subtitle?: string; // muted text next to the title - icon?: string; // curated icon name; default: matching nav item's icon - backHref?: string; // back-link target (host default "/") - backLabel?: string; // back-link label (host default "Kandev") + title?: string; // default: nav-item label for the same path, else plugin name + subtitle?: string; // muted text next to the title + icon?: string; // curated icon name; default: matching nav item's icon + backHref?: string; // back-link target (host default "/") + backLabel?: string; // back-link label (host default "Kandev") actions?: React.ComponentType; // rendered on the right side of the topbar } @@ -349,7 +386,11 @@ interface PluginRegistry { // required for v1 — exact + startsWith("/plugins/{id}") allowed). The host wraps the // page in kandev chrome (PageTopbar + scrollable content area) by default — // configure or opt out via options.topbar. - registerRoute(path: string, Component: React.ComponentType, options?: PluginRouteOptions): void; + registerRoute( + path: string, + Component: React.ComponentType, + options?: PluginRouteOptions, + ): void; // Sidebar/main nav entry. Rendered by in the app sidebar, // and by in the phone menu sheet (the sidebar is @@ -398,7 +439,10 @@ interface PluginRegistry { // registered by the plugin currently being viewed, so your card appears on // your own settings page and never on another plugin's — no per-id gating // needed in your component. - registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void; + 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. @@ -442,6 +486,32 @@ interface PluginRegistry { type PluginPresentation = "desktop" | "mobile"; +type PluginComposerSurface = + | "task-chat" + | "quick-chat" + | "task-create" + | "new-session"; +interface PluginComposerCapability { + insertText(text: string): { status: "inserted" | "ignored" | "unavailable" }; + focus(): { status: "focused" | "unavailable" }; + submit(): Promise<{ + status: "submitted" | "blocked" | "unavailable"; + reason?: string; + }>; +} +interface PluginComposerSlotProps { + surface: PluginComposerSurface; + presentation: PluginPresentation; + taskId: string | null; + taskTitle?: string; + activeSessionId: string | null; + sessionIds: string[]; + disabled: boolean; + submittable: boolean; + disabledReason?: string; + composer: PluginComposerCapability; +} + interface PluginTaskPanelProps { panelId: string; // this registration's panel id, so one Component can back multiple panels taskId: string; @@ -450,9 +520,9 @@ interface PluginTaskPanelProps { } interface TaskPanelRegistration { - id: string; // plugin-local panel id (unique within the plugin, not globally) - title: string; // add-panel-menu row label and dockview tab title - icon?: string; // curated icon name (apps/web/lib/plugins/icons.ts) + id: string; // plugin-local panel id (unique within the plugin, not globally) + title: string; // add-panel-menu row label and dockview tab title + icon?: string; // curated icon name (apps/web/lib/plugins/icons.ts) Component: React.ComponentType; // wrapped in a PluginErrorBoundary mobileEnabled?: boolean; // include in the phone's grouped Panels picker. Default: false. } @@ -488,7 +558,7 @@ interface PluginTaskFilterOption { } interface TaskFilterRegistration { - id: string; // plugin-local filter id (unique within the plugin, not globally) + id: string; // plugin-local filter id (unique within the plugin, not globally) label: string; // filter section label shown in the dropdown getOptions(): PluginTaskFilterOption[]; // Called only when `selected` is non-empty — an empty selection is @@ -652,8 +722,10 @@ defaults, or the bare component when the route opted out (`topbar: false`). The chat composer toolbar (`components/task/chat/chat-input-toolbar-desktop.tsx` and `-mobile.tsx`, via `chat-input-plugin-actions.tsx`) hosts the - `chat-input-actions` slot, passing - `{ taskId, taskTitle, activeSessionId, sessionIds }`. `kanban-card-plugin-slots.tsx` + `chat-input-actions` slot for task and Quick Chat, passing the typed + `PluginComposerSlotProps`. Task creation and new-session creation mount + `task-create-input-actions` and `new-session-input-actions` with the same + capability contract. `kanban-card-plugin-slots.tsx` hosts `task-card-indicators` beside `PRTaskIcon` and `task-card-tags` as its own row, both mounted from `kanban-card-content.tsx`'s `KanbanCardBody`. - `components/task/dockview-shared.tsx` / `dockview-panel-content.tsx` / diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index 3695fa46be..67f6a013e4 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -89,11 +89,11 @@ directory. ## Choose a plugin shape -| Shape | Package contents | Typical contract | Minimal capability set | -| --- | --- | --- | --- | -| Backend plugin | Go binary and manifest | events, webhooks, Host data, state, secrets, or utility-agent calls | only the capabilities used by the backend | +| Shape | Package contents | Typical contract | Minimal capability set | +| ----------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------ | +| Backend plugin | Go binary and manifest | events, webhooks, Host data, state, secrets, or utility-agent calls | only the capabilities used by the backend | | UI-focused plugin | no-op managed binary, ui/bundle.js, optional styles/assets | routes, nav, named slots, WebSocket handlers, keybindings, shared store | ui.bundle; add ui.keybindings when declaring shortcuts | -| Combined plugin | Go binary plus UI bundle | UI calls a declared webhook or backend API; backend uses Host | union of the two surfaces, kept least-privilege | +| Combined plugin | Go binary plus UI bundle | UI calls a declared webhook or backend API; backend uses Host | union of the two surfaces, kept least-privilege | There is no separate HTTP server to launch. pluginsdk.Serve owns the go-plugin/gRPC handshake and Host injection. The backend implements @@ -116,22 +116,23 @@ curated React, UI, and app-store surface. capabilities.events controls event delivery. - GetConfig and EmitEvent are ungated. GetConfig returns this plugin's own config, including cleartext secret fields, so do not log or commit it. -- Declared webhook keys are routable, but Kandev does not authenticate callers - or enforce webhooks[].method. Validate the method, provider signature, and - replay/timestamp rules inside the plugin before side effects. +- Declared webhook keys default to public. Set `webhooks[].access: authenticated` + for browser UI and billable operations. Kandev does not enforce + `webhooks[].method`; public integrations must still validate the provider + signature and replay/timestamp rules before side effects. - capabilities.auth is the highest-risk capability. A webhook response may assert a verified external identity with X-Kandev-Auth-Login; only assert an email the IdP verified as owned by the subject. See [ADR 0050](../decisions/0050-plugin-external-auth-capability.md). ## Storage decision table -| Need | Use | Scope/lifecycle | Capability or rule | -| --- | --- | --- | --- | -| Small JSON object owned by this plugin | Host state: GetState, SetState, DeleteState, ListState | instance, workspace, task, or agent; survives restart/upgrade and is included in Kandev state backups | capabilities.state: true; values are JSON objects, not bare scalars | -| Per-user browser/plugin storage | host.storage: get/set/delete/list/subscribe | instance, workspace, task, session, or repository, scoped per user | capabilities.user_state: true; set/delete accept ifUnmodifiedSince and writerId | -| Operator configuration | Host.GetConfig and manifest config_schema | Plugin-owned settings; config changes restart an active subprocess | Ungated GetConfig; secret fields arrive cleartext in the subprocess | -| Plugin-owned credentials | Host.GetSecret/SetSecret/DeleteSecret, or secret: true config fields | Encrypted Kandev vault, namespaced to this plugin | capabilities.secrets: true; never log values | -| Files, caches, or plugin-managed database | KANDEV_PLUGIN_DATA_DIR | Shared across versions, removed on uninstall | Write only below the injected directory; own schema, locking, and migrations | +| Need | Use | Scope/lifecycle | Capability or rule | +| ----------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| Small JSON object owned by this plugin | Host state: GetState, SetState, DeleteState, ListState | instance, workspace, task, or agent; survives restart/upgrade and is included in Kandev state backups | capabilities.state: true; values are JSON objects, not bare scalars | +| Per-user browser/plugin storage | host.storage: get/set/delete/list/subscribe | instance, workspace, task, session, or repository, scoped per user | capabilities.user_state: true; set/delete accept ifUnmodifiedSince and writerId | +| Operator configuration | Host.GetConfig and manifest config_schema | Plugin-owned settings; config changes restart an active subprocess | Ungated GetConfig; secret fields arrive cleartext in the subprocess | +| Plugin-owned credentials | Host.GetSecret/SetSecret/DeleteSecret, or secret: true config fields | Encrypted Kandev vault, namespaced to this plugin | capabilities.secrets: true; never log values | +| Files, caches, or plugin-managed database | KANDEV_PLUGIN_DATA_DIR | Shared across versions, removed on uninstall | Write only below the injected directory; own schema, locking, and migrations | Host state is not host.storage: Host state is plugin-scoped backend state with no transaction primitive, so design updates to be idempotent; host.storage @@ -178,43 +179,45 @@ bookkeeping is host-internal; plugins do not call lifecycle methods. ### Frontend hook/API matrix -| Surface | Location and input | Manifest requirement | Cleanup/lifecycle | Small example | -| --- | --- | --- | --- | --- | -| registerRoute | registry.registerRoute(path, Component, options?); exact SPA path; options.topbar defaults to host chrome or false for full-bleed | Active ui.bundle | Route is removed on disable/uninstall; use destroy for subscriptions | registry.registerRoute("/acme", Page, { topbar: { title: "Acme" } }) | -| registerNavItem | { id, label, path, icon?, section? }; section is main, integrations, or accepted-but-not-rendered settings | Active ui.bundle | Nav item is revoked and removed from desktop/phone navigation | registry.registerNavItem({ id: "home", label: "Acme", path: "/acme", icon: "chart" }) | -| registerSettingsRoute | registerSettingsRoute(fullPath, Component) with an exact path under /settings/plugins//...; settings shell supplies chrome | Active ui.bundle | Route is removed on disable/uninstall | registry.registerSettingsRoute("/settings/plugins/acme/health", HealthPage) | -| registerComponent | registerComponent(slot, Component); component receives { slotProps?: unknown } | Active ui.bundle | Every registration is owner-tracked, error-isolated, and bulk-revoked | registry.registerComponent("task-sidebar", Panel) | -| registerWsHandler | registerWsHandler(action, handler(payload)); receives actions bridged from lib/ws | Active ui.bundle | Handler is removed on disable/uninstall; tolerate duplicate/replayed actions | registry.registerWsHandler("acme.updated", renderUpdate) | -| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; editable targets/core shortcuts win | registry.registerKeybinding("open-panel", () => host.openModal(...)) | -| registerTaskPanel | { id, title, icon?, Component, mobileEnabled? }; adds a row to the task workspace's "+" (add panel) menu; Component receives { panelId, taskId, sessionId, presentation } | Active ui.bundle | Panel renders behind its own error boundary; slow/failed reloads preserve it, a ready generation missing it closes it, and disable/uninstall closes every owned instance | registry.registerTaskPanel({ id: "notes", title: "Notes", Component: NotesPanel }) | -| registerTaskMenuAction | { id, label, icon?, group: "edit" \| "primary", visible?(context), run(context) }; "edit" nests inside the card's Edit submenu, "primary" renders as a flat top-level item between "Move to"/"Send to workflow" and "Link" | Active ui.bundle | Action is revoked on disable/uninstall; a throwing/rejecting run is caught and logged | registry.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "edit", run: doEnhance }) | -| registerTaskFilter | { id, label, getOptions(), matches(context, selected) }; adds a client-side, multi-select filter section to the kanban board's display dropdown, alongside Workflow/Repository | Active ui.bundle | Filter is revoked on disable/uninstall; selections are ephemeral (not persisted); matches is only called for a non-empty selection, and a throw is caught, logged, and treated as non-matching | registry.registerTaskFilter({ id: "tags", label: "Tags", getOptions: listTagOptions, matches: taskHasSelectedTag }) | -| host.React / host.jsx | Shared React instance and React.createElement alias | Active ui.bundle | No cleanup; never bundle a second React/Radix runtime | const h = host.jsx | -| host.store | Curated Zustand { getState, setState, subscribe } for the live app store | Active ui.bundle | Unsubscribe in destroy; setState mutates the whole SPA and is not a plugin database | const stop = host.store.subscribe(render) | -| host.api.fetch / baseUrl | fetch(path, init?) is scoped to /api/plugins//...; baseUrl is the backend origin for split-origin deployments | Active ui.bundle; backend path must be a declared webhook when relayed | Abort/ignore requests after destroy; do not assume a webhook authenticates callers | host.api.fetch("webhooks/inbound", { method: "POST" }) | -| host.storage | Authenticated, per-user key/value storage: get(scope, scopeId, key)/set(scope, scopeId, key, value, options?)/delete(scope, scopeId, key)/list(scope, scopeId) plus subscribe(filter, handler); no plugin backend required | capabilities.user_state: true | set/delete accept an optional writerId (appended to the host's per-tab id, not a replacement) for echo suppression; list returns every entry under the scope pair, unpaginated | host.storage.set("task", taskId, "note", value, { writerId: panelId }) | -| host.ui | Curated host instances: Alert*, Badge, Button, Card*, Checkbox, Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*, Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea, Tooltip*, RichTextEditor, RichTextReadOnly, plus Combobox, PageTopbar, TaskCreateDialog | Active ui.bundle | Host owns contexts/portals; render with host React and let modal/slot cleanup run | const Button = host.ui.Button | -| host.theme | Current "light" or "dark" theme | Active ui.bundle | Read during render; subscribe through host/app patterns if theme-sensitive | host.theme === "dark" | -| host.navigate | Soft SPA navigation navigate(href, { replace? }) | Active ui.bundle | No registry cleanup; avoid navigating to undeclared external origins | host.navigate("/t/" + taskId) | -| host.openModal | Host-owned modal: { title?, content, size?, dismissible? } -> { close() } | Active ui.bundle | Modal auto-closes on disable/uninstall; close handles are idempotent | const modal = host.openModal({ content: Panel }) | +| Surface | Location and input | Manifest requirement | Cleanup/lifecycle | Small example | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| registerRoute | registry.registerRoute(path, Component, options?); exact SPA path; options.topbar defaults to host chrome or false for full-bleed | Active ui.bundle | Route is removed on disable/uninstall; use destroy for subscriptions | registry.registerRoute("/acme", Page, { topbar: { title: "Acme" } }) | +| registerNavItem | { id, label, path, icon?, section? }; section is main, integrations, or accepted-but-not-rendered settings | Active ui.bundle | Nav item is revoked and removed from desktop/phone navigation | registry.registerNavItem({ id: "home", label: "Acme", path: "/acme", icon: "chart" }) | +| registerSettingsRoute | registerSettingsRoute(fullPath, Component) with an exact path under /settings/plugins//...; settings shell supplies chrome | Active ui.bundle | Route is removed on disable/uninstall | registry.registerSettingsRoute("/settings/plugins/acme/health", HealthPage) | +| registerComponent | registerComponent(slot, Component); component receives { slotProps?: unknown } | Active ui.bundle | Every registration is owner-tracked, error-isolated, and bulk-revoked | registry.registerComponent("task-sidebar", Panel) | +| registerWsHandler | registerWsHandler(action, handler(payload)); receives actions bridged from lib/ws | Active ui.bundle | Handler is removed on disable/uninstall; tolerate duplicate/replayed actions | registry.registerWsHandler("acme.updated", renderUpdate) | +| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; editable targets/core shortcuts win | registry.registerKeybinding("open-panel", () => host.openModal(...)) | +| registerTaskPanel | { id, title, icon?, Component, mobileEnabled? }; adds a row to the task workspace's "+" (add panel) menu; Component receives { panelId, taskId, sessionId, presentation } | Active ui.bundle | Panel renders behind its own error boundary; slow/failed reloads preserve it, a ready generation missing it closes it, and disable/uninstall closes every owned instance | registry.registerTaskPanel({ id: "notes", title: "Notes", Component: NotesPanel }) | +| registerTaskMenuAction | { id, label, icon?, group: "edit" \| "primary", visible?(context), run(context) }; "edit" nests inside the card's Edit submenu, "primary" renders as a flat top-level item between "Move to"/"Send to workflow" and "Link" | Active ui.bundle | Action is revoked on disable/uninstall; a throwing/rejecting run is caught and logged | registry.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "edit", run: doEnhance }) | +| registerTaskFilter | { id, label, getOptions(), matches(context, selected) }; adds a client-side, multi-select filter section to the kanban board's display dropdown, alongside Workflow/Repository | Active ui.bundle | Filter is revoked on disable/uninstall; selections are ephemeral (not persisted); matches is only called for a non-empty selection, and a throw is caught, logged, and treated as non-matching | registry.registerTaskFilter({ id: "tags", label: "Tags", getOptions: listTagOptions, matches: taskHasSelectedTag }) | +| host.React / host.jsx | Shared React instance and React.createElement alias | Active ui.bundle | No cleanup; never bundle a second React/Radix runtime | const h = host.jsx | +| host.store | Curated Zustand { getState, setState, subscribe } for the live app store | Active ui.bundle | Unsubscribe in destroy; setState mutates the whole SPA and is not a plugin database | const stop = host.store.subscribe(render) | +| host.api.fetch / baseUrl | fetch(path, init?) is scoped to /api/plugins//...; baseUrl is the backend origin for split-origin deployments | Active ui.bundle; backend path must be a declared webhook when relayed | Declare `webhooks[].access: authenticated` for UI-only or billable operations; abort requests on destroy | host.api.fetch("webhooks/inbound", { method: "POST" }) | +| host.storage | Authenticated, per-user key/value storage: get(scope, scopeId, key)/set(scope, scopeId, key, value, options?)/delete(scope, scopeId, key)/list(scope, scopeId) plus subscribe(filter, handler); no plugin backend required | capabilities.user_state: true | set/delete accept an optional writerId (appended to the host's per-tab id, not a replacement) for echo suppression; list returns every entry under the scope pair, unpaginated | host.storage.set("task", taskId, "note", value, { writerId: panelId }) | +| host.ui | Curated host instances: Alert*, Badge, Button, Card*, Checkbox, Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*, Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea, Tooltip*, RichTextEditor, RichTextReadOnly, plus Combobox, PageTopbar, TaskCreateDialog | Active ui.bundle | Host owns contexts/portals; render with host React and let modal/slot cleanup run | const Button = host.ui.Button | +| host.theme | Current "light" or "dark" theme | Active ui.bundle | Read during render; subscribe through host/app patterns if theme-sensitive | host.theme === "dark" | +| host.navigate | Soft SPA navigation navigate(href, { replace? }) | Active ui.bundle | No registry cleanup; avoid navigating to undeclared external origins | host.navigate("/t/" + taskId) | +| host.openModal | Host-owned modal: { title?, content, size?, dismissible? } -> { close() } | Active ui.bundle | Modal auto-closes on disable/uninstall; close handles are idempotent | const modal = host.openModal({ content: Panel }) | ### Supported named slots registerComponent currently has these mounted slots. The source type is open to strings, but an unmounted name renders nowhere. -| Slot | Mounted location | slotProps | -| --- | --- | --- | -| task-sidebar | Bottom of task-detail sidebar | none | -| settings-nav | Settings navigation tree | none | -| chat-input-actions | Chat composer toolbar | { taskId, taskTitle?, activeSessionId, sessionIds } | -| chat-top-bar | Session top bar | { taskId, taskTitle?, workspaceId, activeSessionId, sessionIds } | -| main-top-bar | Home/Kanban/Tasks top bar | { workspaceId, workspaceLabel?, currentPage } | -| app-status-bar-left | Left side of desktop status bar or mobile status drawer | AppStatusBarSlotProps | -| app-status-bar-right | Right side of desktop status bar or mobile status drawer | AppStatusBarSlotProps | -| plugin-settings | Top of this plugin's Settings > Plugins page | { pluginId, status }; owner-scoped to the plugin being viewed | -| task-card-indicators | Kanban card, beside the PR status icon | { taskId, workspaceId, workflowStepId } | -| task-card-tags | Kanban card, its own row below the badges row | { taskId, workspaceId, workflowStepId } | +| Slot | Mounted location | slotProps | +| ------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------- | +| task-sidebar | Bottom of task-detail sidebar | none | +| settings-nav | Settings navigation tree | none | +| chat-input-actions | Task or Quick Chat composer toolbar | PluginComposerSlotProps | +| task-create-input-actions | Task creation composer toolbar | PluginComposerSlotProps | +| new-session-input-actions | New-session composer toolbar | PluginComposerSlotProps | +| chat-top-bar | Session top bar | { taskId, taskTitle?, workspaceId, activeSessionId, sessionIds } | +| main-top-bar | Home/Kanban/Tasks top bar | { workspaceId, workspaceLabel?, currentPage } | +| app-status-bar-left | Left side of desktop status bar or mobile status drawer | AppStatusBarSlotProps | +| app-status-bar-right | Right side of desktop status bar or mobile status drawer | AppStatusBarSlotProps | +| plugin-settings | Top of this plugin's Settings > Plugins page | { pluginId, status }; owner-scoped to the plugin being viewed | +| task-card-indicators | Kanban card, beside the PR status icon | { taskId, workspaceId, workflowStepId } | +| task-card-tags | Kanban card, its own row below the badges row | { taskId, workspaceId, workflowStepId } | AppStatusBarSlotProps is { placement, presentation, density, pathname, activeWorkspaceId, activeTaskId, activeSessionId }. Desktop presentation is a @@ -222,15 +225,21 @@ compact 24px bar; mobile presentation is an in-flow drawer, so render a touch-usable row. Status items can be reordered by the host; plugins do not get an ordering API. +`PluginComposerSlotProps` is `{ surface, presentation, taskId, taskTitle?, +activeSessionId, sessionIds, disabled, submittable, disabledReason?, composer }`. +The `composer` capability provides `insertText`, `focus`, and asynchronous +`submit`; submit returns `submitted` only when the native composer accepts the +operation, otherwise `blocked` or `unavailable`. + ## Backend contract ### Plugin lifecycle surface -| Surface | Input/output | Manifest capability or declaration | Lifecycle/cleanup | Example | -| --- | --- | --- | --- | --- | -| Plugin.OnEvent | OnEvent(ctx, *pluginsdk.Event) error; event has EventID, EventType, OccurredAt, WorkspaceID, Payload | Matching capabilities.events subject/pattern | Delivery is sequential per plugin with bounded queue and retries; make handlers idempotent by EventID and reconcile critical state | if event.EventType == "task.created" { ... } | -| Plugin.HandleWebhook | HandleWebhook(ctx, *pluginsdk.WebhookRequest) (*WebhookResponse, error); request includes key, method, path, query, headers, body | webhooks[].key; auth additionally required for login assertion header | Undeclared keys are 404; GET/POST are relayed; validate method/signature and bound side effects | if req.WebhookKey == "inbound" { ... } | -| Host.EmitEvent | EmitEvent(ctx, name, payload) publishes plugin.. | None; intentionally ungated | Subscribers must declare the emitted subject; use versioned names/payloads | host.EmitEvent(ctx, "sync.completed", payload-map) | +| Surface | Input/output | Manifest capability or declaration | Lifecycle/cleanup | Example | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| Plugin.OnEvent | OnEvent(ctx, \*pluginsdk.Event) error; event has EventID, EventType, OccurredAt, WorkspaceID, Payload | Matching capabilities.events subject/pattern | Delivery is sequential per plugin with bounded queue and retries; make handlers idempotent by EventID and reconcile critical state | if event.EventType == "task.created" { ... } | +| Plugin.HandleWebhook | HandleWebhook(ctx, *pluginsdk.WebhookRequest) (*WebhookResponse, error); request includes key, method, path, query, headers, body | webhooks[].key; auth additionally required for login assertion header | Undeclared keys are 404; GET/POST are relayed; validate method/signature and bound side effects | if req.WebhookKey == "inbound" { ... } | +| Host.EmitEvent | EmitEvent(ctx, name, payload) publishes plugin.. | None; intentionally ungated | Subscribers must declare the emitted subject; use versioned names/payloads | host.EmitEvent(ctx, "sync.completed", payload-map) | Event delivery is best-effort: Kandev makes the initial attempt plus retries with 5s, 15s, and 45s delays, and a bounded in-memory queue/error buffer can @@ -241,21 +250,21 @@ subscription vocabulary and wildcard rules are in the ### Host API matrix -| Host surface | Methods | Required manifest capability | Notes | -| --- | --- | --- | --- | -| State | GetState, SetState, DeleteState, ListState | state: true | Plugin-scoped JSON objects keyed by scope/scopeID/key; no transactions | -| Config | GetConfig | None | Reads this plugin's validated config_schema; secret fields are cleartext in the subprocess; config updates restart active plugins | -| Secrets | RevealSecret, GetSecret, SetSecret, DeleteSecret | secrets: true | Encrypted vault; plugin-owned keys are namespaced; never log values | -| Tasks | Tasks().List, Tasks().Get | api_read: tasks | Typed DTOs and opaque pagination cursor | -| Tasks writes | Tasks().Create, Tasks().Update | api_write: tasks | Implemented; routed through Kandev services so events/WS updates fire | -| Sessions | Sessions().List, Sessions().CodeStats | api_read: sessions | Typed session and computed code-stat records | -| Workspaces | Workspaces().List | api_read: workspaces | Instance-visible workspaces | -| Workflows | Workflows().List, Workflows().ListSteps | api_read: workflows | List steps by workflow id | -| Agent profiles | AgentProfiles().List | api_read: agent_profiles | Global agent profiles exposed by the Host data API | -| Repositories | Repositories().List | api_read: repositories | List by workspace id | -| Messages | Messages().List | api_read: messages | Historical user/agent content; Kandev system blocks are stripped | -| Message send | Messages().Send | api_write: messages | Sends a prompt to a task session and records plugin: author | -| Utility agent | InvokeUtilityAgent(ctx, prompt) | agent_invoke: true plus config_schema.utility_agent (format: utility-agent) | One-shot completion using the selected utility-agent ID; Kandev resolves that utility's enabled profile, permissions, and launch settings. Missing or stale bindings are FailedPrecondition | +| Host surface | Methods | Required manifest capability | Notes | +| -------------- | ------------------------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| State | GetState, SetState, DeleteState, ListState | state: true | Plugin-scoped JSON objects keyed by scope/scopeID/key; no transactions | +| Config | GetConfig | None | Reads this plugin's validated config_schema; secret fields are cleartext in the subprocess; config updates restart active plugins | +| Secrets | RevealSecret, GetSecret, SetSecret, DeleteSecret | secrets: true | Encrypted vault; plugin-owned keys are namespaced; never log values | +| Tasks | Tasks().List, Tasks().Get | api_read: tasks | Typed DTOs and opaque pagination cursor | +| Tasks writes | Tasks().Create, Tasks().Update | api_write: tasks | Implemented; routed through Kandev services so events/WS updates fire | +| Sessions | Sessions().List, Sessions().CodeStats | api_read: sessions | Typed session and computed code-stat records | +| Workspaces | Workspaces().List | api_read: workspaces | Instance-visible workspaces | +| Workflows | Workflows().List, Workflows().ListSteps | api_read: workflows | List steps by workflow id | +| Agent profiles | AgentProfiles().List | api_read: agent_profiles | Global agent profiles exposed by the Host data API | +| Repositories | Repositories().List | api_read: repositories | List by workspace id | +| Messages | Messages().List | api_read: messages | Historical user/agent content; Kandev system blocks are stripped | +| Message send | Messages().Send | api_write: messages | Sends a prompt to a task session and records plugin: author | +| Utility agent | InvokeUtilityAgent(ctx, prompt) | agent_invoke: true plus config_schema.utility_agent (format: utility-agent) | One-shot completion using the selected utility-agent ID; Kandev resolves that utility's enabled profile, permissions, and launch settings. Missing or stale bindings are FailedPrecondition | The Go signatures, filters, DTOs, and pagination types live in apps/backend/pkg/pluginsdk/host.go and data_types.go. api_write task/message @@ -318,7 +327,12 @@ window.registerKandevPlugin("acme-dashboard", { return host.jsx("main", null, "Acme dashboard"); } registry.registerRoute("/acme-dashboard", Page); - registry.registerNavItem({ id: "home", label: "Acme", path: "/acme-dashboard", icon: "chart" }); + registry.registerNavItem({ + id: "home", + label: "Acme", + path: "/acme-dashboard", + icon: "chart", + }); }, }); ``` @@ -388,34 +402,38 @@ function useNoteValue(taskId, panelId) { // duplicating the read-and-commit logic without its guards. const guardRef = host.React.useRef({ cancelled: false, generation: 0 }); - const refresh = host.React.useCallback((options = {}) => { - const preserveValue = options.preserveValue ?? dirtyRef.current; - const guard = guardRef.current; - const generation = ++guard.generation; - return host.storage.get("task", taskId, "note").then( - (entry) => { - // Ignore a response that resolves after taskId changed, the panel - // unmounted, or a newer refresh started — otherwise a stale read - // (the previous task's note, or one of two overlapping reads that - // resolved out of order) can land in the current field. - if (guard.cancelled || generation !== guard.generation) return; - if (!preserveValue && !dirtyRef.current) setValue(entry ? entry.value : ""); - updatedAtRef.current = entry ? entry.updatedAt : undefined; - setLoadedTaskId(taskId); - setReadError(false); - return true; - }, - () => { - // Do not mark the task loaded after a rejected read. Rendering an - // empty editor here would allow its first save to omit - // ifUnmodifiedSince and overwrite an existing note. Keep the panel - // in a retry state until an authoritative read succeeds. - if (guard.cancelled || generation !== guard.generation) return false; - setReadError(true); - return false; - }, - ); - }, [taskId]); + const refresh = host.React.useCallback( + (options = {}) => { + const preserveValue = options.preserveValue ?? dirtyRef.current; + const guard = guardRef.current; + const generation = ++guard.generation; + return host.storage.get("task", taskId, "note").then( + (entry) => { + // Ignore a response that resolves after taskId changed, the panel + // unmounted, or a newer refresh started — otherwise a stale read + // (the previous task's note, or one of two overlapping reads that + // resolved out of order) can land in the current field. + if (guard.cancelled || generation !== guard.generation) return; + if (!preserveValue && !dirtyRef.current) + setValue(entry ? entry.value : ""); + updatedAtRef.current = entry ? entry.updatedAt : undefined; + setLoadedTaskId(taskId); + setReadError(false); + return true; + }, + () => { + // Do not mark the task loaded after a rejected read. Rendering an + // empty editor here would allow its first save to omit + // ifUnmodifiedSince and overwrite an existing note. Keep the panel + // in a retry state until an authoritative read succeeds. + if (guard.cancelled || generation !== guard.generation) return false; + setReadError(true); + return false; + }, + ); + }, + [taskId], + ); host.React.useEffect(() => { const guard = guardRef.current; @@ -454,10 +472,15 @@ function useNoteValue(taskId, panelId) { } function NotesPanel({ taskId, panelId }) { - const { value, setValue, updatedAtRef, dirtyRef, loaded, readError, refresh } = useNoteValue( - taskId, - panelId, - ); + const { + value, + setValue, + updatedAtRef, + dirtyRef, + loaded, + readError, + refresh, + } = useNoteValue(taskId, panelId); const [conflict, setConflict] = host.React.useState(false); const [writeError, setWriteError] = host.React.useState(false); const pendingValueRef = host.React.useRef(undefined); @@ -542,11 +565,13 @@ function NotesPanel({ taskId, panelId }) { setConflict(false); setWriteError(false); return () => { - if (writeGenerationRef.current === generation) writeGenerationRef.current += 1; + if (writeGenerationRef.current === generation) + writeGenerationRef.current += 1; pendingValueRef.current = undefined; writeInFlightRef.current = false; writeBlockedRef.current = false; - if (writeTimerRef.current !== undefined) clearTimeout(writeTimerRef.current); + if (writeTimerRef.current !== undefined) + clearTimeout(writeTimerRef.current); writeTimerRef.current = undefined; }; }, [taskId, panelId]); @@ -574,7 +599,11 @@ function NotesPanel({ taskId, panelId }) { "div", null, "This note changed elsewhere. Your edit is preserved.", - host.jsx("button", { type: "button", onClick: retryWrite }, "Retry my edit"), + host.jsx( + "button", + { type: "button", onClick: retryWrite }, + "Retry my edit", + ), ) : writeError ? host.jsx( @@ -587,7 +616,13 @@ function NotesPanel({ taskId, panelId }) { return host.jsx("div", null, editor, status); } -registry.registerTaskPanel({ id: "notes", title: "Notes", icon: "book", Component: NotesPanel, mobileEnabled: true }); +registry.registerTaskPanel({ + id: "notes", + title: "Notes", + icon: "book", + Component: NotesPanel, + mobileEnabled: true, +}); ``` On a phone, all `mobileEnabled` panels appear under one **Panels** bottom-nav @@ -612,8 +647,10 @@ task, not for an unbounded per-item collection. ### 4. Webhook receiver Declare the route, validate the method and provider signature inside the -backend, and return a bounded status/body. Kandev caps incoming bodies at 4 MiB -but does not authenticate the caller. +backend, and return a bounded status/body. Public webhooks are capped at 4 MiB. +For plugin-UI operations, declare `access: authenticated`; those routes may +raise `max_body_bytes` to 16 MiB. Kandev verifies the current user but strips +its session cookie and PAT before relaying the remaining headers. ```yaml webhooks: @@ -717,7 +754,6 @@ An empty selection is implicit "All" for that section — `matches` is only called once at least one option is selected, and a `matches` that throws is caught, logged, and treated as non-matching for that task. - ## Build, package, install, and test ### Plugin repository @@ -788,12 +824,12 @@ instance; it removes the plugin package and KANDEV_PLUGIN_DATA_DIR. Reinstalling the same id/version is rejected. Bump version or uninstall before repackaging. -| Layer | Command/source | Checks | Does not check | -| --- | --- | --- | --- | -| Source | go test, go vet, template lint/build | Backend compilation and tests | Package paths or browser runtime | -| Package | plugin-pack | Manifest presence, safe staging, generated checksums | Full manifest semantics, JS execution, live Host calls | -| Install | pkgtar.Install via upload/sideload | Manifest validation, archive safety, checksums, managed runtime, host executable | Plugin behavior or UI rendering | -| Runtime | Disposable instance smoke test | Subprocess, Host permissions, events, webhooks, UI cleanup | Production-scale load/provenance | +| Layer | Command/source | Checks | Does not check | +| ------- | ------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------ | +| Source | go test, go vet, template lint/build | Backend compilation and tests | Package paths or browser runtime | +| Package | plugin-pack | Manifest presence, safe staging, generated checksums | Full manifest semantics, JS execution, live Host calls | +| Install | pkgtar.Install via upload/sideload | Manifest validation, archive safety, checksums, managed runtime, host executable | Plugin behavior or UI rendering | +| Runtime | Disposable instance smoke test | Subprocess, Host permissions, events, webhooks, UI cleanup | Production-scale load/provenance | ## Common mistakes @@ -818,8 +854,8 @@ repackaging. uninstall. - **Importing direct DB/internal packages:** use apps/backend/pkg/pluginsdk and typed Host methods. Direct database access is outside the plugin contract. -- **Trusting webhook metadata:** webhooks[].method is informational and the route - is not authenticated by Kandev. Validate method, signature, timestamp, replay +- **Trusting webhook metadata:** `webhooks[].method` is informational. Public + routes are not authenticated by Kandev, so validate method, signature, timestamp, replay protection, and body before side effects. - **Bundling React:** use host.React, host.jsx, and host.ui; a second React or Radix copy breaks shared contexts and portals. diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md index 884c94f215..47903ce66b 100644 --- a/docs/public/plugins-manifest.md +++ b/docs/public/plugins-manifest.md @@ -22,62 +22,78 @@ workflow and [Plugins](plugins.md) for install/operate. ## Annotated example ```yaml -id: "kandev-plugin-slack" # ^[a-z0-9][a-z0-9._-]*$ -api_version: 1 # must be 1 (the only supported value) +id: "kandev-plugin-slack" # ^[a-z0-9][a-z0-9._-]*$ +api_version: 1 # must be 1 (the only supported value) version: "1.0.0" display_name: "Slack Notifications" description: "Post to Slack on task events, relay messages to agents" author: "kandev" -categories: ["connector"] # connector | automation | tools | analytics -repo_url: "https://github.com/kdlbs/kandev-plugin-slack" # optional; "Repo" link in Settings > Plugins +categories: ["connector"] # connector | automation | tools | analytics +repo_url: "https://github.com/kdlbs/kandev-plugin-slack" # optional; "Repo" link in Settings > Plugins runtime: - type: binary # only supported value today + type: binary # only supported value today 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 - # any subset; the host's - key - # is required at install time -min_kandev_version: "0.78.0" # optional + windows-amd64: + server/plugin-windows-amd64.exe + # any subset; the host's - key + # is required at install time +min_kandev_version: "0.78.0" # optional capabilities: events: ["task.created", "task.state_changed", "agent.completed"] - api_read: ["tasks", "agent_profiles"] # gates the Host data-reader RPCs - api_write: ["tasks"] # gates Tasks().Create/Update + api_read: ["tasks", "agent_profiles"] # gates the Host data-reader RPCs + api_write: ["tasks"] # gates Tasks().Create/Update state: true secrets: true - agent_invoke: true # gates Host.InvokeUtilityAgent - auth: true # gates external (OIDC/SAML) login — see ADR 0050 - user_state: true # gates host.storage (per-user browser storage) + agent_invoke: true # gates Host.InvokeUtilityAgent + auth: true # gates external (OIDC/SAML) login — see ADR 0050 + user_state: true # gates host.storage (per-user browser storage) webhooks: - key: "slack-events" description: "Slack Events API webhook" - method: "POST" # informational only, not enforced + method: "POST" # informational only, not enforced + access: public # public (default) or authenticated + max_body_bytes: 4194304 # public maximum 4 MiB; authenticated maximum 16 MiB -auth_providers: # login buttons (needs capabilities.auth) +auth_providers: # login buttons (needs capabilities.auth) - id: "google" display_name: "Google" - initiate: "login-start" # names a webhook key above; the button navigates there + initiate: "login-start" # names a webhook key above; the button navigates there config_schema: type: object properties: - bot_token: { type: string, secret: true, title: "Bot Token", description: "Slack bot OAuth token" } - default_channel: { type: string, description: "Default channel for notifications" } + bot_token: + { + type: string, + secret: true, + title: "Bot Token", + description: "Slack bot OAuth token", + } + default_channel: + { type: string, description: "Default channel for notifications" } notify_on_task_created: { type: boolean, default: true } - utility_agent: { type: string, format: utility-agent, title: "Utility Agent", description: "Agent used for plugin LLM calls" } + utility_agent: + { + type: string, + format: utility-agent, + title: "Utility Agent", + description: "Agent used for plugin LLM calls", + } required: ["bot_token", "default_channel", "utility_agent"] -ui: # optional native frontend plugin - bundle: "/ui/bundle.js" # root-relative - styles: ["/ui/plugin.css"] # optional, root-relative - keybindings: # optional, requires ui.bundle - - id: "open-panel" # plugin-local: ^[a-z0-9][a-z0-9-]*$ - default: "mod+shift+j" # combo grammar, see field reference +ui: # optional native frontend plugin + bundle: "/ui/bundle.js" # root-relative + styles: ["/ui/plugin.css"] # optional, root-relative + keybindings: # optional, requires ui.bundle + - id: "open-panel" # plugin-local: ^[a-z0-9][a-z0-9-]*$ + default: "mod+shift+j" # combo grammar, see field reference description: "Open the Acme panel" ``` @@ -88,44 +104,46 @@ ui: # optional native frontend plugin
Complete field reference and validation rules -| Field | Required | Type | Notes | -|---|---|---|---| -| `id` | yes | string | Must match `^[a-z0-9][a-z0-9._-]*$` (lowercase alphanumeric, dots, underscores, hyphens; must start with a lowercase alphanumeric). Directory name under `~/.kandev/plugins/`. | -| `api_version` | yes | int | Must be exactly `1`. Any other value is rejected. | -| `version` | yes | string | Free-form; used as the version directory name (`~/.kandev/plugins///`). | -| `display_name` | no | string | Shown in Settings > Plugins. | -| `description` | no | string | Shown in Settings > Plugins. | -| `author` | no | string | Free-form. | -| `categories` | no | string[] | Each entry must be one of `connector`, `automation`, `tools`, `analytics`. Unknown values are rejected. | -| `icon` | no | string | Package-relative path (e.g. `icon.svg`) to an image the package ships, rendered on the [marketplace](plugins-marketplace.md) card and in plugin lists. The registry index-build resolves it to an absolute `icon_url`; for an installed plugin it is served from the extracted package. Omit it and the card falls back to a letter tile. | -| `repo_url` | no | string | Absolute `http(s)` URL to the plugin's source repository. Rendered as a "Repo" link in Settings > Plugins (both the installed list and the plugin detail). Any other scheme (e.g. `javascript:`) is rejected at registration. Distinct from the marketplace card's `repo_url`, which the registry derives from `plugins.yaml`; declare this in the manifest so sideloaded and directly-installed plugins also carry the link. | -| `runtime.type` | conditionally | string | `"binary"` is the only supported value. Setting it (vs. leaving it empty) makes the manifest **runtime-managed** — see "Managed vs. legacy" below. | -| `runtime.executables` | required when `runtime.type: binary` | map\ | Key is `-` (e.g. `linux-amd64`, `darwin-arm64`, `windows-amd64`); value is a clean, package-relative path under `server/` (no leading `/`, no `..` segments). At least one entry required; the running host's key must be present at install time. Windows values end in `.exe`. | -| `min_kandev_version` | no | string | Lowest Kandev version this plugin supports. Enforced at **install time**: installing onto an older Kandev fails with `requires kandev >= , running ` and nothing is registered. Already-installed plugins are never re-checked at load or start. A Kandev built from source reports the version `dev` and skips the check entirely, so local development is unaffected. Compare as dotted numeric segments (`0.78.0`) — a `v` prefix does not parse as a number and falls back to a string comparison. | -| `capabilities.events` | no | string[] | Bus subjects (or wildcard patterns) this plugin subscribes to. See "Event subscription vocabulary" below. | -| `capabilities.api_read` | no | string[] | Gates the Host data API's read-only accessors. Each entry is a resource name: `tasks`, `sessions`, `messages`, `workspaces`, `workflows`, `agent_profiles`, `repositories`. Calling the matching `Host` accessor (e.g. `Tasks()`) without its resource declared returns gRPC `PermissionDenied`. See "Host data API resource vocabulary" below. | -| `capabilities.api_write` | no | string[] | Gates Host data writes. Current resources are `tasks` (`Tasks().Create/Update`) and `messages` (`Messages().Send`); reads and writes are gated independently. | -| `capabilities.state` | no | bool | Gates `Host.GetState`/`SetState`/`DeleteState`/`ListState`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. | -| `capabilities.secrets` | no | bool | Gates `Host.RevealSecret`/`GetSecret`/`SetSecret`/`DeleteSecret`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. | -| `capabilities.agent_invoke` | no | bool | Gates `Host.InvokeUtilityAgent` — a one-shot completion run by the utility agent selected for this plugin. Declare a `utility_agent` config property with `type: string` and `format: utility-agent`; Settings > Plugins renders the picker. The selected utility agent resolves its own enabled ACP profile. Calling without this capability returns gRPC `PermissionDenied`; calling without a valid profile-backed selection returns gRPC `FailedPrecondition`. See ADR 0048. | -| `capabilities.auth` | no | bool | Lets the plugin log a visitor in against an external IdP (OIDC/SAML). Its webhook validates the token, then asserts the identity to Kandev via the `X-Kandev-Auth-Login` response header (`{provider, subject, email, display_name}`); Kandev mints the session and sets the cookie — the plugin never sees the token. Requires authentication enabled; new users are provisioned as members, and Kandev never creates an admin nor auto-links to an existing admin account. **You MUST only assert an email the IdP verified as owned by the subject — a spoofed email claim is account takeover.** Highest-privilege capability; grant only to trusted plugins. See ADR 0050. | -| `capabilities.user_state` | no | bool | Gates `host.storage` (`get`/`set`/`delete`/`list`/`subscribe`), the authenticated per-user browser storage surface at `/api/plugins/{id}/user-state/...`. Unlike `capabilities.state` (the gRPC `Host.SetState` family, written by the plugin's own backend), this is reachable directly from the plugin's frontend bundle with no Go backend required — every read/write is scoped to the calling user. Calling the route without this capability returns `403`. See [Authoring a plugin](plugins-authoring.md) and the per-user-plugin-storage decision record. | -| `webhooks[].key` | yes | string | Must be unique within the manifest. Used in the relay path `POST /api/plugins/{id}/webhooks/{key}`. | -| `webhooks[].description` | no | string | Free-form. | -| `webhooks[].method` | no | string | **Informational only** — kandev does not validate or enforce the inbound HTTP method against this value. | -| `auth_providers[]` | no | object[] | Login buttons this plugin contributes to the pre-auth login screen (needs `capabilities.auth`). Each is `{ id, display_name, initiate }`, where `initiate` names one of the plugin's `webhooks[].key` values — the button navigates to that webhook, which 302-redirects to the IdP. Surfaced anonymously in the boot payload as `auth.ssoProviders`. | -| `config_schema` | no | object | JSON-Schema-like object driving the settings form at **Settings > Plugins > ``** (`GET /api/plugins/{id}/config` and `PATCH /api/plugins/{id}`). See "Config schema validation and secret fields" below. | -| `ui.bundle` | no | string | Root-relative path (must start with `/`, e.g. `/ui/bundle.js`) to the plugin's native UI ES module, served at `GET /api/plugins/{id}/bundle`. | -| `ui.styles` | no | string[] | Root-relative CSS paths (each must start with `/`), served at `GET /api/plugins/{id}/ui/*` and injected as `` tags on load. | -| `ui.pages` | no | object[] | Optional declarative metadata accepted by the manifest model. The current frontend does not render these entries; a native bundle registers its supported routes/nav/slots at runtime, so most plugins omit `ui.pages`. | -| `ui.pages[].key` | yes* | string | Stable identifier for the page (*required when a page entry is present). | -| `ui.pages[].title` | yes* | string | Display title. | -| `ui.pages[].path` | yes* | string | Route path for the page. | -| `ui.pages[].surface` | yes* | string | Metadata enum (`settings` · `task-panel` · `main-nav`) validated by the manifest parser. It is not a current frontend mount; use the registry hooks in the authoring guide. | -| `ui.keybindings` | no | object[] | Declares plugin keybindings bound at runtime via `registerKeybinding`. Requires `ui.bundle`. | -| `ui.keybindings[].id` | yes* | string | Stable, plugin-local slug (*required when a keybinding entry is present). Must match `^[a-z0-9][a-z0-9-]*$` and be unique within this plugin's own `ui.keybindings` list — not globally; the effective shortcut is namespaced `plugin:{pluginId}:{id}`. | -| `ui.keybindings[].default` | yes* | string | Default combo string. `+`-separated tokens: zero or more modifiers from `mod`, `ctrl`, `cmd`, `meta`, `alt`, `option`, `shift` (`mod` = ⌘ on macOS, Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not combine with a digit or symbol key — the browser reports the shifted glyph for those keys, so the combo could never match. | -| `ui.keybindings[].description` | yes* | string | Non-empty, human-readable label shown in **Settings > Keyboard Shortcuts**. | +| Field | Required | Type | Notes | +| ------------------------------ | ------------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | yes | string | Must match `^[a-z0-9][a-z0-9._-]*$` (lowercase alphanumeric, dots, underscores, hyphens; must start with a lowercase alphanumeric). Directory name under `~/.kandev/plugins/`. | +| `api_version` | yes | int | Must be exactly `1`. Any other value is rejected. | +| `version` | yes | string | Free-form; used as the version directory name (`~/.kandev/plugins///`). | +| `display_name` | no | string | Shown in Settings > Plugins. | +| `description` | no | string | Shown in Settings > Plugins. | +| `author` | no | string | Free-form. | +| `categories` | no | string[] | Each entry must be one of `connector`, `automation`, `tools`, `analytics`. Unknown values are rejected. | +| `icon` | no | string | Package-relative path (e.g. `icon.svg`) to an image the package ships, rendered on the [marketplace](plugins-marketplace.md) card and in plugin lists. The registry index-build resolves it to an absolute `icon_url`; for an installed plugin it is served from the extracted package. Omit it and the card falls back to a letter tile. | +| `repo_url` | no | string | Absolute `http(s)` URL to the plugin's source repository. Rendered as a "Repo" link in Settings > Plugins (both the installed list and the plugin detail). Any other scheme (e.g. `javascript:`) is rejected at registration. Distinct from the marketplace card's `repo_url`, which the registry derives from `plugins.yaml`; declare this in the manifest so sideloaded and directly-installed plugins also carry the link. | +| `runtime.type` | conditionally | string | `"binary"` is the only supported value. Setting it (vs. leaving it empty) makes the manifest **runtime-managed** — see "Managed vs. legacy" below. | +| `runtime.executables` | required when `runtime.type: binary` | map\ | Key is `-` (e.g. `linux-amd64`, `darwin-arm64`, `windows-amd64`); value is a clean, package-relative path under `server/` (no leading `/`, no `..` segments). At least one entry required; the running host's key must be present at install time. Windows values end in `.exe`. | +| `min_kandev_version` | no | string | Lowest Kandev version this plugin supports. Enforced at **install time**: installing onto an older Kandev fails with `requires kandev >= , running ` and nothing is registered. Already-installed plugins are never re-checked at load or start. A Kandev built from source reports the version `dev` and skips the check entirely, so local development is unaffected. Compare as dotted numeric segments (`0.78.0`) — a `v` prefix does not parse as a number and falls back to a string comparison. | +| `capabilities.events` | no | string[] | Bus subjects (or wildcard patterns) this plugin subscribes to. See "Event subscription vocabulary" below. | +| `capabilities.api_read` | no | string[] | Gates the Host data API's read-only accessors. Each entry is a resource name: `tasks`, `sessions`, `messages`, `workspaces`, `workflows`, `agent_profiles`, `repositories`. Calling the matching `Host` accessor (e.g. `Tasks()`) without its resource declared returns gRPC `PermissionDenied`. See "Host data API resource vocabulary" below. | +| `capabilities.api_write` | no | string[] | Gates Host data writes. Current resources are `tasks` (`Tasks().Create/Update`) and `messages` (`Messages().Send`); reads and writes are gated independently. | +| `capabilities.state` | no | bool | Gates `Host.GetState`/`SetState`/`DeleteState`/`ListState`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. | +| `capabilities.secrets` | no | bool | Gates `Host.RevealSecret`/`GetSecret`/`SetSecret`/`DeleteSecret`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. | +| `capabilities.agent_invoke` | no | bool | Gates `Host.InvokeUtilityAgent` — a one-shot completion run by the utility agent selected for this plugin. Declare a `utility_agent` config property with `type: string` and `format: utility-agent`; Settings > Plugins renders the picker. The selected utility agent resolves its own enabled ACP profile. Calling without this capability returns gRPC `PermissionDenied`; calling without a valid profile-backed selection returns gRPC `FailedPrecondition`. See ADR 0048. | +| `capabilities.auth` | no | bool | Lets the plugin log a visitor in against an external IdP (OIDC/SAML). Its webhook validates the token, then asserts the identity to Kandev via the `X-Kandev-Auth-Login` response header (`{provider, subject, email, display_name}`); Kandev mints the session and sets the cookie — the plugin never sees the token. Requires authentication enabled; new users are provisioned as members, and Kandev never creates an admin nor auto-links to an existing admin account. **You MUST only assert an email the IdP verified as owned by the subject — a spoofed email claim is account takeover.** Highest-privilege capability; grant only to trusted plugins. See ADR 0050. | +| `capabilities.user_state` | no | bool | Gates `host.storage` (`get`/`set`/`delete`/`list`/`subscribe`), the authenticated per-user browser storage surface at `/api/plugins/{id}/user-state/...`. Unlike `capabilities.state` (the gRPC `Host.SetState` family, written by the plugin's own backend), this is reachable directly from the plugin's frontend bundle with no Go backend required — every read/write is scoped to the calling user. Calling the route without this capability returns `403`. See [Authoring a plugin](plugins-authoring.md) and the per-user-plugin-storage decision record. | +| `webhooks[].key` | yes | string | Must be unique within the manifest. Used in the relay path `POST /api/plugins/{id}/webhooks/{key}`. | +| `webhooks[].description` | no | string | Free-form. | +| `webhooks[].method` | no | string | **Informational only** — kandev does not validate or enforce the inbound HTTP method against this value. | +| `webhooks[].access` | no | string | `public` (default) allows anonymous callers; `authenticated` requires a Kandev identity. Cookie-authenticated browser calls remain subject to Kandev's same-origin policy. | +| `webhooks[].max_body_bytes` | no | int | Request-body limit for this key. Defaults to 4 MiB. Public webhooks cannot exceed 4 MiB; authenticated webhooks can request up to 16 MiB. Plugins using this field should declare the first supporting `min_kandev_version`. | +| `auth_providers[]` | no | object[] | Login buttons this plugin contributes to the pre-auth login screen (needs `capabilities.auth`). Each is `{ id, display_name, initiate }`, where `initiate` names one of the plugin's `webhooks[].key` values — the button navigates to that webhook, which 302-redirects to the IdP. Surfaced anonymously in the boot payload as `auth.ssoProviders`. | +| `config_schema` | no | object | JSON-Schema-like object driving the settings form at **Settings > Plugins > ``** (`GET /api/plugins/{id}/config` and `PATCH /api/plugins/{id}`). See "Config schema validation and secret fields" below. | +| `ui.bundle` | no | string | Root-relative path (must start with `/`, e.g. `/ui/bundle.js`) to the plugin's native UI ES module, served at `GET /api/plugins/{id}/bundle`. | +| `ui.styles` | no | string[] | Root-relative CSS paths (each must start with `/`), served at `GET /api/plugins/{id}/ui/*` and injected as `` tags on load. | +| `ui.pages` | no | object[] | Optional declarative metadata accepted by the manifest model. The current frontend does not render these entries; a native bundle registers its supported routes/nav/slots at runtime, so most plugins omit `ui.pages`. | +| `ui.pages[].key` | yes\* | string | Stable identifier for the page (\*required when a page entry is present). | +| `ui.pages[].title` | yes\* | string | Display title. | +| `ui.pages[].path` | yes\* | string | Route path for the page. | +| `ui.pages[].surface` | yes\* | string | Metadata enum (`settings` · `task-panel` · `main-nav`) validated by the manifest parser. It is not a current frontend mount; use the registry hooks in the authoring guide. | +| `ui.keybindings` | no | object[] | Declares plugin keybindings bound at runtime via `registerKeybinding`. Requires `ui.bundle`. | +| `ui.keybindings[].id` | yes\* | string | Stable, plugin-local slug (_required when a keybinding entry is present). Must match `^[a-z0-9][a-z0-9-]_$`and be unique within this plugin's own`ui.keybindings`list — not globally; the effective shortcut is namespaced`plugin:{pluginId}:{id}`. | +| `ui.keybindings[].default` | yes\* | string | Default combo string. `+`-separated tokens: zero or more modifiers from `mod`, `ctrl`, `cmd`, `meta`, `alt`, `option`, `shift` (`mod` = ⌘ on macOS, Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not combine with a digit or symbol key — the browser reports the shifted glyph for those keys, so the combo could never match. | +| `ui.keybindings[].description` | yes\* | string | Non-empty, human-readable label shown in **Settings > Keyboard Shortcuts**. | `ui.pages` is declarative manifest metadata only and is not currently rendered by the frontend. A native bundle's runtime nav items, icons, routes, named @@ -142,7 +160,7 @@ must **not** set `base_url` or an `endpoints` block (`health`/`events`/ remote/operator-hosted tier, and validation rejects a managed manifest that sets them. -A manifest with an empty `runtime.type` still *parses* as a legacy remote +A manifest with an empty `runtime.type` still _parses_ as a legacy remote manifest (with `base_url`/`endpoints` instead) and passes `manifest.Validate()` on its own — but the **installer** rejects it: `pkgtar .Install` requires `manifest.IsManaged()` to be true (`runtime.type: @@ -228,39 +246,39 @@ matched — so a plugin subscribed to `shell.output.*` reads the session id off `event_type` as its last segment. For an unsuffixed subject that string is just the subject itself (`task.created`). -| Domain | Events | -|---|---| -| Tasks | `task.created`, `task.updated`, `task.state_changed`, `task.deleted`, `task.moved`, `task.tree_hold_created`, `task.tree_hold_released` | -| Workspaces | `workspace.created`, `workspace.updated`, `workspace.deleted` | -| Workflows | `workflow.created`, `workflow.updated`, `workflow.deleted` | -| Workflow steps | `workflow_step.created`, `workflow_step.updated`, `workflow_step.deleted`, `workflow.step_completion_signaled` | -| Comments / messages | `message.added`, `message.updated`, `message.deleted`, `message.queue.status_changed` | -| Task sessions | `task_session.state_changed` | -| Task plans | `task_plan.created`, `task_plan.updated`, `task_plan.deleted`, `task_plan.revision.created`, `task_plan.reverted` | -| Task walkthroughs | `task_walkthrough.created`, `task_walkthrough.updated`, `task_walkthrough.deleted` | -| Session turns | `turn.started`, `turn.completed` | -| Repositories | `repository.created`, `repository.updated`, `repository.deleted`, `repository.script.created`, `repository.script.updated`, `repository.script.deleted` | -| Executors | `executor.created`, `executor.updated`, `executor.deleted`, `executor.profile.created`, `executor.profile.updated`, `executor.profile.deleted`, `executor.prepare.progress`, `executor.prepare.completed` | -| Users | `user.settings.updated` | -| System | `system.job.update` | -| Environments | `environment.created`, `environment.updated`, `environment.deleted` | -| Agent profiles | `agent_profile.created`, `agent_profile.updated`, `agent_profile.deleted` | -| Agents | `agent.started`, `agent.running`, `agent.boot_ready`, `agent.ready`, `agent.completed`, `agent.failed`, `agent.stopped`, `agent.context_reset`, `agent.acp_session_created`, `agentctl.starting`, `agentctl.ready`, `agentctl.error` | -| Agent stream | `agent.stream` (per-session: `agent.stream.`), `agent.turn.message_saved` | -| Agent prompts | `permission_request.received` (per-session: `permission_request.received.`) | -| Clarification | `clarification.answered`, `clarification.primary_answered`, `clarification.cancelled`, `clarification.stale_dismissed` | -| Git / workspace status | `git.event` (per-session), `git.ws` (per-session), `file.change.notified` (per-session) | -| Shell I/O | `shell.output` (per-session), `shell.exit` (per-session) | -| Dev server I/O | `process.output` (per-session), `process.status` (per-session) | -| Session context | `context_window.updated` (per-session), `available_commands.updated` (per-session), `session_mode.changed` (per-session), `agent_capabilities.updated` (per-session), `session_models.updated` (per-session), `session_info.updated` (per-session), `session_todos.updated` (per-session), `session_prompt_usage.updated` (per-session) | -| Automations | `automation.triggered`, `automation.run.created` | -| GitHub | `github.pr_feedback`, `github.pr_state_changed`, `github.new_pr_to_review`, `github.new_issue`, `github.task_pr.updated`, `github.task_ci_options.updated`, `github.watch.event`, `github.rate_limit.updated` | -| GitLab | `gitlab.mr_feedback`, `gitlab.mr_state_changed`, `gitlab.new_mr_to_review`, `gitlab.new_issue`, `gitlab.task_mr.updated`, `gitlab.watch.event` | -| Jira | `jira.new_issue` | -| Linear | `linear.new_issue` | -| Sentry | `sentry.new_issue` | +| Domain | Events | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Tasks | `task.created`, `task.updated`, `task.state_changed`, `task.deleted`, `task.moved`, `task.tree_hold_created`, `task.tree_hold_released` | +| Workspaces | `workspace.created`, `workspace.updated`, `workspace.deleted` | +| Workflows | `workflow.created`, `workflow.updated`, `workflow.deleted` | +| Workflow steps | `workflow_step.created`, `workflow_step.updated`, `workflow_step.deleted`, `workflow.step_completion_signaled` | +| Comments / messages | `message.added`, `message.updated`, `message.deleted`, `message.queue.status_changed` | +| Task sessions | `task_session.state_changed` | +| Task plans | `task_plan.created`, `task_plan.updated`, `task_plan.deleted`, `task_plan.revision.created`, `task_plan.reverted` | +| Task walkthroughs | `task_walkthrough.created`, `task_walkthrough.updated`, `task_walkthrough.deleted` | +| Session turns | `turn.started`, `turn.completed` | +| Repositories | `repository.created`, `repository.updated`, `repository.deleted`, `repository.script.created`, `repository.script.updated`, `repository.script.deleted` | +| Executors | `executor.created`, `executor.updated`, `executor.deleted`, `executor.profile.created`, `executor.profile.updated`, `executor.profile.deleted`, `executor.prepare.progress`, `executor.prepare.completed` | +| Users | `user.settings.updated` | +| System | `system.job.update` | +| Environments | `environment.created`, `environment.updated`, `environment.deleted` | +| Agent profiles | `agent_profile.created`, `agent_profile.updated`, `agent_profile.deleted` | +| Agents | `agent.started`, `agent.running`, `agent.boot_ready`, `agent.ready`, `agent.completed`, `agent.failed`, `agent.stopped`, `agent.context_reset`, `agent.acp_session_created`, `agentctl.starting`, `agentctl.ready`, `agentctl.error` | +| Agent stream | `agent.stream` (per-session: `agent.stream.`), `agent.turn.message_saved` | +| Agent prompts | `permission_request.received` (per-session: `permission_request.received.`) | +| Clarification | `clarification.answered`, `clarification.primary_answered`, `clarification.cancelled`, `clarification.stale_dismissed` | +| Git / workspace status | `git.event` (per-session), `git.ws` (per-session), `file.change.notified` (per-session) | +| Shell I/O | `shell.output` (per-session), `shell.exit` (per-session) | +| Dev server I/O | `process.output` (per-session), `process.status` (per-session) | +| Session context | `context_window.updated` (per-session), `available_commands.updated` (per-session), `session_mode.changed` (per-session), `agent_capabilities.updated` (per-session), `session_models.updated` (per-session), `session_info.updated` (per-session), `session_todos.updated` (per-session), `session_prompt_usage.updated` (per-session) | +| Automations | `automation.triggered`, `automation.run.created` | +| GitHub | `github.pr_feedback`, `github.pr_state_changed`, `github.new_pr_to_review`, `github.new_issue`, `github.task_pr.updated`, `github.task_ci_options.updated`, `github.watch.event`, `github.rate_limit.updated` | +| GitLab | `gitlab.mr_feedback`, `gitlab.mr_state_changed`, `gitlab.new_mr_to_review`, `gitlab.new_issue`, `gitlab.task_mr.updated`, `gitlab.watch.event` | +| Jira | `jira.new_issue` | +| Linear | `linear.new_issue` | +| Sentry | `sentry.new_issue` | | Office (autonomous agents) | `office.agent.created`, `office.agent.updated`, `office.agent.status_changed`, `office.skill.created`, `office.skill.updated`, `office.project.created`, `office.project.updated`, `office.approval.created`, `office.approval.resolved`, `office.comment.created`, `office.cost.recorded`, `office.run.queued`, `office.run.processed`, `office.run.event_appended` (per-run), `office.routine.triggered`, `office.inbox.item`, `office.task.status_changed`, `office.task.updated`, `office.task.decision_recorded`, `office.task.review_requested`, `office.provider.health_changed`, `office.route_attempt.appended`, `office.routing.settings_updated` | -| Cross-plugin | `plugin..` — published by `Host.EmitEvent`; subscribe with `plugin..*` to react to another plugin's events. | +| Cross-plugin | `plugin..` — published by `Host.EmitEvent`; subscribe with `plugin..*` to react to another plugin's events. | > Event list current as of 2026-07-16; regenerate from > `apps/backend/internal/events/types.go` if this page drifts from the code. @@ -272,13 +290,13 @@ alongside the parsed manifest. These are **kandev-owned runtime state, not author-supplied manifest fields** — do not include them in `manifest.yaml`; they have no effect there and are overwritten on install: -| Field | Meaning | -|---|---| -| `status` | Current lifecycle state: `registered`, `active`, `error`, `disabled`, or `uninstalled`. | -| `install_path` | Absolute path the package was extracted to (`~/.kandev/plugins///`). | -| `signed` | Whether the package's `checksums.txt.sig` was cryptographically verified. Signature verification is not currently wired, so this is always false today (every package is reported unsigned). | -| `installed_at` | Install timestamp. | -| `restart_count` | Best-effort restart bookkeeping used by the supervision loop. | +| Field | Meaning | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `status` | Current lifecycle state: `registered`, `active`, `error`, `disabled`, or `uninstalled`. | +| `install_path` | Absolute path the package was extracted to (`~/.kandev/plugins///`). | +| `signed` | Whether the package's `checksums.txt.sig` was cryptographically verified. Signature verification is not currently wired, so this is always false today (every package is reported unsigned). | +| `installed_at` | Install timestamp. | +| `restart_count` | Best-effort restart bookkeeping used by the supervision loop. | Related: [Plugins](plugins.md), [Authoring a plugin](plugins-authoring.md). diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 5aea2a86e7..fc4e09e371 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -46,6 +46,7 @@ Product-wide capabilities that are not tied to a single feature area. | [plugins](plugins/spec.md) | draft | | [plugins — authoring experience](plugins/authoring-experience.md) | draft | | [plugins — marketplace](plugins/marketplace.md) | building | +| [plugins — Voice extraction host prerequisites](plugins/voice-extraction-host.md) | draft | | [semantic-notifications](platform/notifications.md) | shipped | | [workspace-git-status](platform/workspace-git-status.md) | shipped | | [git-subprocess-admission](platform/git-subprocess-admission.md) | building | diff --git a/docs/specs/plugins/voice-extraction-host.md b/docs/specs/plugins/voice-extraction-host.md new file mode 100644 index 0000000000..09d7b7861e --- /dev/null +++ b/docs/specs/plugins/voice-extraction-host.md @@ -0,0 +1,201 @@ +--- +status: draft +created: 2026-08-11 +owner: kandev +--- + +# Voice Plugin Host Prerequisites + +## Why + +Voice Mode can ship independently only if a plugin can participate in every native prompt composer +without bypassing its draft and submission behavior, and can invoke its own billable backend without +exposing that operation as a public webhook. The host must provide those narrow capabilities before +the existing core implementation can move to a dedicated plugin repository. + +Decision: [ADR-2026-08-11-composer-access-authenticated-webhooks](../../decisions/2026-08-11-composer-access-authenticated-webhooks.md). + +## What + +- A plugin action rendered in a supported composer receives typed composer props owned by that + mounted composer. They report whether editing and submission are currently allowed, insert + text at the current selection, focuses the editor, and submits through the same handler as the + native submit control. +- `composer.insertText(text)` trims an empty transcript to a no-op, replaces the selected range (or + inserts at the caret), applies the native Voice Mode word-boundary spacing rule, places the caret + after the insertion, updates the authoritative local draft synchronously, and focuses the input. +- `composer.submit()` returns a typed outcome and never constructs or sends a message itself. The + native path remains responsible for all existing submission behavior and errors. +- Slot props expose live `disabled`, `submittable`, and optional `disabledReason` state. An + operation against an unmounted or superseded composer fails closed with `unavailable`; submit + against a currently blocked form returns `blocked` without discarding the draft. +- Composer action slots exist in desktop and native-mobile task chat, Quick Chat, task creation, and + new-session creation. Each slot reports its surface and presentation plus only the identifiers + meaningful on that surface. +- The existing `chat-input-actions` registration remains source-compatible. Its slot props gain the + composer capability and surface metadata; new `task-create-input-actions` and + `new-session-input-actions` slots cover creation forms. Quick Chat uses `chat-input-actions` with + `surface: "quick-chat"` and `taskId: null` when it is genuinely task-less. +- A webhook declaration chooses `access: public` or `access: authenticated`. Existing declarations + default to `public`; authenticated webhooks require the current Kandev user and same-origin browser + request checks before the existing plugin webhook RPC runs. +- A webhook declaration may lower its request cap through `max_body_bytes`. Only authenticated + webhooks may raise it above the existing 4 MiB public ceiling, up to a host ceiling of 16 MiB. + Voice transcription declares an + authenticated webhook with a limit of at least 10 MiB. +- The Voice plugin's OpenAI key belongs to its existing plugin settings and secret storage. Kandev + relays the request but does not own, expose, or select that key. +- Plugin localization is a separately scoped prerequisite. This package adds no host i18n API. The + later Voice plugin task must decide how plugin-owned catalogs follow Kandev's locale before core + Voice Mode is removed. +- Disabling, uninstalling, or reloading a plugin aborts its in-flight webhook requests, unregisters + its composer actions, and leaves native drafts intact. +- The current core Voice Mode stays present until a separately delivered Voice plugin proves parity + on all listed surfaces. Removing core Voice Mode is not part of these host prerequisites. + +## API Surface + +### Composer slots + +```ts +type PluginComposerSurface = + | "task-chat" + | "quick-chat" + | "task-create" + | "new-session"; +type PluginPresentation = "desktop" | "mobile"; + +type PluginComposerSubmitResult = + | { status: "submitted" } + | { status: "blocked"; reason?: string } + | { status: "unavailable" }; + +interface PluginComposerCapability { + insertText(text: string): { status: "inserted" | "ignored" | "unavailable" }; + focus(): { status: "focused" | "unavailable" }; + submit(): Promise; +} + +interface PluginComposerSlotProps { + surface: PluginComposerSurface; + presentation: PluginPresentation; + taskId: string | null; + taskTitle?: string; + activeSessionId: string | null; + sessionIds: string[]; + disabled: boolean; + submittable: boolean; + disabledReason?: string; + composer: PluginComposerCapability; +} +``` + +The host re-renders slot props when native state changes. `submit()` always revalidates native state +at call time. The host may replace the capability object when a surface is remounted; methods on the +old object return `unavailable`. + +### Authenticated webhooks + +Manifest: + +```yaml +webhooks: + - key: transcribe + access: authenticated + max_body_bytes: 16777216 +``` + +Frontend: + +```ts +host.api.fetch("webhooks/transcribe", { + method: "POST", + body: formData, + signal, +}); +``` + +HTTP and runtime wire contract: + +```text +POST /api/plugins/{pluginId}/webhooks/{key} +Origin: + +rpc HandleWebhook(WebhookRequest) returns (WebhookResponse) +``` + +`access` is enforced by the host before `HandleWebhook` runs. For `authenticated`, the normal Kandev +identity must be present and browser cookie authentication additionally requires an accepted Origin; +authentication-disabled installations use the synthetic default user. Public webhooks retain their +current external-call behavior. Request cancellation propagates through the existing RPC context. + +## Permissions + +- Composer capabilities exist only as props on a currently rendered slot; they are not placed in + `host.store`, exported globally, or addressable by task/session ID. +- Webhook access changes only who may invoke that declared key. It grants no Host data, state, secret, + or event capability. +- Plugin backends receive the existing webhook request shape. They never receive the Kandev session + cookie or PAT. The Voice plugin reads its OpenAI secret through existing plugin configuration. + +## Failure Modes + +- Empty or whitespace-only insertion is ignored. Unsupported or stale composers return + `unavailable`; they do not mutate another surface's draft. +- A blocked native submit returns `blocked`, preserves the native draft, and leaves native error + presentation authoritative. A native submission failure resolves `blocked` with its safe reason + where available and preserves the draft exactly as a native failure does. +- An unavailable plugin runtime returns `503`; cancellation stops the runtime request and returns no + synthetic success; an oversized body returns `413`; malformed multipart or metadata returns `400`; + missing authentication returns `401`; origin or declaration failure returns + `403` or `404` according to the existing hide-existence policy. +- Host logs may contain plugin ID, action, request ID, byte count, duration, status, and cancellation, + but never audio bytes, authorization material, cookies, or plugin secrets. + +## Compatibility And Versioning + +- These additions are backward-compatible within plugin `api_version: 1`; all new manifest and host + fields are optional, and existing slot components that ignore added props continue to work. +- A plugin using `webhooks[].access` or `max_body_bytes` must set `min_kandev_version` to the first + release containing this contract. Older hosts reject installation instead of silently treating an + authenticated webhook as public. +- Manifest types, validation, authoring docs, and handler behavior change together. The protobuf and + Go plugin SDK webhook contract remain unchanged. + +## Scenarios + +- **GIVEN** a task-chat selection, **WHEN** a plugin inserts a transcript and invokes submit, **THEN** + the selection is replaced and the existing native submit handler runs. +- **GIVEN** a running agent configured for steering or queueing, **WHEN** a plugin auto-submits, + **THEN** the same steering or queueing decision and error handling as the native submit button runs. +- **GIVEN** task creation or new-session creation on desktop or Pixel 5, **WHEN** a plugin inserts at + the textarea selection and submits, **THEN** the native form validation and creation handler run. +- **GIVEN** a plugin action is recording on one session, **WHEN** the task, session, or composer + changes, **THEN** the plugin cancels its request and the stale capability cannot modify the new + composer. +- **GIVEN** Quick Chat, **WHEN** a composer plugin action is rendered, **THEN** it receives the shared + capability with `surface: "quick-chat"` and completes the same insertion and submit behavior. +- **GIVEN** a plugin declares an authenticated `transcribe` webhook with a 16 MiB cap, **WHEN** its UI + sends 10 MiB of multipart audio through `host.api.fetch`, **THEN** the existing webhook RPC reaches + that plugin backend and returns its response. +- **GIVEN** an unauthenticated caller, **WHEN** it calls the authenticated transcription webhook, + **THEN** the request fails before plugin execution and cannot consume the plugin's configured key. +- **GIVEN** an in-flight transcription, **WHEN** the caller aborts or the plugin is disabled, **THEN** + the HTTP and runtime requests are cancelled and no transcript is inserted. +- **GIVEN** a plugin built for this contract, **WHEN** it is installed on an older Kandev release, + **THEN** `min_kandev_version` rejects installation with no partial registration. + +## Out Of Scope + +- Implementing, packaging, publishing, or removing the dedicated Voice plugin. +- Removing core Voice Mode before plugin parity and migration are separately validated. +- Exposing draft text, selection offsets, or arbitrary native composer mutation through + `host.store` or a global host API. +- General authenticated REST routing, response streaming, bidirectional browser sockets, or changing + the default limit for existing public webhooks. +- Sandboxing mutually malicious native UI plugin bundles. +- Plugin localization; it is a separate prerequisite for the later Voice plugin extraction. + +## Implementation Plan + +See [the implementation plan](../../plans/plugin-voice-extraction-host/plan.md).