Files
multica/server/internal/service/plugin.go
Jiayuan Zhangandmultica-agent 5bf10d485c MUL-6582: add durable scheduled Plugin hooks (#7448)
* MUL-6582 feat(plugins): add durable scheduled hooks

Co-authored-by: multica-agent <[email protected]>

* MUL-6582: add schedule-pulse example plugin

Ship a real scheduled plugin that records each unique delivery_id in
workspace storage, so a host retry does not look like a second pulse.
The cmd/server test installs this example manifest and drives two
scheduler replicas through a dropped first attempt.

Co-authored-by: multica-agent <[email protected]>

* MUL-6582 fix(migrations): make schedule indexes retry-safe

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 17:37:21 +08:00

787 lines
31 KiB
Go

package service
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
"github.com/multica-ai/multica/server/internal/util/secretbox"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/featureflag"
"github.com/multica-ai/multica/server/pkg/plugincontract"
)
// PluginService owns plugin publishing, installation, configuration, and
// plugin-owned state.
type PluginService struct {
Queries *db.Queries
TxStarter TxStarter
// Secrets encrypts `secret` config values. Nil disables secret writes so a
// misconfigured deployment fails closed instead of storing plaintext.
Secrets *secretbox.Box
// LocalDir is MULTICA_PLUGIN_DIR: a directory of plugin sources an author
// can publish from without zipping and uploading after every edit. Empty
// disables the development publish channel.
LocalDir string
// DevOrigins is MULTICA_PLUGIN_DEV_ORIGINS: hook endpoint origins an author
// may point a plugin at while building one, typically a localhost server.
// Empty in every deployment that has not opted in, which is the only reason
// it is safe to skip the public-HTTPS guard for them.
DevOrigins []string
// Host gates which declared contributions this build can actually run.
Host plugincontract.Capabilities
// DeploymentKey is the raw MULTICA_PLUGIN_SECRET_KEY, used to derive each
// installation's hook signing secret. Held separately from Secrets because
// signing needs a key it can reproduce, not a sealed box.
DeploymentKey []byte
// Callbacks issues the short-lived tokens a hook handler uses to call back.
// Nil means hooks go out without one.
Callbacks *CallbackTokens
// CallbackBaseURL is the Action API root a hook handler should call, sent
// alongside the callback token so a plugin does not hardcode our hostname.
CallbackBaseURL string
// FeatureFlags gates the hook engine. Held here because the EVENT path has
// no request to read the flag from: it runs on a worker, so the check has to
// live with the service rather than in a handler. Nil reads as disabled,
// which is the safe direction for the one path that reaches a third party.
FeatureFlags *featureflag.Service
// HookClient overrides the outbound client, and ONLY for an endpoint whose
// origin the operator already named in DevOrigins. Nil everywhere else, so
// a public endpoint always goes through the SSRF-guarded client and this
// field cannot widen what a deployment can reach.
HookClient *http.Client
}
func NewPluginService(queries *db.Queries, txStarter TxStarter) *PluginService {
service := &PluginService{
Queries: queries,
TxStarter: txStarter,
LocalDir: strings.TrimSpace(os.Getenv("MULTICA_PLUGIN_DIR")),
DevOrigins: parseDevOrigins(os.Getenv("MULTICA_PLUGIN_DEV_ORIGINS")),
Host: plugincontract.HostCapabilities(),
}
service.HookClient = devHookClient(service.DevOrigins, os.Getenv("MULTICA_PLUGIN_DEV_CA"))
return service
}
// devHookClient trusts an additional CA for dev-origin hook endpoints only.
//
// A hook URL must be HTTPS — the manifest requires it — so an author testing
// against a local server needs its certificate trusted by something. This adds
// a named CA file rather than turning verification off: the failure mode of
// InsecureSkipVerify is that it survives into production behind a config flag
// somebody forgot, and there is no way to tell from the code whether it is
// active. A CA that has to be supplied by path cannot silently apply to
// anything else.
//
// Nil unless BOTH an origin allowlist and a CA path are set, and it is only
// ever consulted for an endpoint already inside that allowlist.
func devHookClient(devOrigins []string, caPath string) *http.Client {
if len(devOrigins) == 0 || strings.TrimSpace(caPath) == "" {
return nil
}
pem, err := os.ReadFile(caPath)
if err != nil {
slog.Warn("plugins: MULTICA_PLUGIN_DEV_CA could not be read; dev hook endpoints will not be trusted", "path", caPath, "error", err)
return nil
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
slog.Warn("plugins: MULTICA_PLUGIN_DEV_CA contained no certificates", "path", caPath)
return nil
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
slog.Info("plugins: dev hook endpoints will trust an additional CA", "path", caPath, "origins", devOrigins)
return &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
// Refuse redirects, matching the secure client. A 302 from a dev
// endpoint would replay the SIGNED body and the callback token to
// wherever it pointed — the one destination check this path skipped is
// exactly the one a redirect would need.
CheckRedirect: func(*http.Request, []*http.Request) error {
return errors.New("hook endpoints must not redirect")
},
}
}
type PluginErrorKind string
const (
PluginErrorInvalid PluginErrorKind = "invalid"
PluginErrorNotFound PluginErrorKind = "not_found"
PluginErrorConflict PluginErrorKind = "conflict"
PluginErrorForbidden PluginErrorKind = "forbidden"
PluginErrorIncompatible PluginErrorKind = "incompatible"
PluginErrorQuota PluginErrorKind = "quota"
PluginErrorUnavailable PluginErrorKind = "unavailable"
)
type PluginError struct {
Kind PluginErrorKind
Message string
Err error
}
func (e *PluginError) Error() string {
if e.Err != nil {
return e.Message + ": " + e.Err.Error()
}
return e.Message
}
func (e *PluginError) Unwrap() error { return e.Err }
func pluginErrf(kind PluginErrorKind, format string, args ...any) *PluginError {
return &PluginError{Kind: kind, Message: fmt.Sprintf(format, args...)}
}
// PluginPreview is what the consent screen renders. It is deliberately the raw
// manifest text plus the scope list: there is no signature, no trust tier, and
// no publisher verification in this model, so the administrator reading the
// scopes IS the trust decision.
//
// What it describes is one published version, and installing binds to that same
// version — so unlike the URL model this replaced, the code approved here is the
// code that runs.
type PluginPreview struct {
Manifest plugincontract.Manifest `json:"manifest"`
Scopes []string `json:"scopes"`
ConfigSchema []PluginConfigField `json:"config_schema"`
VersionID string `json:"version_id"`
Version string `json:"version"`
Digest string `json:"digest"`
// Upgrade describes what changes if this replaces an existing install.
Installed bool `json:"installed"`
InstalledVersion string `json:"installed_version,omitempty"`
AddedScopes []string `json:"added_scopes,omitempty"`
}
// PluginConfigField is one host-rendered configuration input.
type PluginConfigField struct {
Key string `json:"key"`
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Required bool `json:"required"`
Options []string `json:"options,omitempty"`
Placeholder string `json:"placeholder,omitempty"`
Multiline bool `json:"multiline,omitempty"`
}
// ConfigFieldsForManifest flattens the manifest config schema into declaration
// order so the client renders the same form the server validates against.
func ConfigFieldsForManifest(manifest plugincontract.Manifest) []PluginConfigField {
fields := make([]PluginConfigField, 0, manifest.Config.Len())
for _, field := range manifest.Config.Fields {
fields = append(fields, PluginConfigField{
Key: field.Key,
Type: field.Type,
Label: field.Label,
Description: field.Description,
Required: field.Required,
Options: field.Options,
Placeholder: field.Placeholder,
Multiline: field.Multiline,
})
}
return fields
}
// ManifestForVersion decodes the manifest a published version froze at publish
// time.
//
// Nothing is fetched. The manifest, the surface scripts and the skill text were
// all validated together when the author published, and they are read back
// together — an installation cannot end up consenting to one version's manifest
// while running another version's code.
func ManifestForVersion(version db.PluginPackageVersion) (plugincontract.Manifest, []byte, error) {
var manifest plugincontract.Manifest
if err := json.Unmarshal(version.Manifest, &manifest); err != nil {
return plugincontract.Manifest{}, nil, &PluginError{Kind: PluginErrorInvalid, Message: "published plugin manifest is unreadable", Err: err}
}
return manifest, version.Manifest, nil
}
// parseDevOrigins reads the opt-in list. Anything that is not a bare origin is
// dropped rather than half-honored: a path or query here would read as a broader
// grant than it is.
func parseDevOrigins(raw string) []string {
origins := make([]string, 0)
for _, candidate := range strings.Split(raw, ",") {
candidate = strings.TrimRight(strings.TrimSpace(candidate), "/")
if candidate == "" {
continue
}
parsed, err := url.Parse(candidate)
if err != nil || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" {
continue
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
continue
}
origins = append(origins, parsed.Scheme+"://"+parsed.Host)
}
return origins
}
func (s *PluginService) isDevOrigin(sourceURL string) bool {
if len(s.DevOrigins) == 0 {
return false
}
parsed, err := url.Parse(sourceURL)
if err != nil || parsed.Host == "" {
return false
}
origin := parsed.Scheme + "://" + parsed.Host
for _, allowed := range s.DevOrigins {
if origin == allowed {
return true
}
}
return false
}
// PreviewPlugin reads a published version's manifest without writing anything.
// It is the first half of the two-step install: the administrator must see the
// scopes before an installation row exists.
func (s *PluginService) PreviewPlugin(ctx context.Context, workspaceID pgtype.UUID, versionID string) (*PluginPreview, error) {
version, err := s.VersionForWorkspace(ctx, workspaceID, versionID)
if err != nil {
return nil, err
}
manifest, _, err := ManifestForVersion(version)
if err != nil {
return nil, err
}
if err := manifest.CheckCapabilities(s.Host); err != nil {
return nil, &PluginError{Kind: PluginErrorIncompatible, Message: capabilityMessage(err), Err: err}
}
preview := &PluginPreview{
Manifest: manifest,
Scopes: manifest.Scopes,
ConfigSchema: ConfigFieldsForManifest(manifest),
VersionID: uuidString(version.ID),
Version: version.Version,
Digest: version.Digest,
}
existing, err := s.Queries.GetWorkspacePluginInstallationByKey(ctx, db.GetWorkspacePluginInstallationByKeyParams{
WorkspaceID: workspaceID,
PluginKey: manifest.Key,
})
if err == nil {
preview.Installed = true
preview.InstalledVersion = existing.Version
preview.AddedScopes = addedScopes(decodeScopes(existing.GrantedScopes), manifest.Scopes)
} else if !errors.Is(err, pgx.ErrNoRows) {
return nil, &PluginError{Kind: PluginErrorUnavailable, Message: "load existing installation", Err: err}
}
return preview, nil
}
func capabilityMessage(err error) string {
var unavailable *plugincontract.ErrCapabilityUnavailable
if errors.As(err, &unavailable) {
return "This plugin declares capabilities that are not enabled yet: " + strings.Join(unavailable.Missing, ", ")
}
return "This plugin declares capabilities that are not enabled yet"
}
func addedScopes(granted []string, wanted []string) []string {
have := make(map[string]bool, len(granted))
for _, scope := range granted {
have[scope] = true
}
added := make([]string, 0)
for _, scope := range wanted {
if !have[scope] {
added = append(added, scope)
}
}
return added
}
func decodeScopes(raw []byte) []string {
var scopes []string
if len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, &scopes); err != nil {
return nil
}
return scopes
}
// InstallPlugin is the second half of the install. grantedScopes must match the
// manifest exactly: partial consent would leave the plugin silently broken, and
// consenting to a scope the manifest does not request would grant access the
// administrator was never shown a reason for.
//
// The installation is bound to one published version. A later publish creates
// another version and does not touch this row — upgrading is a second, explicit
// consent, which is what makes "approved the manifest, ran the code" a true
// statement rather than an aspiration.
func (s *PluginService) InstallPlugin(ctx context.Context, workspaceID, userID pgtype.UUID, versionID string, grantedScopes []string) (db.PluginInstallation, error) {
version, err := s.VersionForWorkspace(ctx, workspaceID, versionID)
if err != nil {
return db.PluginInstallation{}, err
}
manifest, canonical, err := ManifestForVersion(version)
if err != nil {
return db.PluginInstallation{}, err
}
if err := manifest.CheckCapabilities(s.Host); err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorIncompatible, Message: capabilityMessage(err), Err: err}
}
if err := requireExactScopes(manifest.Scopes, grantedScopes); err != nil {
return db.PluginInstallation{}, err
}
scopesJSON, err := json.Marshal(manifest.Scopes)
if err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorInvalid, Message: "encode granted scopes", Err: err}
}
existing, err := s.Queries.GetWorkspacePluginInstallationByKey(ctx, db.GetWorkspacePluginInstallationByKeyParams{
WorkspaceID: workspaceID,
PluginKey: manifest.Key,
})
if errors.Is(err, pgx.ErrNoRows) {
// A transaction even though it is one row: the skill resources land in
// the same commit. An installation whose skills half-arrived is worse
// than one that failed, because the missing half is invisible.
tx, txErr := s.TxStarter.Begin(ctx)
if txErr != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "begin install", Err: txErr}
}
defer func() { _ = tx.Rollback(ctx) }()
queries := s.Queries.WithTx(tx)
if lockErr := lockPluginPackageKey(ctx, queries, workspaceID, manifest.Key); lockErr != nil {
return db.PluginInstallation{}, lockErr
}
if recheckErr := requireVersionStillPublished(ctx, queries, workspaceID, version.ID); recheckErr != nil {
return db.PluginInstallation{}, recheckErr
}
installation, createErr := queries.CreatePluginInstallation(ctx, db.CreatePluginInstallationParams{
WorkspaceID: workspaceID,
PluginKey: manifest.Key,
PackageVersionID: version.ID,
// The published version string, not the manifest's: a development
// publish lands under a `+dev.N` suffix, and the settings page has to
// name the version that actually exists.
Version: version.Version,
Manifest: canonical,
GrantedScopes: scopesJSON,
InstalledBy: userID,
})
if createErr != nil {
// Two admins installing the same plugin at once: one loses the
// unique index. That is a conflict to retry, not a broken backend.
if isUniqueViolation(createErr) {
return db.PluginInstallation{}, pluginErrf(PluginErrorConflict, "this plugin is already installed in this workspace")
}
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "install plugin", Err: createErr}
}
if skillErr := s.InstallSkillResources(ctx, queries, installation, manifest, userID); skillErr != nil {
return db.PluginInstallation{}, skillErr
}
if scheduleErr := s.reconcilePluginHookSchedules(ctx, queries, installation, manifest); scheduleErr != nil {
return db.PluginInstallation{}, scheduleErr
}
if commitErr := tx.Commit(ctx); commitErr != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "commit install", Err: commitErr}
}
return installation, nil
}
if err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "load existing installation", Err: err}
}
// Upgrade in place. Values whose fields the new manifest dropped are pruned
// rather than left as state nothing can reach — and that applies to secrets
// too, which is the case where unreachable residue is ciphertext. The whole
// upgrade is one transaction so a snapshot can never land with the previous
// version's secrets still attached.
config := pruneConfig(existing.Config, manifest)
orphanedSecrets, err := s.orphanedSecretKeys(ctx, existing.ID, manifest)
if err != nil {
return db.PluginInstallation{}, err
}
tx, err := s.TxStarter.Begin(ctx)
if err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "begin upgrade", Err: err}
}
defer func() { _ = tx.Rollback(ctx) }()
queries := s.Queries.WithTx(tx)
if err := lockPluginPackageKey(ctx, queries, workspaceID, manifest.Key); err != nil {
return db.PluginInstallation{}, err
}
if err := requireVersionStillPublished(ctx, queries, workspaceID, version.ID); err != nil {
return db.PluginInstallation{}, err
}
for _, key := range orphanedSecrets {
if _, err := queries.DeletePluginSecret(ctx, db.DeletePluginSecretParams{InstallationID: existing.ID, Key: key}); err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "prune plugin secret", Err: err}
}
}
updated, err := queries.UpdatePluginInstallationManifest(ctx, db.UpdatePluginInstallationManifestParams{
ID: existing.ID,
PackageVersionID: version.ID,
Version: version.Version,
Manifest: canonical,
GrantedScopes: scopesJSON,
Config: config,
})
if err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "upgrade plugin", Err: err}
}
// Re-run on upgrade so a changed SKILL.md takes effect and a dropped one is
// pruned. Same transaction as the manifest snapshot: the two must never
// disagree about what this version contributes.
if err := s.InstallSkillResources(ctx, queries, updated, manifest, userID); err != nil {
return db.PluginInstallation{}, err
}
if err := s.reconcilePluginHookSchedules(ctx, queries, updated, manifest); err != nil {
return db.PluginInstallation{}, err
}
if err := tx.Commit(ctx); err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "commit upgrade", Err: err}
}
return updated, nil
}
// orphanedSecretKeys returns stored secrets the new manifest no longer declares
// as a secret field — including a field that changed type away from secret.
func (s *PluginService) orphanedSecretKeys(ctx context.Context, installationID pgtype.UUID, manifest plugincontract.Manifest) ([]string, error) {
stored, err := s.Queries.ListPluginSecretKeys(ctx, installationID)
if err != nil {
return nil, &PluginError{Kind: PluginErrorUnavailable, Message: "list plugin secrets", Err: err}
}
orphaned := make([]string, 0)
for _, row := range stored {
field, ok := manifest.Config.Field(row.Key)
if !ok || field.Type != plugincontract.ConfigSecret {
orphaned = append(orphaned, row.Key)
}
}
return orphaned, nil
}
func requireExactScopes(manifestScopes, grantedScopes []string) error {
if len(manifestScopes) != len(grantedScopes) {
return pluginErrf(PluginErrorConflict, "granted_scopes must match the manifest scopes exactly")
}
wanted := make(map[string]bool, len(manifestScopes))
for _, scope := range manifestScopes {
wanted[scope] = true
}
for _, scope := range grantedScopes {
if !wanted[scope] {
return pluginErrf(PluginErrorConflict, "granted_scopes contains %q, which the manifest does not request", scope)
}
}
return nil
}
func pruneConfig(raw []byte, manifest plugincontract.Manifest) []byte {
var values map[string]any
if len(raw) == 0 || json.Unmarshal(raw, &values) != nil {
return []byte("{}")
}
for key := range values {
if _, ok := manifest.Config.Field(key); !ok {
delete(values, key)
}
}
encoded, err := json.Marshal(values)
if err != nil {
return []byte("{}")
}
return encoded
}
func parseUUIDValue(value string) (pgtype.UUID, error) { return util.ParseUUID(value) }
func uuidString(value pgtype.UUID) string { return util.UUIDToString(value) }
// ParseInstallationManifest reads back the consented snapshot. Callers must use
// this, never a freshly fetched manifest: what the source URL serves today is
// not what the administrator approved.
func ParseInstallationManifest(installation db.PluginInstallation) (plugincontract.Manifest, error) {
var manifest plugincontract.Manifest
if err := json.Unmarshal(installation.Manifest, &manifest); err != nil {
return plugincontract.Manifest{}, fmt.Errorf("decode stored plugin manifest: %w", err)
}
return manifest, nil
}
// SetConfig validates values against the stored manifest schema and splits them
// by destination: plain values go on the installation row, secrets go to the
// encrypted table. A secret value never lands in `config`.
func (s *PluginService) SetConfig(ctx context.Context, installation db.PluginInstallation, values map[string]any) (db.PluginInstallation, error) {
manifest, err := ParseInstallationManifest(installation)
if err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorInvalid, Message: "stored plugin manifest is unreadable", Err: err}
}
plain := map[string]any{}
secrets := map[string]string{}
for key, value := range values {
field, ok := manifest.Config.Field(key)
if !ok {
return db.PluginInstallation{}, pluginErrf(PluginErrorInvalid, "unknown config field %q", key)
}
if field.Type == plugincontract.ConfigSecret {
text, ok := value.(string)
if !ok {
return db.PluginInstallation{}, pluginErrf(PluginErrorInvalid, "config field %q must be a string", key)
}
if len(text) > MaxPluginSecretBytes {
return db.PluginInstallation{}, pluginErrf(PluginErrorInvalid, "config field %q exceeds %d bytes", key, MaxPluginSecretBytes)
}
secrets[key] = text
continue
}
normalized, err := normalizeConfigValue(field, value)
if err != nil {
return db.PluginInstallation{}, err
}
plain[key] = normalized
}
// Merge over the stored values so a partial update does not silently clear
// fields the form did not submit.
merged := map[string]any{}
if len(installation.Config) > 0 {
_ = json.Unmarshal(installation.Config, &merged)
}
for key, value := range plain {
merged[key] = value
}
encoded, err := json.Marshal(merged)
if err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorInvalid, Message: "encode plugin config", Err: err}
}
if len(secrets) > 0 && s.Secrets == nil {
return db.PluginInstallation{}, pluginErrf(PluginErrorUnavailable, "plugin secrets are disabled: MULTICA_PLUGIN_SECRET_KEY is not configured")
}
// Secrets and plain values are two tables with no foreign key between them,
// so one transaction is what keeps a saved form from landing half-applied.
tx, err := s.TxStarter.Begin(ctx)
if err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "begin configure", Err: err}
}
defer func() { _ = tx.Rollback(ctx) }()
queries := s.Queries.WithTx(tx)
for key, text := range secrets {
// An empty submission clears the secret rather than storing "".
if text == "" {
if _, err := queries.DeletePluginSecret(ctx, db.DeletePluginSecretParams{InstallationID: installation.ID, Key: key}); err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "clear plugin secret", Err: err}
}
continue
}
ciphertext, sealErr := s.Secrets.Seal([]byte(text))
if sealErr != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "encrypt plugin secret", Err: sealErr}
}
if err := queries.UpsertPluginSecret(ctx, db.UpsertPluginSecretParams{
InstallationID: installation.ID,
Key: key,
Ciphertext: ciphertext,
}); err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "store plugin secret", Err: err}
}
}
updated, err := queries.UpdatePluginInstallationConfig(ctx, db.UpdatePluginInstallationConfigParams{
ID: installation.ID,
Config: encoded,
})
if err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "store plugin config", Err: err}
}
if err := tx.Commit(ctx); err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "commit configure", Err: err}
}
return updated, nil
}
// isUniqueViolation reports a Postgres 23505, the only class of write conflict
// the plugin surface can produce.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
// MaxPluginSecretBytes bounds one secret value before encryption.
const MaxPluginSecretBytes = 8192
func normalizeConfigValue(field plugincontract.ConfigField, value any) (any, error) {
label := fmt.Sprintf("config field %q", field.Key)
switch field.Type {
case plugincontract.ConfigString:
text, ok := value.(string)
if !ok {
return nil, pluginErrf(PluginErrorInvalid, "%s must be a string", label)
}
if len(text) > 4096 {
return nil, pluginErrf(PluginErrorInvalid, "%s exceeds 4096 bytes", label)
}
return text, nil
case plugincontract.ConfigNumber:
number, ok := value.(float64)
if !ok {
return nil, pluginErrf(PluginErrorInvalid, "%s must be a number", label)
}
return number, nil
case plugincontract.ConfigBool:
flag, ok := value.(bool)
if !ok {
return nil, pluginErrf(PluginErrorInvalid, "%s must be a boolean", label)
}
return flag, nil
case plugincontract.ConfigEnum:
text, ok := value.(string)
if !ok {
return nil, pluginErrf(PluginErrorInvalid, "%s must be a string", label)
}
for _, option := range field.Options {
if option == text {
return text, nil
}
}
return nil, pluginErrf(PluginErrorInvalid, "%s must be one of the declared options", label)
default:
return nil, pluginErrf(PluginErrorInvalid, "%s has an unsupported type", label)
}
}
// ConfiguredSecretKeys reports which secret fields hold a value. It returns
// names only — no endpoint ever returns a secret value, not even masked.
func (s *PluginService) ConfiguredSecretKeys(ctx context.Context, installationID pgtype.UUID) ([]string, error) {
rows, err := s.Queries.ListPluginSecretKeys(ctx, installationID)
if err != nil {
return nil, &PluginError{Kind: PluginErrorUnavailable, Message: "list plugin secrets", Err: err}
}
keys := make([]string, 0, len(rows))
for _, row := range rows {
keys = append(keys, row.Key)
}
return keys, nil
}
// SetEnabled toggles an installation. Disabling hides every contribution
// immediately but keeps storage and secrets, so re-enabling is not a reinstall.
func (s *PluginService) SetEnabled(ctx context.Context, installation db.PluginInstallation, enabled bool) (db.PluginInstallation, error) {
if installation.Enabled == enabled {
return installation, nil
}
tx, err := s.TxStarter.Begin(ctx)
if err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "begin plugin state update", Err: err}
}
defer func() { _ = tx.Rollback(ctx) }()
queries := s.Queries.WithTx(tx)
updated, err := queries.SetPluginInstallationEnabled(ctx, db.SetPluginInstallationEnabledParams{
ID: installation.ID,
Enabled: enabled,
})
if err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "update plugin state", Err: err}
}
if err := s.setPluginHookSchedulesEnabled(ctx, queries, installation.ID, enabled); err != nil {
return db.PluginInstallation{}, err
}
if err := tx.Commit(ctx); err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "commit plugin state update", Err: err}
}
return updated, nil
}
// Uninstall removes the installation and all of its application-owned rows.
// There are no foreign keys or cascades by repository policy, so the deletes
// share one transaction: a partial uninstall would strand rows nothing can
// reach or clean up later.
func (s *PluginService) Uninstall(ctx context.Context, installation db.PluginInstallation) error {
tx, err := s.TxStarter.Begin(ctx)
if err != nil {
return &PluginError{Kind: PluginErrorUnavailable, Message: "begin uninstall", Err: err}
}
defer func() { _ = tx.Rollback(ctx) }()
queries := s.Queries.WithTx(tx)
if err := queries.DeletePluginHookSchedulesByInstallation(ctx, installation.ID); err != nil {
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin hook schedules", Err: err}
}
if err := queries.DeletePluginStorageByInstallation(ctx, installation.ID); err != nil {
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin storage", Err: err}
}
if err := queries.DeletePluginSecretsByInstallation(ctx, installation.ID); err != nil {
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin secrets", Err: err}
}
// Hook records go too. They name an installation that is about to stop
// existing, and keeping them would leave rows nothing can attribute — the
// same reason storage and secrets are cleared here rather than swept later.
if err := queries.DeletePluginInvocationsByInstallation(ctx, installation.ID); err != nil {
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin invocations", Err: err}
}
// The skills this installation contributed go with it. Scoped by
// plugin_installation_id, so a skill a person wrote is untouched even if it
// happens to share a name with something the plugin once provided.
if err := queries.DeletePluginSkillsByInstallation(ctx, installation.ID); err != nil {
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin skills", Err: err}
}
if err := queries.DeletePluginInstallation(ctx, installation.ID); err != nil {
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin installation", Err: err}
}
if err := tx.Commit(ctx); err != nil {
return &PluginError{Kind: PluginErrorUnavailable, Message: "commit uninstall", Err: err}
}
return nil
}
// InstallationForWorkspace loads an installation and confirms it belongs to the
// workspace in the URL, so an id from another workspace cannot be operated on.
func (s *PluginService) InstallationForWorkspace(ctx context.Context, workspaceID pgtype.UUID, installationID string) (db.PluginInstallation, error) {
parsed, err := util.ParseUUID(installationID)
if err != nil {
return db.PluginInstallation{}, pluginErrf(PluginErrorNotFound, "plugin installation not found")
}
installation, err := s.Queries.GetWorkspacePluginInstallation(ctx, db.GetWorkspacePluginInstallationParams{
WorkspaceID: workspaceID,
ID: parsed,
})
if errors.Is(err, pgx.ErrNoRows) {
return db.PluginInstallation{}, pluginErrf(PluginErrorNotFound, "plugin installation not found")
}
if err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "load plugin installation", Err: err}
}
return installation, nil
}