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

20 KiB

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

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)

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):

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.

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:

# manifest.yaml
capabilities:
  api_read: ["sessions"]
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):

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.