Files
kandev/docs/plans/plugins/GRPC-CONTRACT.md
T
João Salavisa fae2303db3 feat(plugins): per-plugin settings pages driven by manifest config_schema (#1761)
* feat(plugins): per-plugin settings pages driven by manifest config_schema

Settings > Plugins rows now open /settings/plugins/:id — a detail page
showing the manifest plus a settings form generated from the plugin
author's config_schema. Secret fields (secret: true / format: password)
are masked on the operator API and kept on masked re-submits; a running
plugin restarts on save and reads its config (cleartext secrets included)
through the new Host GetConfig gRPC RPC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): write plugin config file owner-only (0600)

Operator config can now carry cleartext secret values (config_schema
fields marked secret), so <id>.config.yml must not be world-readable —
match the 0600 mode the record file already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugins): vault-backed plugin secrets — Get/Set/DeleteSecret RPCs and encrypted config secrets

Plugins get vault-backed secret primitives on the Host API
(GetSecret/SetSecret/DeleteSecret, capability-gated, namespaced to
plugin:<id>㊙️<key> so a plugin can only touch its own entries;
purged on uninstall). Secret config_schema fields now live in kandev's
AES-256-GCM vault too — <id>.config.yml persists only a vault reference,
and the Host GetConfig RPC resolves it back to cleartext for the plugin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(plugins): serve PR screenshots from the media/pr-1761-screenshots ref instead of the repo

The PR body now points at raw URLs pinned to an unmerged media ref, so
the binaries never land in main (PRs are squash-merged; only the branch
tip tree merges).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address review feedback on settings validation and save flow

- integer form fields reject non-integral input instead of parseInt-truncating
- enum selections keep their schema-declared types (numeric enums round-trip)
- backend enum validation normalizes int-vs-float64 before comparison
- maskSecrets masks any non-zero secret value regardless of runtime type
- save flow splits PATCH from refetch: a refetch failure no longer reports a
  successful save as an error, and typed secrets are masked on screen
- optional enum fields gain a 'Not set' option so they can be cleared
- resolveConfigSecrets copies before mutating; restart-failure SetStatus
  errors are logged; SDK test fake returns a non-nil empty config map
- add missing tests: usePluginConfigForm hook, getPluginConfig client,
  integer/enum regression cases

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): harden secret config invariants from review

- reject non-string values on declared secret fields at validation time,
  closing the path where a numeric/boolean secret bypassed the vault and
  persisted cleartext in the config file
- delete removed secret fields' vault entries only AFTER the config commit
  succeeds, so a failed SetConfig never leaves the still-current config
  referencing a deleted vault entry
- uninstall fails closed: the plugin's vault namespace is purged before any
  destructive step, and a cleanup failure aborts the uninstall (retryable)
  instead of letting a reinstall inherit stale secrets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): failure-atomic config secrets, quiesced uninstall purge, ctx propagation

- UpdateConfig is now failure-atomic for secret overwrites: storeConfigSecrets
  snapshots each vault entry before writing and returns a rollback that
  restores prior values (or deletes newly-created ones). A vault.Set failure
  mid-loop or a failed SetConfig commit rolls back, so a request reported as
  failed never changes the value a still-current config ref resolves to.
- Uninstall stops the plugin process BEFORE purging its vault namespace, so
  the plugin can no longer race the cleanup with a SetSecret between ListIDs
  and the deletes; the purge stays fail-closed and retryable.
- Thread the request context through Uninstall -> deletePluginSecrets instead
  of context.Background(), per the pass-context-through-chains convention.
- Consolidate the 'secret not found:' prefix check into isSecretNotFound
  (host.go + service rollback share one definition).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): detach rollback context, fail closed without a vault, reconcile uninstall status

- vault rollback closures now run on context.WithoutCancel(ctx): a request
  cancelled mid-save (browser closed) can no longer abort the rollback and
  leave the vault holding new values while the unchanged config still points
  at them (Greptile P1).
- storeConfigSecrets fails closed (errSecretVaultRequired) when a plugin
  declares secret config fields but no vault is wired, instead of silently
  persisting the secret in cleartext.
- vaultRestoreFunc distinguishes not-found from a genuine Reveal error: a
  backend read failure aborts the update before writing rather than risk a
  rollback that deletes a real secret; rollback now returns errors, joined
  into UpdateConfig's failure so an inconsistent vault is surfaced, not just
  logged.
- Uninstall reconciles lifecycle status when a secret-purge failure aborts
  after the process was stopped: a previously-running plugin transitions to
  error and notifies observers instead of being reported active with no
  process running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): detach removed-secret cleanup ctx and clear stale save dirty flag

- cleanupRemovedConfigSecrets now runs on context.WithoutCancel(ctx), matching
  the rollback path, so a client disconnect right after the config commit can
  no longer cancel the delete and orphan now-unreferenced vault entries.
- tests: assert GetConfig succeeds before treating an empty map as proof that
  nothing persisted (a swallowed read error would be a false pass).
- web: on a post-save refetch failure, rebase initialValues onto the masked
  snapshot so the saved form no longer reads as dirty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): fail tests on discarded GetConfig errors, drop setState-in-updater

- config_test: capture and fatal on GetConfig errors at the four remaining
  positive-assertion sites (a nil from a failed read would panic on the next
  map access instead of giving a clear failure), matching the earlier fix.
- web hook: compute the masked post-save snapshot from the current values and
  apply it to setValues + setInitialValues directly, instead of calling
  setInitialValues inside a setValues functional updater (a React anti-pattern
  that runs twice under StrictMode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): skip validation of a secret field's own vault ref

- validateConfigSchema takes the plugin id and skips a secret field whose
  value is that field's config vault ref (an internal storage marker,
  already validated when first set). Without this, a field declaring both
  secret: true and enum: [...] 400s on every save that keeps the mask,
  because the enum check saw the vault ref instead of a user value. Freshly
  entered secret values are still validated; a foreign plugin's ref is not
  skipped. Covered by unit + service-level regression tests.
- surface mid-loop vault rollback failures in storeConfigSecrets via a
  warning instead of discarding them (double-fault observability).
- web: integer inputs get step=1 so browsers flag non-integral input in
  place (serializeConfigValues remains the backstop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(plugins): split config secret tests into config_secrets_test.go

config_test.go had grown past the 800-line backend file-length limit
(revive), failing CI lint. Move the vault-backed config and plugin-scoped
secret tests (plus their fake-vault/store helpers) into a sibling file;
no test logic changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:13:58 +01:00

421 lines
20 KiB
Markdown

# kandev.plugin.v1 — gRPC plugin contract (FROZEN)
Supersedes the HTTP+HMAC transport. Every task builds against this file; do not
diverge without updating it. The frontend contract (PLUGIN-API.md) is unchanged
except where noted in §7.
## 1. Architecture
- Plugin **backends are Go binaries** distributed in a release tarball and
**spawned by kandev as subprocesses** via `hashicorp/go-plugin`.
- Transport: gRPC over a unix domain socket (macOS/Linux) or loopback TCP +
AutoMTLS (Windows) — negotiated by go-plugin, invisible to authors.
- Auth: the spawn relationship + go-plugin handshake + AutoMTLS. **No api_key,
no webhook_secret, no HMAC** — all credential machinery is removed for managed
plugins. The remote/self-hosted tier (`base_url` registration) is REMOVED
(future work if ever needed).
- The `LISTENING <addr>` stdout handshake is replaced by go-plugin's handshake.
## 2. go-plugin handshake
```go
var Handshake = plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "KANDEV_PLUGIN",
MagicCookieValue: "kandev-plugin-v1",
}
// plugin map key: "plugin"; AutoMTLS: enabled on the client (kandev) side.
```
Env kandev injects into the subprocess:
- `KANDEV_PLUGIN_DATA_DIR` — per-plugin writable dir (`~/.kandev/plugins/<id>/data`).
## 3. Proto (`apps/backend/proto/kandev/plugin/v1/plugin.proto`)
```proto
syntax = "proto3";
package kandev.plugin.v1;
import "google/protobuf/struct.proto";
// Implemented by the PLUGIN. kandev is the client.
service Plugin {
rpc DeliverEvent(Event) returns (EventAck);
rpc InvokeTool(ToolRequest) returns (ToolResponse);
rpc HandleWebhook(WebhookRequest) returns (WebhookResponse);
}
// Implemented by KANDEV (served back over the go-plugin broker).
// Every RPC is capability-gated server-side (§5).
service Host {
rpc GetState(GetStateRequest) returns (GetStateResponse);
rpc SetState(SetStateRequest) returns (SetStateResponse);
rpc DeleteState(DeleteStateRequest) returns (DeleteStateResponse);
rpc ListState(ListStateRequest) returns (ListStateResponse);
rpc RevealSecret(RevealSecretRequest) returns (RevealSecretResponse);
rpc EmitEvent(EmitEventRequest) returns (EmitEventResponse);
// The plugin's own operator-editable config (Settings > Plugins > <plugin>,
// driven by the manifest's config_schema). Ungated; secret values arrive
// in cleartext — this RPC is how an operator-configured credential (e.g. a
// PAT) reaches the plugin. At rest, secret config fields live in kandev's
// encrypted vault (the config file holds only a vault reference); the Host
// resolves them before responding. kandev restarts a running plugin on
// config change, so reading config at startup is sufficient.
rpc GetConfig(GetConfigRequest) returns (GetConfigResponse);
// Plugin-scoped secret primitives — capability `secrets`. Keys are
// namespaced server-side to the calling plugin (vault id
// "plugin:<id>:secret:<key>", key must match
// [a-zA-Z0-9][a-zA-Z0-9._-]{0,127}), so a plugin can only ever touch its
// OWN entries; RevealSecret remains the way to resolve an
// operator-provided reference to a shared/global secret. Values live in
// kandev's encrypted vault (AES-256-GCM at rest) and the whole
// "plugin:<id>:" namespace is deleted on uninstall.
rpc GetSecret(GetSecretRequest) returns (GetSecretResponse);
rpc SetSecret(SetSecretRequest) returns (SetSecretResponse);
rpc DeleteSecret(DeleteSecretRequest) returns (DeleteSecretResponse);
// Host data API (ADR 0043, §3a below) — reads, capability api_read:<resource>.
rpc ListTasks(ListTasksRequest) returns (ListTasksResponse);
rpc GetTask(GetTaskRequest) returns (Task);
rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse);
rpc ListWorkflows(ListWorkflowsRequest) returns (ListWorkflowsResponse);
rpc ListWorkflowSteps(ListWorkflowStepsRequest) returns (ListWorkflowStepsResponse);
rpc ListAgentProfiles(ListAgentProfilesRequest) returns (ListAgentProfilesResponse);
rpc ListRepositories(ListRepositoriesRequest) returns (ListRepositoriesResponse);
rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse);
rpc ListSessionCodeStats(ListSessionCodeStatsRequest) returns (ListSessionCodeStatsResponse);
// Host data API — writes, capability api_write:<resource>. Declared in the
// proto for a stable contract; handlers are NOT wired yet (§3a "Deferred
// writes") — calling any of these three today returns gRPC Unimplemented
// regardless of capabilities.
rpc CreateTask(CreateTaskRequest) returns (Task);
rpc UpdateTask(UpdateTaskRequest) returns (Task);
rpc CreateComment(CreateCommentRequest) returns (Comment);
}
message Event {
string event_id = 1; // fresh uuid per delivery
string event_type = 2; // bus subject, e.g. "task.created"
string occurred_at = 3; // RFC3339 UTC
string workspace_id = 4; // empty if not derivable
google.protobuf.Struct payload = 5; // marshaled bus event.Data
}
message EventAck {}
message ToolRequest {
string tool_call_id = 1;
string tool_name = 2;
google.protobuf.Struct input = 3;
ToolContext context = 4;
}
message ToolContext { string task_id = 1; string agent_instance_id = 2; string session_id = 3; }
message ToolResponse { google.protobuf.Struct output = 1; string error = 2; }
message WebhookRequest {
string webhook_key = 1;
string method = 2;
string path = 3; // remainder after the key
string query = 4;
map<string, string> headers = 5; // single-valued; multi joined by ", "
bytes body = 6;
}
message WebhookResponse { int32 status = 1; map<string, string> headers = 2; bytes body = 3; }
message GetStateRequest { string scope = 1; string scope_id = 2; string key = 3; }
message GetStateResponse { bool found = 1; google.protobuf.Struct value = 2; }
message SetStateRequest { string scope = 1; string scope_id = 2; string key = 3; google.protobuf.Struct value = 4; }
message SetStateResponse {}
message DeleteStateRequest { string scope = 1; string scope_id = 2; string key = 3; }
message DeleteStateResponse {}
message ListStateRequest { string scope = 1; string scope_id = 2; }
message ListStateResponse { repeated StateEntry entries = 1; }
message StateEntry { string key = 1; google.protobuf.Struct value = 2; string updated_at = 3; }
message GetConfigRequest {}
message GetConfigResponse { google.protobuf.Struct config = 1; }
message GetSecretRequest { string key = 1; }
message GetSecretResponse { bool found = 1; string value = 2; }
message SetSecretRequest { string key = 1; string value = 2; }
message SetSecretResponse {}
message DeleteSecretRequest { string key = 1; }
message DeleteSecretResponse {}
message RevealSecretRequest { string ref = 1; }
message RevealSecretResponse { string value = 1; }
message EmitEventRequest { string event_name = 1; google.protobuf.Struct payload = 2; }
message EmitEventResponse {}
```
Notes: scope ∈ instance|workspace|task|agent (empty scope_id for instance —
matches the state store). The plugin never passes its own id; the Host service
instance is bound to the plugin's record at spawn time.
### 3a. Host data API (ADR 0043)
Read/write RPCs let plugins read (and, later, write) kandev's own domain data —
tasks, sessions, workspaces, workflows, agent profiles, repositories, comments —
over the same Host gRPC channel used for state/secrets, instead of opening the
kandev database file directly. Full message definitions (`Page`, `PageInfo`,
`Task`, `TaskFilter`, `Workspace`, `Workflow`, `WorkflowStep`, `AgentProfile`,
`Repository`, `Session`, `SessionFilter`, `SessionCodeStats`, and the deferred
`CreateTaskRequest`/`UpdateTaskRequest`/`Comment`/`CreateCommentRequest`) live in
the real proto — `apps/backend/proto/kandev/plugin/v1/plugin.proto` — and are not
duplicated here; this section covers the RPC list (added to `service Host` above),
capability gating, and cross-cutting conventions. See ADR 0043
(`docs/decisions/0043-plugin-host-data-api.md`) for the design rationale.
**Readable resources.** Each read RPC requires `api_read:<resource>` in the
plugin's manifest:
| RPC | Capability | Resource |
|---|---|---|
| `ListTasks` / `GetTask` | `api_read:tasks` | tasks |
| `ListWorkspaces` | `api_read:workspaces` | workspaces |
| `ListWorkflows` | `api_read:workflows` | workflows |
| `ListWorkflowSteps` | `api_read:workflows` | workflows |
| `ListAgentProfiles` | `api_read:agent_profiles` | agent_profiles |
| `ListRepositories` | `api_read:repositories` | repositories |
| `ListSessions` | `api_read:sessions` | sessions |
| `ListSessionCodeStats` | `api_read:sessions` | sessions |
An undeclared capability returns gRPC `PermissionDenied` with message
`capability 'api_read:tasks' not declared` (substituting the actual resource) —
identical in shape to the existing state/secrets gating. Declaring the resource
grants every RPC listed against it; there is no finer-grained gate within a
resource (e.g. `api_read:workflows` covers both `ListWorkflows` and
`ListWorkflowSteps`).
**Deferred writes.** `CreateTask`, `UpdateTask`, and `CreateComment` are declared
on `service Host` and in the proto (frozen shape for `api_write:tasks` /
`api_write:comments`) but have no server-side handler yet — calling any of them
returns gRPC `Unimplemented` today regardless of what `api_write` declares. When
implemented, writes will route through the task service's `CreateTask`/
`UpdateTask`/comment-create methods (never a repository) so the standard
`task.*` events fire, and the server will stamp `source = "plugin:<id>"` on the
created row — a plugin cannot set it itself.
**Reads go through the service layer, never a repository.** Each read handler
calls the relevant internal service (task service, workflow service, the
analytics service for `ListSessionCodeStats`) so derived fields and future
access rules stay centralized, exactly as writes will route through
event-publishing service methods once implemented.
**DTOs are a hand-mapped, versioned contract — never internal structs.** The
backend maps internal models to the proto messages above with explicit
conversion code; it never marshals domain structs through
`google.protobuf.Struct` and never generates the messages from models. Fields
are additive-only after merge — removing or renaming one is a breaking change
requiring a new `api_version`. `SessionCodeStats` in particular is a
deliberately **computed** shape (`lines_added_committed` /
`lines_deleted_committed` from commit sums, `lines_added_peak_pending` /
`lines_deleted_peak_pending` from the peak uncommitted diff across snapshots),
computed on demand per request — plugins never see the raw
`task_session_commits` / `task_session_git_snapshots` rows those numbers are
derived from.
**Conventions.**
- **Pagination:** opaque-cursor. A request carries `Page{limit, cursor}` (0 limit
→ server default, currently 50, capped at 200); a list response carries
`PageInfo{next_cursor, has_more}`. An empty cursor is the first page; echo
`next_cursor` to continue. Plugins MUST NOT interpret cursor contents — the
current server encodes it as a decimal offset, but that is an implementation
detail, not part of the contract.
- **Timestamps:** RFC3339 strings (`created_at`, `updated_at`, `started_at`,
`occurred_at`, ...), matching the `Event` envelope and the JSON API — one time
representation across the whole plugin contract, never protobuf `Timestamp`.
- **Nullables:** optional string fields use proto3 `optional` (e.g.
`Task.started_at`, `Task.completed_at`, `Task.parent_id`,
`Session.ended_at`), so an absent (NULL) value is distinguishable from an
empty string.
- **Scoping (v1):** reads are global to the kandev instance — plugins are
installed instance-wide, not per-workspace. Filters (`workspace_ids`,
`task_ids`, `states` on `TaskFilter`/`SessionFilter`) narrow results but do
not themselves confer or restrict visibility; a server-side scoping hook is
reserved for a future per-plugin/per-user restriction without a contract
change (ADR 0043, open decision (a)).
- **Ephemeral tasks** (quick-chat) are excluded from `ListTasks` unless the
request sets `TaskFilter.include_ephemeral`.
## 4. SDK (`apps/backend/pkg/pluginsdk`)
Public Go module surface (authors import only this):
```go
type Plugin interface {
OnEvent(ctx context.Context, e *Event) error // return err → kandev retries
InvokeTool(ctx context.Context, req *ToolRequest) (*ToolResponse, error)
HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error)
}
type Host interface { // injected before Serve returns
GetState/SetState/DeleteState/ListState(...)
GetConfig(ctx) (map[string]any, error) // own operator config, cleartext
RevealSecret(ctx, ref string) (string, error) // operator-provided shared-secret ref
GetSecret(ctx, key) (value string, found bool, err error) // plugin-owned, vault-backed
SetSecret(ctx, key, value string) error
DeleteSecret(ctx, key string) error
EmitEvent(ctx, name string, payload map[string]any) error
// Host data API (ADR 0043, §3a) — each accessor is capability-gated by
// the corresponding api_read:<resource>; see "Host data API accessors"
// below for the reader interfaces and Go-native DTOs.
Tasks() TaskReader
Sessions() SessionReader
Workspaces() WorkspaceReader
Workflows() WorkflowReader
AgentProfiles() AgentProfileReader
Repositories() RepositoryReader
}
func Serve(p Plugin, opts ...Option) // blocks; wires go-plugin server + broker
// Optional embeddable no-op base: sdk.UnimplementedPlugin
// Optional embeddable no-op base for Host data accessors (every method
// PermissionDenied/Unimplemented): sdk.UnimplementedHostData — used on the
// kandev side, not by plugin authors.
```
SDK types mirror proto but use `map[string]any` for Struct fields. The SDK owns
all go-plugin/grpc plumbing (handshake, broker for Host, conversions).
### Host data API accessors (`apps/backend/pkg/pluginsdk/host.go`, `data_types.go`)
Each `Host.<Resource>()` call above returns a small reader interface. All
methods take `context.Context`; list methods take a Go-native `Page{Limit
int32; Cursor string}` and return `(items []T, *PageInfo, error)` where
`PageInfo{NextCursor string; HasMore bool}` — the Go-native mirror of the wire
`Page`/`PageInfo` messages (§3a). A resource whose capability isn't declared
still returns a non-nil reader; every method on it returns a gRPC
`PermissionDenied` error instead of a zero value.
```go
type TaskReader interface {
List(ctx context.Context, filter TaskFilter, page Page) ([]Task, *PageInfo, error)
Get(ctx context.Context, id string) (*Task, error)
}
type SessionReader interface {
List(ctx context.Context, filter SessionFilter, page Page) ([]Session, *PageInfo, error)
CodeStats(ctx context.Context, filter SessionFilter, page Page) ([]SessionCodeStats, *PageInfo, error)
}
type WorkspaceReader interface {
List(ctx context.Context, page Page) ([]Workspace, *PageInfo, error)
}
type WorkflowReader interface {
List(ctx context.Context, workspaceID string, page Page) ([]Workflow, *PageInfo, error)
ListSteps(ctx context.Context, workflowID string) ([]WorkflowStep, error)
}
type AgentProfileReader interface {
List(ctx context.Context, page Page) ([]AgentProfile, *PageInfo, error)
}
type RepositoryReader interface {
List(ctx context.Context, workspaceID string, page Page) ([]Repository, *PageInfo, error)
}
```
`Task`, `Session`, `SessionCodeStats`, `Workspace`, `Workflow`, `WorkflowStep`,
`AgentProfile`, `Repository`, `TaskFilter`, `SessionFilter` are Go-native
structs in `pluginsdk` (field-for-field mirrors of the proto messages, PascalCase
Go names for the proto's snake_case fields, `*string` for `optional string`) —
authors never see the generated `pluginv1.*` types.
**Authoring example** — a plugin declaring `api_read: ["sessions"]` and reading
computed per-session code stats instead of opening the kandev database:
```yaml
# manifest.yaml
capabilities:
api_read: ["sessions"]
```
```go
func (p *statsPlugin) OnEvent(ctx context.Context, e *pluginsdk.Event) error {
stats, pageInfo, err := p.host.Sessions().CodeStats(ctx, pluginsdk.SessionFilter{
WorkspaceIDs: []string{e.WorkspaceID},
}, pluginsdk.Page{Limit: 100})
if err != nil {
return err // e.g. gRPC PermissionDenied if api_read:sessions isn't declared
}
for _, s := range stats {
log.Printf("session %s: +%d/-%d committed, +%d/-%d peak pending",
s.SessionID, s.LinesAddedCommitted, s.LinesDeletedCommitted,
s.LinesAddedPeakPending, s.LinesDeletedPeakPending)
}
_ = pageInfo.HasMore // paginate via pageInfo.NextCursor when true
return nil
}
```
`kandev-plugin-agent-stats` is the plugin ADR 0043 was written for: it
originally opened `~/.kandev/data/kandev.db` read-only and hand-aggregated
`task_session_commits`/`task_session_git_snapshots` to get exactly the numbers
`Sessions().CodeStats(...)` now returns as a stable, computed DTO — read via
the API, never the DB.
## 5. Delivery / tools / webhooks semantics (unchanged from HTTP era)
- **DeliverEvent**: unary. Per-plugin sequential queue, 10s timeout, 3 retries
(5s/15s/45s, injectable), ring buffer 100/5min while plugin unhealthy, flush
in order on recovery. Non-nil error or timeout counts as failure.
- **InvokeTool**: 30s timeout.
- **HandleWebhook**: kandev's HTTP endpoint `POST /api/plugins/{id}/webhooks/{key}`
converts the HTTP request to WebhookRequest and relays the WebhookResponse.
- **Health**: go-plugin client `Ping()` every 30s (injectable), 3 consecutive
failures → status `error` (+ restart attempt with backoff), recovery → `active`
+ delivery flush. Crash (process exit) → immediate restart with backoff
(max 5 attempts, then `error`).
- **Capability gating**: each Host RPC checks the plugin's manifest capabilities
before doing any work — `state` for `GetState`/`SetState`/`DeleteState`/
`ListState`, `secrets` for `RevealSecret`, `api_read:<resource>` for each Host
data API read RPC (§3a) — and returns PermissionDenied with
`capability '<name>' not declared` on a miss. `EmitEvent` is ungated.
`api_write:<resource>` is accepted in the manifest but the write RPCs
(`CreateTask`/`UpdateTask`/`CreateComment`) aren't implemented yet, so they
return gRPC `Unimplemented` regardless of capability.
## 6. Package format (`<id>-<version>.tar.gz`)
```
manifest.yaml # authoritative; read BEFORE any code runs
server/plugin-<goos>-<goarch>[.exe] # any subset; host platform key required at install
ui/bundle.js # optional (frontend half)
ui/*.css / assets/icon.svg # optional
checksums.txt # "sha256 path" for every other file
checksums.txt.sig # OPTIONAL ed25519 signature (unsigned → warn)
```
Manifest additions (replaces base_url; endpoints block is REMOVED):
```yaml
runtime:
type: binary
executables:
linux-amd64: server/plugin-linux-amd64
darwin-arm64: server/plugin-darwin-arm64
# ... any subset
min_kandev_version: "0.78.0" # optional
```
Install pipeline: `POST /api/plugins/install` with JSON `{"url": "..."}` OR
multipart field `package` → verify checksums.txt covers all files & hashes match
→ parse+validate manifest (host platform key present; id pattern; capabilities)
→ extract to `~/.kandev/plugins/<id>/<version>/` → write record → status
`registered` → spawn → handshake OK → `active`. Record keeps `version` and
`install_path`. Uninstall stops the process and removes record + versions + data
(24h grace not required for v1). `POST /api/plugins/register` is REMOVED.
## 7. Frontend deltas (PLUGIN-API.md otherwise unchanged)
- `GET /api/plugins/{id}/bundle` and `/api/plugins/{id}/ui/*` are served by
kandev **from the extracted package dir** (no reverse proxy, no upstream).
- Management page: "Register plugin" (manifest paste) is replaced by "Install
plugin" (URL input + file upload). No credentials are ever displayed.
- Boot payload `plugins: [{id,name,bundleUrl,styleUrls}]` unchanged.
```