All docs

Plugin docs

Publishing to a plugin index

The plugin index behind Browse: its format, SHA-256 pinning, and hosting your own.

Plugins → Browse lists the plugins in a plugin index: one static JSON file. Cuttle reads it from CUTTLE_PLUGIN_INDEX_URL; unset, it lists the built-in catalog, examples/plugins/index.json shipped with the build (the source checkout and the desktop payload both carry it), read from disk. There is no hosted marketplace yet; any HTTPS host that serves the file works, so an organization can run its own.

The built-in catalog lists the two example plugins (examples/plugins/), with downloadUrls pointing at the packages in examples/plugins/dist/ as raw GitHub files of the Cuttle repository. Those URLs are the recorded origin (plugin sync downloads from it on other devices), but whenever a package named by a download URL ships in the build's examples/plugins/dist/ and hashes to the expected SHA-256, Cuttle uses that file instead of the network, so the catalog works while the repository is private and offline.

The index file

{
  "format": "cuttle.plugins.index",
  "version": 1,
  "plugins": [{
    "id": "acme.automation-suite",           // publisher.name, as in the manifest
    "version": "1.2.0",
    "displayName": "Automation Suite",
    "description": "Curated automations for repository hygiene",
    "publisher": "acme",
    "downloadUrl": "https://example.com/automation-suite-1.2.0.cuttleplugin",
    "sha256": "<sha256 of the .cuttleplugin bytes>",   // `cuttle plugins pack` prints it
    "size": 48213,                                       // bytes
    "engines": { "cuttle": "^1.0.0" },
    "permissionsSummary": ["automations.contribute", "sessions.spawn", "webhooks.receive"]
  }]
}
  • A malformed entry is skipped (Browse says how many); the rest still show. One entry per plugin id: the highest version wins.
  • permissionsSummary is only a preview on the card. The install sheet always shows the permissions the downloaded package declares.

What Cuttle checks

  • The index and every download go through the server's SSRF guard (safeFetch): loopback and private addresses are refused unless listed in SAFE_FETCH_ALLOWLIST (host names or CIDRs) — set it for an index on your LAN.
  • The SHA-256 is the pin. Installing downloads downloadUrl (at most the 20 MB package limit) and refuses it, storing nothing, unless it hashes to the index's sha256 (409 package_tampered). The client only names a plugin id; the URL and hash always come from the index the server fetched.
  • The package then goes through the same import checks as a file, and keeps index:<downloadUrl> as its origin: your other devices (plugin sync) and a plugin set import download it from there, under the same SHA-256 check.
  • The index is cached for an hour (?refresh=1 / Refresh fetches again). When it can't be reached Browse shows the last copy marked stale, or a banner with Retry.
  • Every package is still an Unverified publisher: the index says which bytes to expect, not who made them (Signing).

Publishing a plugin

  1. Pack it: cuttle plugins pack <dir> writes <name>-<version>.cuttleplugin and prints its size and SHA-256 (the same reader the server uses, so a package that packs imports).
  2. Put the file anywhere the server can download over HTTPS — a GitHub release asset, object storage, your own web server.
  3. Add or update its entry in the index: downloadUrl, sha256 and size from step 1, and id, version, displayName, description, publisher, engines copied from the manifest. A new version replaces the entry (one per plugin id); a changed file needs a new sha256, or every install refuses it as tampered.
  4. Installed copies are not updated by the index. People update by installing the new version from Browse (or importing the file), which goes through the usual update rules: nothing new asked → applied; more asked → waits for their review.

The example plugins follow this with packages committed under examples/plugins/dist/; services/plugins/examples.test.ts fails when a package, its source or its index entry disagree, and prints the command that re-packs it.

Signing

Not in v1. A SHA-256 pins which bytes an index, a synced install or an export expects, so a package altered in transit or on the host is refused, but nothing proves who published it: every plugin shows Unverified publisher, and the install sheet's permission list is the protection.

Proposed direction (not built; it will change as the marketplace is designed):

  • Publishers register a signing key; a package carries a detached signature over its SHA-256 and manifest id.
  • Cuttle verifies the signature at import against the publisher's key, and the badge becomes the verified publisher's name. An index entry can then name the publisher's key instead of trusting the index host.
  • A plugin id stays bound to the key that first published it, so an update signed by another key is refused.
  • Unsigned packages keep importing as today, still marked unverified; an admin policy can refuse them.

API and CLI

GET /api/plugins/index[?refresh=1]{ url, fetchedAt, stale, error, invalid, plugins[] }, each with compatible and installed
POST /api/plugins/index/:pluginId/importdownload + SHA-256 check + import; answers the install preview like POST /api/plugins/packages
cuttle plugins browse [--refresh]the list
cuttle plugins install <id> --from-index [--yes-required]install from the index