Skip to content

Third-party plugin marketplaces - #1582

Open
SawyerHood wants to merge 31 commits into
marketplace-git-tag-semverfrom
marketplace-third-party
Open

Third-party plugin marketplaces#1582
SawyerHood wants to merge 31 commits into
marketplace-git-tag-semverfrom
marketplace-third-party

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Top layer (4/4) of the plugin marketplace stack. Prerequisite: #1581 (git tag semver).

Summary

  • Adds the marketplace registry: bb marketplace add | list | refresh | remove over new validated routes, accepting an https manifest URL, git:<url>[@ref], or path:<dir>. The manifest's own name is its identity; collisions and the reserved bb-official are refused; identity drift on refresh is rejected. Adding installs nothing.
  • Install routing: bb plugin install <entry>@<marketplace>; a bare id resolves across marketplaces (one match installs, several fail listing qualified choices), with every existing source form untouched.
  • Trust UX: a new install-plan endpoint reports the true resolved source (npm package/range/registry, or git url/ref/range/subdir plus the exact tag and commit) before anything runs; the CLI prompt and app dialog render it, and the server verifies the approved facts — including the git commit — at install time.
  • Server-side fetch policy for third-party content: HTTPS on 443 only, public DNS results, no credentials, no redirects; applied to manifests and icons. Local marketplaces use real-path containment; git marketplaces stage into a throwaway checkout that is cleaned up on the next operation after a crash.
  • Removal disposition: one transaction converts installed plugins to direct provenance retaining full source intent and exact resolution — update checks keep working afterward — then deletes catalog rows and icons. Re-adding the same marketplace works.
  • Browse groups by marketplace (official first) with tag-derived sections; cards show authors and, for third-party entries, their marketplace. Settings gains a Plugin marketplaces section mirroring the CLI. SDK (plugins.marketplaces.*, catalog.installPlan), guide, and skills updated in the same change.

Validation

Standalone green: workspace typecheck and lint; server/app/cli/db/integration suites (known umask failure aside). End-to-end tests cover the add → install-with-plan → range update → moved-tag refusal → removal → still-updates-as-direct lifecycle, icon isolation, SSRF policy, and re-add.

No server↔daemon wire shape changes anywhere in the stack; protocol version 119 is unchanged deliberately.

AGENT GENERATED: by Claude Fable 5

@SawyerHood SawyerHood changed the title marketplace third party Third-party plugin marketplaces Aug 14, 2026
@SawyerHood
SawyerHood force-pushed the marketplace-third-party branch from b116566 to a88a380 Compare August 14, 2026 04:34
@SawyerHood
SawyerHood marked this pull request as ready for review August 14, 2026 05:29
@bb-slop-cop

bb-slop-cop Bot commented Aug 14, 2026

Copy link
Copy Markdown

🚨 SLOP COP 🚨 · review

I am SlopCop. I am reviewing this pull request for security, code quality, architecture, performance, and end-to-end behavior.

I will post one final review after the parallel checks finish.

<ResourceBrowseGrid className="grid-cols-[repeat(auto-fill,minmax(min(100%,18rem),1fr))] gap-2">
{section.entries.map((entry) => (
<BrowseCard
key={`${entry.marketplace}/${entry.entryId}`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Medium: The new card key keeps marketplace identity only inside React.

The open callback and detail route still use only pluginId. The server also marks each same-ID listing as installed.

Two marketplaces can list the same ID. One card can then open the wrong listing or offer removal for another source.

Carry the marketplace and entry ID through the route and detail lookup. Show a separate occupied-ID state for another source.

fetch: fetchMarketplace,
});
try {
const name = materialized.catalog.name;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Medium: Addition accepts a name that later routes reject.

The manifest name has no 64-character limit. The public SDK and route contract has that limit.

BB can add a marketplace that supported commands cannot refresh or remove by name.

Use the shared pluginMarketplaceNameSchema when the manifest enters the system. Remove the duplicate name rule.

args.icon.path,
`entry "${args.entryId}" icon`,
);
const bytes = new Uint8Array(await readFile(path));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — High: The icon size check occurs after the full file read.

readFile loads all bytes before the 256 KiB validator runs. A Git or path marketplace can point to a very large local file.

Open the file once. Read at most the limit plus one byte. Then validate the same opened file.

db: deps.db,
marketplaceName: name,
base: materialized.iconBase,
entries: materialized.catalog.plugins,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — High: A marketplace can start unbounded serial icon work.

The manifest has a 1 MiB JSON limit, but it has no plugin count or total icon byte limit.

The icon loader processes each entry in series. Thousands of entries can hold a refresh for hours and store large BLOB data.

Add an entry limit and an aggregate icon byte limit. Add one overall refresh deadline.

icon: entryIconName(entry),
iconUrl: entryIconUrl(entry.id),
category: entryCategory(entry),
iconUrl: entryIconUrl(row.name, entry.id),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Medium: Catalog search performs BLOB and installed-state queries for each entry.

entryIconUrl reads the full icon row, including its bytes. The result builder also queries the installed plugin table for each entry.

A large catalog causes thousands of synchronous SQLite queries and reads unused BLOB data.

Load icon hashes and installed identities with bulk queries. Do not select icon bytes for search results.

},

async install(input) {
const resolved = resolveEntry(input);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — High: Installation does not use the marketplace lock.

Refresh and removal use a lock for the marketplace name. Installation resolves the row and entry without that lock.

Removal can delete the row before installation writes catalog provenance. Refresh can also change the source during this operation.

Use the same name lock for install, refresh, removal, and same-name addition. Resolve the entry again inside the lock.

"install refused: the marketplace source changed after confirmation; review it again",
);
}
if (current.kind === "npm") return undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — High: The npm confirmation does not identify exact code.

The confirmation contains only the package, range or tag, and registry. This branch accepts that data without an exact version or integrity value.

A mutable tag or range can select different full-trust code after the user confirms it.

Resolve the exact version and integrity before confirmation. Require the installer to use those exact values.

});
replacePluginMarketplaceIcons(tx, name, icons);
});
deps.notifyCatalogChanged?.();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Medium: Marketplace notifications leave the marketplace list stale.

This new mutation sends the existing plugins-changed event. Its frontend handler invalidates catalog search, but not the marketplace list query.

A CLI command or another window can leave Settings with old counts, times, and errors.

Add the marketplace list key to the realtime invalidation set. Add a cache invalidation test.

...(entry.engines === undefined ? {} : { engines: entry.engines }),
...(resolved.npmRegistry === undefined
? {}
: { npmRegistry: resolved.npmRegistry }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — High: A marketplace can select an unprotected npm registry.

This value comes from an untrusted marketplace manifest. The install path gives it to the npm resolver without the marketplace network controls.

The registry response can also select a tarball URL. Either request can reach a private host through DNS or a redirect.

Use the guarded downloader for registry and tarball requests. Add redirect, time, and byte limits.

"clone",
"--quiet",
"--no-checkout",
source.url,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — High: Git marketplace clones bypass the protected network path.

The parser accepts HTTP URLs, private hosts, and embedded credentials. This call gives the URL directly to git clone.

Git uses its own DNS and redirect path. Thus, publicMarketplaceFetch cannot protect this request.

The clone also downloads unrestricted history. A refresh can contact private services or fill the server disk.

Require public HTTPS without credentials. Apply DNS and redirect checks. Use a shallow fetch and a checkout size limit.

@bb-slop-cop bb-slop-cop Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 SLOP COP 🚨 · review

Plain-English summary

This change lets BB add external plugin catalogs from HTTPS, Git, or a local path.

It adds marketplace controls to Settings, plugin discovery, installation confirmation, the CLI, the SDK, and the database.

Adding a marketplace only stores its catalog. It does not install or run a plugin.

Findings

I found six high-risk defects and four medium-risk defects.

  • High: Git catalogs bypass the new network guard and can clone unlimited repository data.
  • High: Custom npm registries and tarball locations bypass the same guard.
  • High: Npm confirmation does not bind the exact version and integrity.
  • High: Local icon files load before the size check.
  • High: A catalog can start unbounded serial icon work and store unbounded icon data.
  • High: Install can race with marketplace refresh or removal.
  • Medium: A long manifest name creates a marketplace that public routes cannot manage.
  • Medium: Catalog search runs queries for each entry and reads unused icon BLOB data.
  • Medium: Duplicate plugin IDs lose marketplace identity in the detail and removal flows.
  • Medium: Other clients can leave the marketplace list stale after a change.

The Browse view also renders every catalog card at once. Large catalogs need pagination or a windowed list.

The CLI help still calls all catalog entries official. That text now gives incorrect trust information.

Architecture and duplicate logic

The code now has two marketplace name rules. The manifest rule lacks the shared contract's length limit.

The network trust policy also has separate paths. HTTPS catalogs use the guard, but Git and npm do not.

Catalog search repeats icon and installed-state queries. Bulk queries can replace this duplicate work.

Checks

  • GitHub CI passed on the reviewed SHA.
  • All 86 focused marketplace server tests passed.
  • All 80 focused app tests passed.
  • All 25 focused CLI tests passed.
  • Type checks passed for six affected packages.
  • Browser QA passed add, browse, source disclosure, cancel, and remove.
  • The dev CLI listed the added marketplace correctly.
  • A larger local server selection passed 155 of 158 tests.

Two update tests exceeded their five-second limit under parallel load. One test found the macOS /tmp path alias.

I posted this as a comment-only review. I did not approve the pull request or use the request-changes option.

@SawyerHood
SawyerHood force-pushed the marketplace-third-party branch 2 times, most recently from b26c5f6 to 1b150a4 Compare August 14, 2026 06:55
@SawyerHood
SawyerHood force-pushed the marketplace-third-party branch from 02d86d4 to 11fb888 Compare August 14, 2026 14:17
@SawyerHood
SawyerHood force-pushed the marketplace-third-party branch 2 times, most recently from baec3b5 to 9d8d99a Compare August 14, 2026 16:33
SawyerHood and others added 4 commits August 14, 2026 16:43
Add, list, refresh, and remove the marketplaces bb reads plugin catalogs
from. A marketplace stays a discovery layer: adding one validates and
caches its catalog and installs nothing, removing one keeps its installed
plugins running as direct installs with their source intent and exact
resolution intact.

The Phase 1 refresh and icon machinery now runs per marketplace row
instead of only for bb-official, and reads a manifest over HTTPS, from a
git checkout, or from a directory. git checkouts are staged, read, and
deleted: the manifest and the validated icon bytes are what bb keeps.

Install routing gains `<entry-id>@<marketplace>`; a bare entry id
installs the single marketplace match, falls back to the bundled official
plugin of that name, or is refused with the `id@marketplace` choices.
The new install-plan route reports the true resolved source — npm
package and range, or git url, ref/range, subdir, and the tag and commit
a range currently lands on — so the CLI prompt and the app dialog confirm
against what will actually be fetched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cover `bb marketplace add|list|refresh|remove`, `<id>@<marketplace>`
install routing, the resolved-source confirmation, and the removal
disposition in the plugins guide chapter, the bb-cli skill, the
bb-plugin-authoring skill, and docs/configuration.md. The guide-coverage
test now asserts every `bb marketplace` subcommand is documented too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add a server suite over in-memory SQLite with a fake fetch and real git
fixtures: add/refresh/remove for git and path marketplaces, the name
collision and reserved-name refusals, ambiguous bare-install resolution,
the removal disposition (provenance becomes direct while every field the
update pipeline reads survives), icon isolation between marketplaces, and
bb-official refreshing normally while a third-party refresh fails.

Cover the route boundary, the CLI confirmation text for each source
variant, the `bb marketplace` command output, the Browse grouping, and
the app's third-party install disclosure. Regenerate the bundled plugin
SDK types for the widened catalog contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The store lists plugins bundled with the app under bb-official, so a
Browse card sends "<id>@BB-official" for them. Resolve that qualified
form to the bundled copy instead of refusing it, and name the listing
marketplace in `bb plugin search` once a third-party entry appears.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SawyerHood and others added 25 commits August 14, 2026 16:43
A git or path marketplace read its manifest fully into memory and checked
the size afterwards. Stat first, so an oversize document is refused
before it is loaded — the same bound an https manifest gets from its
content-length.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The marketplace list and the install plan added two more query keys to the
catalog query module. The plugin cache owner reads the marketplace prefix,
so the keys belong with the other plugin keys in query-keys.ts, where the
boot-path registry can read them without the catalog client.

Boot payload: 1667.7 KB -> 1658.7 KB raw, 437.9 KB -> 435.6 KB brotli.
Category returns to filter-only; entries render as one grid per
marketplace group instead of tag-derived sections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An entry this BB cannot install is noise on an install surface. The
search API keeps returning incompatible entries with reasons for the
CLI status output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The installed corner control reused the Download arrow, which reads as
an available install. A check reads as state; the tooltip names the
uninstall action it opens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The manifest accepted a name with no length limit while the public SDK
and route contract cap it at 64 characters. BB could store a marketplace
that no supported command could refresh or remove by name.

The manifest now parses its own name with pluginMarketplaceNameSchema,
so one rule decides what bb can address, and the published schema
carries the same limit.
Refresh, removal, and same-name addition each took the per-marketplace
lock; installation did not. A removal could delete the row, or a refresh
retarget the entry, between resolving the plan and writing catalog
provenance — and the plugin would trace back to a listing that no longer
said what it said.

Installation now takes the same lock and resolves the entry again inside
it, so it runs against the row that is current under the lock. Its test
lands with the npm binding in the next commit: the four new third-party
cases share one block.
A git entry's confirmation named the exact commit, and the install
refused any other one. The npm side showed only the package, the range
or dist-tag, and the registry. Both of those are mutable, so a listing
could deliver different full-trust code after the user confirmed it.

The install plan now resolves the exact version, and the integrity when
the registry publishes one, through the same registry and the same
selection rules the install uses. A third-party install carries that
pair into the installer, which refuses any drift — symmetric with the
git commit binding.

The four new third-party cases share one block, so this also brings the
tests for the marketplace install lock and the manifest name limit.
The search help called every listing one of "BB's official plugins",
and the install messages called any catalog entry an "official plugin
entry". A host can now add marketplaces BB does not review, so the text
names the source and says which one BB reviews.
main reordered a pending-interaction enum, so this layer's committed
bundled declarations and the template copy no longer matched what the
sources generate.
The published schema and the parser both cap a marketplace name at 64
characters now, so the parity fixture set covers it.
Cards read "By: <author>"; bundled entries attribute to BB Team at the
server boundary so the CLI shows the same. The marketplaces settings
section moves its intro into the section description, groups the add
form with its hint, pads list rows, and renders Official as a badge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the marketplace-third-party branch from 9d8d99a to 17a5bb2 Compare August 14, 2026 16:46
SawyerHood and others added 2 commits August 14, 2026 16:52
A catalog install can come from any marketplace, and the plugin list
does not say which, so labeling every catalog install BB Official was a
wrong trust signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant