mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* feat(plugins): host plugin artifacts, bind installs to immutable versions A plugin used to be a URL. The manifest was frozen at install, but the surface script was fetched from the author's server every time a panel opened, with no integrity check — so an administrator consented to a manifest while the browser ran whatever that host served that day, inside the scopes already granted. The author's uptime was our uptime, every panel open leaked the reader's IP and "who read which issue" to the author, and publishing at all required a public HTTPS domain, which is why the repo's own deploy-sentinel example never ran. The author now uploads an artifact bundle and Multica stores it: - plugin_package / plugin_package_version / plugin_package_file. A version is insert-only, and the (package_id, version) unique index is what makes immutability a database rule rather than a convention. - An installation names one version. Publishing another changes nothing for a workspace until an administrator upgrades, which is a second consent. - The bundle is validated once, at publish: the manifest parses, every file it declares is present and loadable, and a surface entry with a top-level import is refused with the line named. That failure used to surface months later in a reader's browser. - The host inlines the surface script into the document it generates, so the CSP names no remote script origin at all. `connect-src 'none'` now means it: a surface with no net: scope cannot reach anywhere, including its author. - Install-by-URL is gone rather than kept alongside. Two paths would make "is this code frozen?" depend on how the plugin was installed. - MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary immutable version instead of being a second render path; re-publishing an unchanged version lands as `+dev.N`. Hook endpoints and MCP servers remain on the author's own infrastructure. Migration 376 drops existing installations: they name a source URL that is no longer a concept, and the code they would run was never published here. plugins_v1 has never left its feature flag and holds no production data — the same call migration 344 made. Closes MUL-6469 Co-authored-by: multica-agent <[email protected]> * fix(plugins): serialize publish/install/delete, correct the isolation claim Review of #7321 found five problems. Four are fixed here; the fifth is real, needs its own design pass, and is now tracked and described honestly in the code instead of claimed away. Migration prefix collision. main took 376 for agent_task_durable_work_dir while this branch was open, so `migration prefix 376 is reused` failed backend-tests. Rebased and renumbered the six migrations to 377-382. TOCTOU between delete and install/publish. Relationships are application-owned by repository policy, so nothing made "the version this installation names still exists" true across statements: `delete counts zero installs` -> `install reads the version` -> `delete commits` -> `install commits` left an installation whose panel 404s forever, and a publish racing a delete left a version whose package row was gone. Publish, install and delete now hold a (workspace, plugin key) advisory lock for their whole transaction, and delete counts installations inside it. Both interleavings have regression tests that reproduce the bug with the lock removed. Failed publishes moved package state. upsertPackage ran before the version transaction, so a republish that lost the unique index had already renamed the package, and a first publish that failed afterwards left a plugin with zero versions. It now runs inside the same transaction. The top-level import check was wrong in BOTH directions. It read line prefixes, so it missed ` import x` and `/* c */ import x` — and it refused a valid file whose template literal contained the word at a line start, which is ordinary in a surface that renders code samples. A refusal blocks a publish with no way around it, so that false positive was the worse half. Replaced with a scanner that skips comments and string/template literals; where it is imprecise it is imprecise safely (`/` always reads as division), so a mistake can only cost a detection, never invent one. Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self` navigation by design, no shipped CSP directive covers it, and contentWindow is unchanged across it — so a hostile artifact can still reach its author once and hand the replacement document the bridge port. Closing it needs an embedder frame-src policy or a handshake a navigated document cannot complete; both are decisions of their own. What lands here is damage control, labelled as such: the generated document beacons on pagehide from a listener the plugin cannot detach, and the embedder drops the bridge and unmounts the frame. The "a surface cannot reach its author" wording is removed from the SDK README and the tests. Closes MUL-6469 Refs MUL-6485 Co-authored-by: multica-agent <[email protected]> * fix(plugins): harden surface validation and error reporting * fix(migrations): renumber plugin package migrations * fix(plugins): validate classic surfaces and reset failures --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]>
525 lines
22 KiB
Go
525 lines
22 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/plugincontract"
|
|
)
|
|
|
|
// Publishing.
|
|
//
|
|
// An author uploads an artifact bundle; Multica stores it and serves it. There
|
|
// is no source URL, and there is no second way in: what an administrator
|
|
// approves and what a reader's browser runs are the same rows in this database.
|
|
//
|
|
// A published version is immutable. Publishing again creates another version and
|
|
// changes nothing for a workspace that already installed one — upgrading is an
|
|
// administrator's action, which is the whole reason the two are separate.
|
|
//
|
|
// Only the frontend artifact moved here. Hook endpoints and MCP servers still
|
|
// live on the author's own infrastructure; we do not run third-party backends.
|
|
|
|
// PluginPackageVersionSummary is one published version as the settings page
|
|
// lists it.
|
|
type PluginPackageVersionSummary struct {
|
|
ID string `json:"id"`
|
|
Version string `json:"version"`
|
|
Digest string `json:"digest"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
PublishedAt string `json:"published_at"`
|
|
// Installed marks the version this workspace currently runs, so "which one
|
|
// am I on" is answerable without cross-referencing two lists.
|
|
Installed bool `json:"installed"`
|
|
}
|
|
|
|
// PluginPackageSummary is one publishable plugin identity with its versions,
|
|
// newest first.
|
|
type PluginPackageSummary struct {
|
|
ID string `json:"id"`
|
|
PluginKey string `json:"plugin_key"`
|
|
Name string `json:"name"`
|
|
Versions []PluginPackageVersionSummary `json:"versions"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
const pluginTimeFormat = "2006-01-02T15:04:05Z07:00"
|
|
|
|
// PublishBundle stores an uploaded artifact bundle as a new immutable version.
|
|
func (s *PluginService) PublishBundle(ctx context.Context, workspaceID, userID pgtype.UUID, archive []byte) (PluginPackageSummary, error) {
|
|
bundle, err := plugincontract.ParseBundle(archive)
|
|
if err != nil {
|
|
// The wrapped text names the file or field at fault, and it is written
|
|
// for the author. A publish failure they cannot act on would send them
|
|
// back to guessing, which is the failure mode this whole path replaces.
|
|
return PluginPackageSummary{}, pluginErrf(PluginErrorInvalid, "plugin package is invalid: %v", err)
|
|
}
|
|
return s.publish(ctx, workspaceID, userID, bundle, false)
|
|
}
|
|
|
|
// PublishLocalBundle publishes from MULTICA_PLUGIN_DIR — the development
|
|
// channel, so an author iterating on a surface does not have to zip and upload
|
|
// after every edit.
|
|
//
|
|
// It produces an ordinary immutable version rather than a live directory the
|
|
// panel reads at render time. A second render path would mean "is the code
|
|
// frozen?" depended on how the plugin was installed, which is exactly the
|
|
// ambiguity this change removes. Re-publishing an unchanged version string is
|
|
// what a dev loop does constantly, so those get a `+dev.N` build suffix instead
|
|
// of a conflict.
|
|
func (s *PluginService) PublishLocalBundle(ctx context.Context, workspaceID, userID pgtype.UUID, name string) (PluginPackageSummary, error) {
|
|
if s.LocalDir == "" {
|
|
return PluginPackageSummary{}, pluginErrf(PluginErrorInvalid, "local plugin sources require MULTICA_PLUGIN_DIR")
|
|
}
|
|
if name == "" || strings.ContainsAny(name, `/\`) || strings.HasPrefix(name, ".") {
|
|
return PluginPackageSummary{}, pluginErrf(PluginErrorInvalid, "local plugin source must be a single directory name under MULTICA_PLUGIN_DIR")
|
|
}
|
|
bundle, err := plugincontract.ParseBundleFromDir(func(entry string) ([]byte, bool, error) {
|
|
content, readErr := s.readLocalFile(name, entry)
|
|
var pluginErr *PluginError
|
|
if errors.As(readErr, &pluginErr) && pluginErr.Kind == PluginErrorNotFound {
|
|
return nil, false, nil
|
|
}
|
|
if readErr != nil {
|
|
return nil, false, readErr
|
|
}
|
|
return content, true, nil
|
|
})
|
|
if err != nil {
|
|
return PluginPackageSummary{}, pluginErrf(PluginErrorInvalid, "local plugin package is invalid: %v", err)
|
|
}
|
|
return s.publish(ctx, workspaceID, userID, bundle, true)
|
|
}
|
|
|
|
// publish writes the package row, the version and its files in ONE transaction,
|
|
// under the (workspace, plugin key) lock.
|
|
//
|
|
// All three parts are in the same transaction for the same reason: any partial
|
|
// result is a lie the version list cannot show. A version whose files half
|
|
// landed is a panel that loads nothing; a package row whose display name was
|
|
// updated by a publish that then conflicted claims to describe a version that
|
|
// was never stored; and a first publish that failed after creating the package
|
|
// leaves a plugin with zero versions in the list.
|
|
func (s *PluginService) publish(ctx context.Context, workspaceID, userID pgtype.UUID, bundle plugincontract.Bundle, devLoop bool) (PluginPackageSummary, error) {
|
|
if err := bundle.Manifest.CheckCapabilities(s.Host); err != nil {
|
|
return PluginPackageSummary{}, &PluginError{Kind: PluginErrorIncompatible, Message: capabilityMessage(err), Err: err}
|
|
}
|
|
|
|
tx, err := s.TxStarter.Begin(ctx)
|
|
if err != nil {
|
|
return PluginPackageSummary{}, &PluginError{Kind: PluginErrorUnavailable, Message: "begin publish", Err: err}
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
queries := s.Queries.WithTx(tx)
|
|
|
|
if err := lockPluginPackageKey(ctx, queries, workspaceID, bundle.Manifest.Key); err != nil {
|
|
return PluginPackageSummary{}, err
|
|
}
|
|
pkg, err := upsertPackage(ctx, queries, workspaceID, userID, bundle.Manifest)
|
|
if err != nil {
|
|
return PluginPackageSummary{}, err
|
|
}
|
|
existing, err := queries.ListPluginPackageVersions(ctx, pkg.ID)
|
|
if err != nil {
|
|
return PluginPackageSummary{}, &PluginError{Kind: PluginErrorUnavailable, Message: "list published versions", Err: err}
|
|
}
|
|
version, err := resolvePublishVersion(bundle.Manifest.Version, existing, devLoop)
|
|
if err != nil {
|
|
return PluginPackageSummary{}, err
|
|
}
|
|
|
|
row, err := queries.CreatePluginPackageVersion(ctx, db.CreatePluginPackageVersionParams{
|
|
PackageID: pkg.ID,
|
|
WorkspaceID: workspaceID,
|
|
Version: version,
|
|
Manifest: bundle.Canonical,
|
|
Digest: BundleDigest(bundle),
|
|
SizeBytes: int64(bundle.TotalSize()),
|
|
PublishedBy: userID,
|
|
})
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return PluginPackageSummary{}, pluginErrf(PluginErrorConflict, "version %s of this plugin is already published; published versions are immutable, so publish a new version instead", version)
|
|
}
|
|
return PluginPackageSummary{}, &PluginError{Kind: PluginErrorUnavailable, Message: "publish plugin version", Err: err}
|
|
}
|
|
for _, file := range bundle.Files {
|
|
sum := sha256.Sum256(file.Content)
|
|
if err := queries.CreatePluginPackageFile(ctx, db.CreatePluginPackageFileParams{
|
|
VersionID: row.ID,
|
|
Path: file.Path,
|
|
Content: file.Content,
|
|
SizeBytes: int64(len(file.Content)),
|
|
Sha256: hex.EncodeToString(sum[:]),
|
|
}); err != nil {
|
|
return PluginPackageSummary{}, &PluginError{Kind: PluginErrorUnavailable, Message: "store plugin package file", Err: err}
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return PluginPackageSummary{}, &PluginError{Kind: PluginErrorUnavailable, Message: "commit publish", Err: err}
|
|
}
|
|
return s.packageSummary(ctx, workspaceID, pkg)
|
|
}
|
|
|
|
// BundleDigest hashes the bundle's contents: the canonical manifest, then each
|
|
// file's path and bytes in sorted order.
|
|
//
|
|
// Deliberately not a hash of the uploaded archive. Two zips of identical files
|
|
// differ in timestamps and compression, and the question the digest answers is
|
|
// "are we looking at the same plugin", not "was this the same upload".
|
|
func BundleDigest(bundle plugincontract.Bundle) string {
|
|
hash := sha256.New()
|
|
hash.Write(bundle.Canonical)
|
|
for _, file := range bundle.Files {
|
|
fmt.Fprintf(hash, "\n%s\n%d\n", file.Path, len(file.Content))
|
|
hash.Write(file.Content)
|
|
}
|
|
return hex.EncodeToString(hash.Sum(nil))
|
|
}
|
|
|
|
// resolvePublishVersion decides what version string this publish lands under.
|
|
//
|
|
// An upload keeps the manifest's version and collides loudly if it is taken —
|
|
// that collision IS the immutability guarantee, and an author who meant to
|
|
// change something needs to say so in the manifest. A local development publish
|
|
// takes `+dev.N` instead, because re-publishing an unchanged version is what
|
|
// editing a file and reloading looks like.
|
|
func resolvePublishVersion(version string, existing []db.PluginPackageVersion, devLoop bool) (string, error) {
|
|
if !devLoop {
|
|
return version, nil
|
|
}
|
|
taken := map[string]bool{}
|
|
highest := 0
|
|
for _, row := range existing {
|
|
taken[row.Version] = true
|
|
if suffix, ok := strings.CutPrefix(row.Version, version+"+dev."); ok {
|
|
if number, err := strconv.Atoi(suffix); err == nil && number > highest {
|
|
highest = number
|
|
}
|
|
}
|
|
}
|
|
if !taken[version] {
|
|
return version, nil
|
|
}
|
|
candidate := version + "+dev." + strconv.Itoa(highest+1)
|
|
if len(candidate) > plugincontract.MaxVersionLength {
|
|
return "", pluginErrf(PluginErrorInvalid, "version %q leaves no room for a development suffix; shorten it below %d bytes", version, plugincontract.MaxVersionLength)
|
|
}
|
|
return candidate, nil
|
|
}
|
|
|
|
// lockPluginPackageKey takes the transaction-scoped lock that serializes
|
|
// publish, install and delete for one plugin. Every caller must already be in a
|
|
// transaction — the lock is released by its commit or rollback.
|
|
func lockPluginPackageKey(ctx context.Context, queries *db.Queries, workspaceID pgtype.UUID, pluginKey string) error {
|
|
if err := queries.LockPluginPackageKey(ctx, uuidString(workspaceID)+":"+pluginKey); err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "lock plugin package", Err: err}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// upsertPackage takes `queries` rather than reading s.Queries so it runs inside
|
|
// its caller's transaction: the display name must move only if the version that
|
|
// carries it is actually stored.
|
|
func upsertPackage(ctx context.Context, queries *db.Queries, workspaceID, userID pgtype.UUID, manifest plugincontract.Manifest) (db.PluginPackage, error) {
|
|
existing, err := queries.GetWorkspacePluginPackageByKey(ctx, db.GetWorkspacePluginPackageByKeyParams{
|
|
WorkspaceID: workspaceID,
|
|
PluginKey: manifest.Key,
|
|
})
|
|
if err == nil {
|
|
if existing.Name == manifest.Name {
|
|
return existing, nil
|
|
}
|
|
// The display name follows the newest publish; the key does not move.
|
|
updated, updateErr := queries.UpdatePluginPackageName(ctx, db.UpdatePluginPackageNameParams{
|
|
ID: existing.ID,
|
|
Name: manifest.Name,
|
|
})
|
|
if updateErr != nil {
|
|
return db.PluginPackage{}, &PluginError{Kind: PluginErrorUnavailable, Message: "update plugin package", Err: updateErr}
|
|
}
|
|
return updated, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return db.PluginPackage{}, &PluginError{Kind: PluginErrorUnavailable, Message: "load plugin package", Err: err}
|
|
}
|
|
created, err := queries.CreatePluginPackage(ctx, db.CreatePluginPackageParams{
|
|
WorkspaceID: workspaceID,
|
|
PluginKey: manifest.Key,
|
|
Name: manifest.Name,
|
|
CreatedBy: userID,
|
|
})
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
// Belt and braces behind the lock: a publish racing one in another
|
|
// process that has not yet taken it still loses the index cleanly.
|
|
return db.PluginPackage{}, pluginErrf(PluginErrorConflict, "this plugin was just published by someone else; try again")
|
|
}
|
|
return db.PluginPackage{}, &PluginError{Kind: PluginErrorUnavailable, Message: "create plugin package", Err: err}
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
// ListPackages returns everything published in this workspace with its versions.
|
|
func (s *PluginService) ListPackages(ctx context.Context, workspaceID pgtype.UUID) ([]PluginPackageSummary, error) {
|
|
packages, err := s.Queries.ListWorkspacePluginPackages(ctx, workspaceID)
|
|
if err != nil {
|
|
return nil, &PluginError{Kind: PluginErrorUnavailable, Message: "list plugin packages", Err: err}
|
|
}
|
|
summaries := make([]PluginPackageSummary, 0, len(packages))
|
|
for _, pkg := range packages {
|
|
summary, err := s.packageSummary(ctx, workspaceID, pkg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
summaries = append(summaries, summary)
|
|
}
|
|
return summaries, nil
|
|
}
|
|
|
|
func (s *PluginService) packageSummary(ctx context.Context, workspaceID pgtype.UUID, pkg db.PluginPackage) (PluginPackageSummary, error) {
|
|
versions, err := s.Queries.ListPluginPackageVersions(ctx, pkg.ID)
|
|
if err != nil {
|
|
return PluginPackageSummary{}, &PluginError{Kind: PluginErrorUnavailable, Message: "list published versions", Err: err}
|
|
}
|
|
// Which version this workspace runs, if any. Read from the installation
|
|
// rather than "the newest one": the two disagreeing is the normal state
|
|
// after a publish, and hiding that would defeat the point.
|
|
installedVersionID := ""
|
|
installation, err := s.Queries.GetWorkspacePluginInstallationByKey(ctx, db.GetWorkspacePluginInstallationByKeyParams{
|
|
WorkspaceID: workspaceID,
|
|
PluginKey: pkg.PluginKey,
|
|
})
|
|
if err == nil {
|
|
installedVersionID = uuidString(installation.PackageVersionID)
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
return PluginPackageSummary{}, &PluginError{Kind: PluginErrorUnavailable, Message: "load plugin installation", Err: err}
|
|
}
|
|
|
|
rendered := make([]PluginPackageVersionSummary, 0, len(versions))
|
|
for _, version := range versions {
|
|
id := uuidString(version.ID)
|
|
rendered = append(rendered, PluginPackageVersionSummary{
|
|
ID: id,
|
|
Version: version.Version,
|
|
Digest: version.Digest,
|
|
SizeBytes: version.SizeBytes,
|
|
PublishedAt: version.CreatedAt.Time.UTC().Format(pluginTimeFormat),
|
|
Installed: id != "" && id == installedVersionID,
|
|
})
|
|
}
|
|
return PluginPackageSummary{
|
|
ID: uuidString(pkg.ID),
|
|
PluginKey: pkg.PluginKey,
|
|
Name: pkg.Name,
|
|
Versions: rendered,
|
|
CreatedAt: pkg.CreatedAt.Time.UTC().Format(pluginTimeFormat),
|
|
}, nil
|
|
}
|
|
|
|
// DeletePackage removes a package with every version and file it published.
|
|
//
|
|
// Refused while any installation still runs one of those versions: deleting them
|
|
// would leave an installed panel with no code to load, and an installation is
|
|
// the administrator's decision to unwind, not the publisher's.
|
|
func (s *PluginService) DeletePackage(ctx context.Context, workspaceID pgtype.UUID, packageID string) error {
|
|
parsed, err := util.ParseUUID(packageID)
|
|
if err != nil {
|
|
return pluginErrf(PluginErrorNotFound, "plugin package not found")
|
|
}
|
|
// Read once outside the transaction only to learn the plugin key the lock is
|
|
// taken on. Everything the decision rests on is re-read inside it.
|
|
pkg, err := s.Queries.GetWorkspacePluginPackage(ctx, db.GetWorkspacePluginPackageParams{
|
|
WorkspaceID: workspaceID,
|
|
ID: parsed,
|
|
})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return pluginErrf(PluginErrorNotFound, "plugin package not found")
|
|
}
|
|
if err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "load plugin package", Err: err}
|
|
}
|
|
|
|
tx, err := s.TxStarter.Begin(ctx)
|
|
if err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "begin delete", Err: err}
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
queries := s.Queries.WithTx(tx)
|
|
|
|
if err := lockPluginPackageKey(ctx, queries, workspaceID, pkg.PluginKey); err != nil {
|
|
return err
|
|
}
|
|
// Counted INSIDE the lock, not before it. Counting outside would let an
|
|
// install that started after the count commit after the delete, leaving an
|
|
// installation whose version is gone.
|
|
installed, err := queries.CountInstallationsOfPackageVersions(ctx, pkg.ID)
|
|
if err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "count installations", Err: err}
|
|
}
|
|
if installed > 0 {
|
|
return pluginErrf(PluginErrorConflict, "this plugin is still installed; uninstall it before deleting the published package")
|
|
}
|
|
|
|
// Files first: they name a version that is about to stop existing, and there
|
|
// are no cascades by repository policy.
|
|
if err := queries.DeletePluginPackageFilesByPackage(ctx, pkg.ID); err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin package files", Err: err}
|
|
}
|
|
if err := queries.DeletePluginPackageVersionsByPackage(ctx, pkg.ID); err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin package versions", Err: err}
|
|
}
|
|
if err := queries.DeletePluginPackage(ctx, pkg.ID); err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "delete plugin package", Err: err}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "commit delete", Err: err}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// requireVersionStillPublished re-reads a version inside the caller's locked
|
|
// transaction.
|
|
//
|
|
// Install validates the version, its manifest and its scopes before opening a
|
|
// transaction — none of that can be done under a lock without holding it across
|
|
// work that does not need it. So the version is confirmed once more after the
|
|
// lock is held, which is the point at which a concurrent delete can no longer
|
|
// slip in between.
|
|
func requireVersionStillPublished(ctx context.Context, queries *db.Queries, workspaceID, versionID pgtype.UUID) error {
|
|
_, err := queries.GetWorkspacePluginPackageVersion(ctx, db.GetWorkspacePluginPackageVersionParams{
|
|
WorkspaceID: workspaceID,
|
|
ID: versionID,
|
|
})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return pluginErrf(PluginErrorConflict, "this version was deleted while the install was being confirmed; publish or pick another version")
|
|
}
|
|
if err != nil {
|
|
return &PluginError{Kind: PluginErrorUnavailable, Message: "re-read published plugin version", Err: err}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// VersionForWorkspace loads a published version and confirms it belongs to the
|
|
// workspace in the URL. Publishing is workspace-private, so a version id from
|
|
// another workspace must not be installable here.
|
|
func (s *PluginService) VersionForWorkspace(ctx context.Context, workspaceID pgtype.UUID, versionID string) (db.PluginPackageVersion, error) {
|
|
parsed, err := util.ParseUUID(strings.TrimSpace(versionID))
|
|
if err != nil {
|
|
return db.PluginPackageVersion{}, pluginErrf(PluginErrorNotFound, "published plugin version not found")
|
|
}
|
|
version, err := s.Queries.GetWorkspacePluginPackageVersion(ctx, db.GetWorkspacePluginPackageVersionParams{
|
|
WorkspaceID: workspaceID,
|
|
ID: parsed,
|
|
})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return db.PluginPackageVersion{}, pluginErrf(PluginErrorNotFound, "published plugin version not found")
|
|
}
|
|
if err != nil {
|
|
return db.PluginPackageVersion{}, &PluginError{Kind: PluginErrorUnavailable, Message: "load published plugin version", Err: err}
|
|
}
|
|
return version, nil
|
|
}
|
|
|
|
// PluginSurfaceScript is the code one surface runs, plus the digest of the
|
|
// version it came from.
|
|
type PluginSurfaceScript struct {
|
|
Code string `json:"code"`
|
|
Version string `json:"version"`
|
|
Digest string `json:"digest"`
|
|
}
|
|
|
|
// SurfaceScript returns the script for one surface of an installation.
|
|
//
|
|
// Read from the installed version's stored files, never fetched: this is the
|
|
// method that makes "the administrator consented to this code" true rather than
|
|
// aspirational. The manifest consulted is the installation's own snapshot, so a
|
|
// surface the admin never approved cannot be named here even if a later version
|
|
// declares one.
|
|
func (s *PluginService) SurfaceScript(ctx context.Context, installation db.PluginInstallation, surfaceKey string) (PluginSurfaceScript, error) {
|
|
manifest, err := ParseInstallationManifest(installation)
|
|
if err != nil {
|
|
return PluginSurfaceScript{}, &PluginError{Kind: PluginErrorInvalid, Message: "stored plugin manifest is unreadable", Err: err}
|
|
}
|
|
entry := ""
|
|
for _, surface := range manifest.Contributes.Surfaces {
|
|
if surface.Key == surfaceKey {
|
|
entry = surface.Entry
|
|
break
|
|
}
|
|
}
|
|
if entry == "" {
|
|
return PluginSurfaceScript{}, pluginErrf(PluginErrorNotFound, "this Plugin does not contribute a surface named %q", surfaceKey)
|
|
}
|
|
file, err := s.Queries.GetPluginPackageFile(ctx, db.GetPluginPackageFileParams{
|
|
VersionID: installation.PackageVersionID,
|
|
Path: entry,
|
|
})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return PluginSurfaceScript{}, pluginErrf(PluginErrorNotFound, "the installed version does not contain %q", entry)
|
|
}
|
|
if err != nil {
|
|
return PluginSurfaceScript{}, &PluginError{Kind: PluginErrorUnavailable, Message: "read plugin surface", Err: err}
|
|
}
|
|
return PluginSurfaceScript{
|
|
Code: string(file.Content),
|
|
Version: installation.Version,
|
|
Digest: file.Sha256,
|
|
}, nil
|
|
}
|
|
|
|
// packageFile reads one file of a published version, for install-time work such
|
|
// as skill resources.
|
|
func (s *PluginService) packageFile(ctx context.Context, queries *db.Queries, versionID pgtype.UUID, entry string) ([]byte, error) {
|
|
file, err := queries.GetPluginPackageFile(ctx, db.GetPluginPackageFileParams{
|
|
VersionID: versionID,
|
|
Path: entry,
|
|
})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, pluginErrf(PluginErrorInvalid, "the published version does not contain %q", entry)
|
|
}
|
|
if err != nil {
|
|
return nil, &PluginError{Kind: PluginErrorUnavailable, Message: "read plugin package file", Err: err}
|
|
}
|
|
return file.Content, nil
|
|
}
|
|
|
|
// readLocalFile reads a file from inside a local plugin directory.
|
|
//
|
|
// `entry` is validated by the contract package to be relative and
|
|
// traversal-free, but this is the layer that touches the filesystem and it does
|
|
// not get to assume that: the joined path is re-checked against the plugin's own
|
|
// directory after cleaning.
|
|
func (s *PluginService) readLocalFile(name, entry string) ([]byte, error) {
|
|
if s.LocalDir == "" {
|
|
return nil, pluginErrf(PluginErrorInvalid, "local plugin sources require MULTICA_PLUGIN_DIR")
|
|
}
|
|
if name == "" || strings.ContainsAny(name, `/\`) || strings.HasPrefix(name, ".") {
|
|
return nil, pluginErrf(PluginErrorInvalid, "local plugin source must be a single directory name under MULTICA_PLUGIN_DIR")
|
|
}
|
|
root := filepath.Join(s.LocalDir, name)
|
|
path := filepath.Clean(filepath.Join(root, entry))
|
|
if path != root && !strings.HasPrefix(path, root+string(os.PathSeparator)) {
|
|
return nil, pluginErrf(PluginErrorInvalid, "plugin resource %q escapes its directory", entry)
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, pluginErrf(PluginErrorNotFound, "plugin resource %q was not found", entry)
|
|
}
|
|
if err != nil {
|
|
return nil, &PluginError{Kind: PluginErrorInvalid, Message: "read plugin resource", Err: err}
|
|
}
|
|
return raw, nil
|
|
}
|