279e15b318
* feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/CreateComment) The api_write manifest capability was a stored no-op. Wire the deferred phase-2 write RPCs (ADR 0043) so closed-loop plugins (Sentry->task, Slack slash-command task creation, refactor fan-out) can create and update tasks and post comments. - SDK: Host.Tasks() gains Create/Update; new Host.Comments() writer; CreateTaskInput/UpdateTaskInput/Comment DTOs (pkg/pluginsdk). - Gating: api_write:tasks gates Create/UpdateTask, api_write:comments gates CreateComment; undeclared -> gRPC PermissionDenied, per-method and independent of the read caps on the same resource. - Writes route through the first-party service layer (task service for tasks, office service for comments) via backendapp adapters, never a repository -- so task.*/comment-created events fire and WS updates. Server stamps source="plugin:<id>"; CreateTask resolves workspace/ workflow defaults and best-effort honors start_agent; UpdateTask uses a conservative title/description/state/workflow_step_id mask. - Fixture plugins exercise a CreateTask+CreateComment round-trip; in-process, real-broker wire, and subprocess tests cover the round-trip and per-capability denial. - Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments moved writes deferred -> implemented; ADR 0043 amended with the concrete service-layer routing decision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(plugins): send a message to a task session via Host SendMessage (replaces CreateComment) The office TaskComment write was the wrong primitive — it's an office/ dashboard construct, not a way to message the agent working a task, and it only did anything under office reactivity. Replace the CreateComment write RPC with SendMessage, which delivers a prompt to a task session through the orchestrator's real delivery path (the same one message_task uses). - Proto: swap CreateComment/Comment for SendMessage(SendMessageRequest) returns SendMessageResponse{session_id, status}; regenerated stubs. - SDK: Host.Comments() removed; Host.Messages().Send(taskID, sessionID, text) added (pairs with Messages().List), returning MessageDispatch. - Gating: api_write:comments dropped; SendMessage gated api_write:messages (independent of api_read:messages on the same accessor, like Tasks()). - Delivery: a new pluginsTaskMessengerAdapter (backendapp) resolves the target session (explicit id verified against the task, or the primary session), records a user message stamped source="plugin:<id>", then dispatches by state — running → queue; idle/completed → prompt (resume if the agent process is gone); never-started → launch; failed dispatch deletes the recorded message. Wired via SetWriteDeps (live-read). - Tests: adapter state-machine unit tests (queue/start/prompt/resume/ delete-on-failure/belongs-to-task/no-primary/terminal); SDK, in-process gating, wire, and subprocess round-trips updated to SendMessage. - Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments, and the ADR 0043 amendment updated from comment-create to message-send. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): address PR review — nil guards, detached contexts, state validation Follow-up fixes from the #1970 review (Claude + CodeRabbit bots): - grpcHostServer.CreateTask/UpdateTask/SendMessage: nil-guard the returned value before toProto() so a custom/test Host returning (nil, nil) yields a gRPC Internal error, not a server panic (matches GetTask's pattern). - host.go: fix stale server doc comment (Comments()/CreateComment → Messages()/SendMessage). - startTaskBestEffort and the post-resume wait+retry now run on context.WithoutCancel(ctx): the task/resume is already committed, so a plugin's incoming deadline must not silently abort the launch/resume. - pluginsTaskWriterAdapter.UpdateTask validates the state string against the known task-state enum (excluding orchestrator-owned SCHEDULING) and returns InvalidArgument, so a plugin typo can't forward a bogus state to the service. - Tests: add the resume-wait-timeout path (message deleted so a retry can't duplicate), and a dedicated pluginsTaskWriterAdapter test covering source→metadata mapping and state validation (both adapters now depend on narrow interfaces for testability). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): launch start_agent asynchronously so CreateTask doesn't block Review nit follow-up: startTaskBestEffort called StartTask synchronously, so with the detached context the plugin's CreateTask RPC blocked on the orchestrator's full inline launch path (office lookup, state update, workflow step move, task fetch) with no bound — and the "asynchronous" comment was wrong. Move the fire-and-forget into pluginsTaskStarterAdapter (backendapp, no goleak), mirroring MCP's launchAutoStartTask: the launch runs in a goroutine on a context.WithTimeout(context.WithoutCancel(ctx), AgentLaunchTimeout), and StartTask returns immediately. The plugin host stays a simple synchronous call (its taskStarter contract now documents "returns promptly, launches async"). Adds adapters_plugin_starter_test.go: launch invoked, returns before the launch completes, detaches from a cancelled caller context, and swallows launch errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plugins): document write validation + SendMessage rollback; harden starter test cleanup Addresses the final PR #1970 review nits: - adapters_plugin_starter_test.go: register the block-channel release via t.Cleanup (sync.Once-guarded) before the goroutine can block, per AGENTS.md. - GRPC-CONTRACT.md: document CreateTask/UpdateTask validation + error rules (title required, id required, NotFound on missing task, invalid-state reject). - spec.md: document that a failed SendMessage dispatch deletes the recorded message, plus the required-field/NotFound/terminal-session rules. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
312 lines
12 KiB
Go
312 lines
12 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/plugins/manifest"
|
|
"github.com/kandev/kandev/internal/plugins/state"
|
|
"github.com/kandev/kandev/pkg/pluginsdk"
|
|
)
|
|
|
|
// pluginHost implements pluginsdk.Host (the kandev.plugin.v1 Host RPCs, §3
|
|
// of docs/plans/plugins/GRPC-CONTRACT.md) for exactly one plugin. Service
|
|
// hands runtime.Manager a fresh pluginHost per spawn/restart via
|
|
// hostForPlugin, bound to that plugin's id and its manifest-declared
|
|
// capabilities at spawn time. Every method is capability-gated: State
|
|
// methods (GetState/SetState/DeleteState/ListState) require
|
|
// capabilities.state, RevealSecret requires capabilities.secrets. EmitEvent
|
|
// is intentionally ungated — the frozen contract's capability-gating list
|
|
// (§5) only names state and secrets (api_read/api_write reserved for
|
|
// future); event emission has no boolean capability to gate on.
|
|
type pluginHost struct {
|
|
// UnimplementedHostData is embedded so pluginHost satisfies
|
|
// pluginsdk.Host even when one of the data-source fields below is nil
|
|
// (e.g. a test pluginHost built without SetDataSources' wiring, or a
|
|
// capability the manifest doesn't declare — see host_data.go's denied
|
|
// readers for the capability-gated path). Every accessor
|
|
// (Tasks/Sessions/Workspaces/Workflows/AgentProfiles/Repositories) is
|
|
// overridden with a real, capability-gated implementation in
|
|
// host_data.go; this embed only remains as defense-in-depth.
|
|
pluginsdk.UnimplementedHostData
|
|
|
|
pluginID string
|
|
capabilities manifest.Capabilities
|
|
|
|
// configSchema is the plugin's manifest config_schema, used by
|
|
// GetConfig to know which fields are secret (and therefore stored as
|
|
// vault references to resolve back to cleartext).
|
|
configSchema map[string]any
|
|
|
|
state *state.Store
|
|
secrets SecretVault
|
|
bus bus.EventBus
|
|
|
|
// configs reads the plugin's operator-editable config for the ungated
|
|
// GetConfig RPC. Satisfied by store.Store; nil in tests that build a
|
|
// pluginHost without one (GetConfig then returns an empty map).
|
|
configs configReader
|
|
|
|
// Host data API (ADR 0043) service-layer dependencies, wired by
|
|
// Service.hostForPlugin from Service.SetDataSources. See host_data.go.
|
|
taskData taskDataSource
|
|
workflows workflowLister
|
|
workflowSteps workflowStepLister
|
|
agentProfiles agentProfileDataSource
|
|
sessionCodeStats sessionCodeStatsSource
|
|
messageData messageDataSource
|
|
|
|
// taskWriter backs the CreateTask/UpdateTask write RPCs (ADR 0043
|
|
// phase 2, capability api_write:tasks). Wired via SetDataSources like the
|
|
// readers — the task service is available at data-source wiring time. See
|
|
// host_write.go.
|
|
taskWriter taskWriter
|
|
|
|
// writeDeps returns the live task messenger and task starter behind the
|
|
// SendMessage RPC (api_write:messages) and CreateTask's start_agent. Read
|
|
// live (not snapshotted at hostForPlugin) because the orchestrator is
|
|
// constructed after boot-active plugins spawn — same rationale as
|
|
// utilityDeps. nil on a bare test host. See host_write.go.
|
|
writeDeps func() (taskMessenger, taskStarter)
|
|
|
|
// utilityDeps returns the live utility-agent dependencies (ADR 0048) at
|
|
// call time rather than a spawn-time snapshot. hostUtilityMgr is
|
|
// constructed late in boot — after StartActivePlugins has already spawned
|
|
// boot-active plugins — so snapshotting here would strand those hosts with
|
|
// nil deps and make InvokeUtilityAgent return Unimplemented for their whole
|
|
// lifetime. Reading live (under Service.mu) lets the later SetUtilityAgent
|
|
// wiring take effect without a plugin restart. nil on a bare test host.
|
|
// See host_utility.go.
|
|
utilityDeps func() (utilityAgentSource, utilityRunner)
|
|
}
|
|
|
|
var _ pluginsdk.Host = (*pluginHost)(nil)
|
|
|
|
// permissionDenied builds the gRPC error RemotePlugin/Host RPCs return for
|
|
// an undeclared capability, matching the wire-level message from
|
|
// docs/specs/plugins/spec.md ("Permissions"): "capability '<name>' not
|
|
// declared".
|
|
func permissionDenied(capability string) error {
|
|
return status.Errorf(codes.PermissionDenied, "capability '%s' not declared", capability)
|
|
}
|
|
|
|
// taskNotFound builds the gRPC error taskReader.Get returns for an id that
|
|
// doesn't resolve to a task — the SAME error, in-process or over the wire
|
|
// (grpcHostServer.GetTask forwards it as-is), so a plugin never observes a
|
|
// (nil, nil) success for a missing task.
|
|
func taskNotFound(id string) error {
|
|
return status.Errorf(codes.NotFound, "task %q not found", id)
|
|
}
|
|
|
|
// invalidArgument builds the gRPC error a Host data reader returns when a
|
|
// plugin passes a malformed filter value (e.g. a non-RFC3339 time bound).
|
|
func invalidArgument(msg string) error {
|
|
return status.Error(codes.InvalidArgument, msg)
|
|
}
|
|
|
|
func (h *pluginHost) GetState(ctx context.Context, scope, scopeID, key string) (map[string]any, bool, error) {
|
|
if !h.capabilities.State {
|
|
return nil, false, permissionDenied("state")
|
|
}
|
|
raw, found, err := h.state.Get(ctx, h.pluginID, scope, scopeID, key)
|
|
if err != nil || !found {
|
|
return nil, found, err
|
|
}
|
|
value, err := unmarshalStateValue(raw)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return value, true, nil
|
|
}
|
|
|
|
func (h *pluginHost) SetState(ctx context.Context, scope, scopeID, key string, value map[string]any) error {
|
|
if !h.capabilities.State {
|
|
return permissionDenied("state")
|
|
}
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return fmt.Errorf("plugins: marshal state value: %w", err)
|
|
}
|
|
return h.state.Set(ctx, h.pluginID, scope, scopeID, key, raw)
|
|
}
|
|
|
|
func (h *pluginHost) DeleteState(ctx context.Context, scope, scopeID, key string) error {
|
|
if !h.capabilities.State {
|
|
return permissionDenied("state")
|
|
}
|
|
return h.state.Delete(ctx, h.pluginID, scope, scopeID, key)
|
|
}
|
|
|
|
func (h *pluginHost) ListState(ctx context.Context, scope, scopeID string) ([]pluginsdk.StateEntry, error) {
|
|
if !h.capabilities.State {
|
|
return nil, permissionDenied("state")
|
|
}
|
|
entries, err := h.state.List(ctx, h.pluginID, scope, scopeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]pluginsdk.StateEntry, len(entries))
|
|
for i, e := range entries {
|
|
value, err := unmarshalStateValue(e.Value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i] = pluginsdk.StateEntry{
|
|
Key: e.Key,
|
|
Value: value,
|
|
UpdatedAt: e.UpdatedAt.UTC().Format(time.RFC3339),
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// configReader is the narrow slice of store.Store pluginHost needs for the
|
|
// GetConfig RPC.
|
|
type configReader interface {
|
|
GetConfig(id string) (map[string]any, error)
|
|
}
|
|
|
|
// GetConfig returns the plugin's own operator-editable config, secret values
|
|
// included — this RPC is how a configured credential (e.g. a PAT) reaches
|
|
// the plugin process. Secret fields are stored as vault references
|
|
// (configVaultRef) and resolved back to cleartext here; this resolution is
|
|
// deliberately NOT gated on capabilities.secrets, since it is the plugin's
|
|
// own config, set by the operator specifically for it.
|
|
func (h *pluginHost) GetConfig(ctx context.Context) (map[string]any, error) {
|
|
if h.configs == nil {
|
|
return map[string]any{}, nil
|
|
}
|
|
config, err := h.configs.GetConfig(h.pluginID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if config == nil {
|
|
return map[string]any{}, nil
|
|
}
|
|
return h.resolveConfigSecrets(ctx, config)
|
|
}
|
|
|
|
// resolveConfigSecrets replaces each secret config field's vault reference
|
|
// with the cleartext value from the vault. A resolution failure is an error
|
|
// (not a silent drop): the plugin must never mistake a broken vault for
|
|
// "the operator cleared this setting". Works on a copy — writing cleartext
|
|
// into the caller's map would leak it into any future cached configReader
|
|
// value, even though today's FSStore.GetConfig always returns a fresh map.
|
|
func (h *pluginHost) resolveConfigSecrets(ctx context.Context, config map[string]any) (map[string]any, error) {
|
|
out := make(map[string]any, len(config))
|
|
for k, v := range config {
|
|
out[k] = v
|
|
}
|
|
for field := range secretPropertyKeys(h.configSchema) {
|
|
if !isConfigVaultRef(h.pluginID, field, out[field]) {
|
|
continue
|
|
}
|
|
if h.secrets == nil {
|
|
return nil, errors.New("plugins: secret vault not configured")
|
|
}
|
|
cleartext, err := h.secrets.Reveal(ctx, pluginConfigSecretID(h.pluginID, field))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("plugins: resolve secret config field %q: %w", field, err)
|
|
}
|
|
out[field] = cleartext
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetSecret reads a plugin-owned secret previously stored via SetSecret.
|
|
// found is false (with a nil error) when the key was never set. Requires
|
|
// capabilities.secrets, like every secret-touching RPC.
|
|
func (h *pluginHost) GetSecret(ctx context.Context, key string) (string, bool, error) {
|
|
if err := h.checkSecretAccess(key); err != nil {
|
|
return "", false, err
|
|
}
|
|
value, err := h.secrets.Reveal(ctx, pluginSecretID(h.pluginID, key))
|
|
if err != nil {
|
|
if isSecretNotFound(err) {
|
|
return "", false, nil
|
|
}
|
|
return "", false, err
|
|
}
|
|
return value, true, nil
|
|
}
|
|
|
|
// SetSecret upserts a plugin-owned secret into kandev's encrypted vault,
|
|
// namespaced to this plugin (vault id "plugin:<id>:secret:<key>") — a
|
|
// plugin can never write outside its own namespace.
|
|
func (h *pluginHost) SetSecret(ctx context.Context, key, value string) error {
|
|
if err := h.checkSecretAccess(key); err != nil {
|
|
return err
|
|
}
|
|
vaultID := pluginSecretID(h.pluginID, key)
|
|
return h.secrets.Set(ctx, vaultID, vaultID, value)
|
|
}
|
|
|
|
// DeleteSecret removes a plugin-owned secret. Deleting a missing key is not
|
|
// an error, matching DeleteState's semantics.
|
|
func (h *pluginHost) DeleteSecret(ctx context.Context, key string) error {
|
|
if err := h.checkSecretAccess(key); err != nil {
|
|
return err
|
|
}
|
|
err := h.secrets.Delete(ctx, pluginSecretID(h.pluginID, key))
|
|
if isSecretNotFound(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// checkSecretAccess is the shared gate for the plugin-scoped secret RPCs:
|
|
// capability secrets declared, a wired vault, and a key that is a single
|
|
// sane identifier (pluginSecretKeyPattern) so it can never smuggle
|
|
// separators into the vault-id namespace.
|
|
func (h *pluginHost) checkSecretAccess(key string) error {
|
|
if !h.capabilities.Secrets {
|
|
return permissionDenied("secrets")
|
|
}
|
|
if h.secrets == nil {
|
|
return errors.New("plugins: secret vault not configured")
|
|
}
|
|
if !pluginSecretKeyPattern.MatchString(key) {
|
|
return status.Errorf(codes.InvalidArgument, "invalid secret key %q", key)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *pluginHost) RevealSecret(ctx context.Context, ref string) (string, error) {
|
|
if !h.capabilities.Secrets {
|
|
return "", permissionDenied("secrets")
|
|
}
|
|
if h.secrets == nil {
|
|
return "", errors.New("plugins: secret vault not configured")
|
|
}
|
|
return h.secrets.Reveal(ctx, ref)
|
|
}
|
|
|
|
// EmitEvent publishes a plugin-originated event onto the bus, subject
|
|
// "plugin.<id>.<name>" (per the task's build instructions). A no-op if no
|
|
// event bus was wired (e.g. early boot, or a test Service without one).
|
|
func (h *pluginHost) EmitEvent(ctx context.Context, name string, payload map[string]any) error {
|
|
if h.bus == nil {
|
|
return nil
|
|
}
|
|
subject := "plugin." + h.pluginID + "." + name
|
|
event := bus.NewEvent(subject, "plugin:"+h.pluginID, payload)
|
|
return h.bus.Publish(ctx, subject, event)
|
|
}
|
|
|
|
// unmarshalStateValue decodes a plugin_state row's JSON value into a
|
|
// Go-native map, matching pluginsdk's Struct<->map[string]any convention.
|
|
func unmarshalStateValue(raw json.RawMessage) (map[string]any, error) {
|
|
var value map[string]any
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
return nil, fmt.Errorf("plugins: unmarshal state value: %w", err)
|
|
}
|
|
return value, nil
|
|
}
|