Skip to content

feat: per-company plugin enablement - #255

Open
stubbi wants to merge 15 commits into
mainfrom
feat/company-plugin-enablement
Open

feat: per-company plugin enablement#255
stubbi wants to merge 15 commits into
mainfrom
feat/company-plugin-enablement

Conversation

@stubbi

@stubbi stubbi commented Jul 18, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work.
  • Plugins act on behalf of companies (running agent tools, handling company-scoped events, contributing UI), and the schema already has a per-company plugin_company_settings.enabled column for this.
  • That column is dormant: plugin enablement is effectively instance-global today — once an instance admin installs a plugin, every company gets it with no way to opt out, and there is no manifest-level mechanism for a plugin author to require instance-admin control over which companies get it (needed by governance-style plugins like billing).
  • This gap blocks any plugin that needs to be mandatory-but-admin-controlled rather than a company self-service toggle, and leaves company owners with no UI to manage which of the instance's installed plugins their company actually uses.
  • This pull request activates the dormant per-company switch: a manifest companyEnablement declaration (default: "on"|"off", optional locked), a plugins:manage permission, catalog/enablement routes with a write-time lock guarantee, six enforcement points across the plugin-hosting surface, and a company-facing "Plugins" settings page.
  • The benefit is a real two-switch model (instance install × company enablement) that supports both opt-in company plugins and instance-mandated ones, unblocking the standing-gate and billing-plugin work stacked on top of this branch.

Linked Issues or Issue Description

Fixes #258

No pre-existing public issue tracks this; describing in-PR per the feature-request template. This branch deliberately converges with the pre-existing contrib/company-plugin-enablement branch (see CONTRIBUTING.md → search-first); at implementation time we diffed against it and reused matching enforcement wiring and authz tests where they lined up. Refs the earlier branch as related prior art: contrib/company-plugin-enablement.

Problem or motivation
plugin_company_settings.enabled exists in the schema but nothing reads or writes it — plugin enablement is instance-global in practice. There is no way for a company to disable a plugin it doesn't want, and no way for an instance admin to install a plugin that is mandatory for every company but still tracked per-company (e.g. so a later admin override or audit trail is meaningful).

Proposed solution
Add an optional manifest field companyEnablement: { default: "on" | "off", locked?: boolean } (absent ⇒ "on", today's behavior, zero impact on existing plugins). locked: true renders as a non-interactive "Managed by instance" entry — only an instance admin can override it per company. Add GET /plugins/companies/:companyId/catalog and PUT /plugins/:pluginId/companies/:companyId/enablement (409 on a locked plugin), gated by a new plugins:manage permission (implicit for company owner/admin, grantable via principal_permission_grants); catalog reads additionally require the company.plugins surface (PR-1). Centralize the actual enablement check in one helper, isPluginEnabledForCompany(pluginId, companyId), and apply it at all six places a plugin currently acts for a company: host-services company resolution, bridge data/action calls, event-bus delivery, agent-tool dispatch, plugin-scoped API routes, and ui-contributions slot filtering. Ship a company "Plugins" settings page (surface company.plugins) with catalog + toggle + mobile nav entry.

Alternatives considered
Leaving enablement instance-only (status quo) was rejected — it cannot support governance-style plugins (billing) that must be mandatory yet still track state per company, and gives companies no self-service control over optional plugins. A per-route ad hoc check at each of the six call sites (instead of one shared helper) was rejected as a correctness risk — a missed call site would silently leave a "disabled" plugin acting for a company; the single helper plus per-gate enforcement tests close that hole deliberately.

Roadmap alignment
Checked ROADMAP.md — generic core plugin-lifecycle primitive, upstreamable, not overlapping planned core work. Full design at docs/superpowers/specs/2026-07-18-settings-visibility-and-plugin-enablement-design.md §4.

What Changed

  • Manifest schema: companyEnablement?: { default: "on" | "off"; locked?: boolean }, validated with .strict() on the sub-schema.
  • New permission key plugins:manage in PERMISSION_KEYS; implicit for owner/admin company memberships, grantable to other principals.
  • GET /plugins/companies/:companyId/catalog — installed + ready + catalog-eligible plugins with { enabled, locked, defaultEnabled, hasCompanySettingsPage }; sandbox-provider/credential-broker infra plugins stay excluded via existing categories.
  • PUT /plugins/:pluginId/companies/:companyId/enablement{ enabled }; 409 for locked plugins; write-time lock guarantee verified (host-services upsert path cannot silently enable a disabled/locked plugin — see plugin-host-services.ts localFolders.configure call site, confirmed unreachable for gated plugins).
  • isPluginEnabledForCompany(pluginId, companyId) helper in plugin-company-enablement.ts, applied at all six enforcement points: host-services company resolution, bridge data/actions, event-bus delivery, agent-tool dispatch, plugin-scoped API routes (companyResolution), and ui-contributions slot filtering.
  • Company "Plugins" settings page (company.plugins surface): catalog list, toggle, capability summary, link into an enabled plugin's companySettingsPage; mobile CompanySettingsNav entry added (initially missed, caught in review).
  • OpenAPI spec updated for catalog/enablement routes.

Verification

  • cd server && pnpm typecheck — 0 errors.
  • Enablement-helper unit tests: default on/off × existing-row states, full matrix.
  • Authz tests: plugins:manage required for toggle, viewer denied, locked plugin returns 409, catalog read requires company.plugins surface exposure.
  • One enforcement test per gate point (6/6), including the PUT-route ungated-write regression found in final review (host-services localFolders path) and the launchers/PluginPage company-filter gap found in the same pass — both fixed and re-verified.
  • Slot-filtering UI test: disabled-plugin slots never reach a company's ui-contributions.
  • Toggle round-trip and cross-company PUT-isolation tests.
  • 15 commits on feat/company-plugin-enablement, base feat/settings-surface-policy; final whole-branch review closed "with fixes" and re-verified.

Risks

  • Base branch: stacked on feat/settings-surface-policy (PR-1) — this PR only makes sense merged after PR-1, and inherits PR-1's capabilities/surface-gate plumbing. Diff against main will include PR-1's changes until PR-1 merges; review the PR-1-vs-PR-2 diff range, not the full branch diff.
  • Backward compatible by construction: manifest field is optional and absent ⇒ "on", so every existing installed plugin keeps today's instance-global behavior with no data migration.
  • The write-time lock guarantee (a locked plugin can never be silently enabled via a different code path) was the main risk surface here — closed by an explicit gate ordering fix plus a dedicated regression test rather than by inspection alone.
  • Minor known follow-ups, not blocking: duplicated forbidden() throw in plugin-company-enablement.ts (DRY candidate), a redundant getById per gated call (efficiency, not correctness), and a missing toggle-error-path UI test — tracked for follow-up, not this PR's scope.
  • Default-off cron jobs still fire instance-level regardless of company enablement — spec-consistent (jobs are instance infrastructure, not company-scoped actions) but worth reviewer awareness.

Model Used

Claude (Anthropic), Sonnet 5 (model id claude-sonnet-5) driving Claude Code's subagent-driven SDD workflow — spec-driven task briefs, extended multi-step tool use, and an independent code-review pass per task, plus a dedicated whole-branch final review before merge.

Screenshots

CompanyPlugins catalog — locked plugin row

CompanyPlugins catalog (locked plugin row, non-interactive "Managed by instance")

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have not referenced internal/instance-local Paperclip issues or links (only public GitHub #NNN / github.com/paperclipai/paperclip URLs)
  • My branch name describes the change (e.g. docs/..., fix/...) and contains no internal Paperclip ticket id or instance-derived details
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

@stubbi
stubbi changed the base branch from feat/settings-surface-policy to main July 18, 2026 18:44
stubbi and others added 15 commits July 18, 2026 20:45
…ocked)

Per-company plugin enablement (spec 2026-07-18 §4.2): plugins may declare
an opt-in default and a locked flag for instance-managed governance
plugins. Absent field keeps today's default-on behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dmin

Seeded through the existing role default-grant path
(grantsForHumanRole -> ensureHumanRoleDefaultGrants); startup backfill
adds it to existing owner/admin memberships. Grantable to any principal
via principal_permission_grants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Manifest-default-aware (companyEnablement.default) evaluation over
plugin_company_settings rows, typed 403 plugin_not_enabled_for_company,
and a fail-open event-bus deliverability checker. Adapted from
contrib/company-plugin-enablement with the manifest-default delta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Company-scoped events skip subscriptions of plugins disabled for that
company (manifest default aware); lookup failures fail open so events
are never silently dropped. Adapted from contrib/company-plugin-enablement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the documented no-op ensurePluginAvailableForCompany; every
company-scoped host operation now fails closed with the typed 403 when
the plugin is disabled for that company.

Fixture fix beyond the brief: the current Task 3 gate
(pluginCompanyEnablementService) fails closed on an *unknown* plugin id
via registry.getById, unlike the simpler prior-art gate the brief's
contingency note describes (which only consulted plugin_company_settings).
So swapping the "plugin-record-id" literal for a real randomUUID() in
plugin-orchestration-apis.test.ts and
plugin-access-authorization-host-services.test.ts wasn't sufficient on
its own — each suite now also seeds a matching `plugins` row (manifestJson
empty, so the enablement default stays "on") so the gate resolves the
same way these suites already assumed.

Also adds a focused test proving localFolders.configure's
plugin_company_settings upsert is unreachable for a company-disabled
plugin: the gate (ensurePluginAvailableForCompany) already runs first
in the existing code, before any read or write of that row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /plugins/companies/:companyId/catalog (surface-gated via PR-1
company.plugins, infrastructure plugins excluded, manifest-aware enabled
state) and PUT /plugins/:pluginId/companies/:companyId/enablement
(plugins:manage required, 409 plugin_enablement_locked for locked
plugins, settingsJson/lastError preserved on toggle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…T cross-company test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…blement

bridge/data, bridge/action, data/:key, actions/:key, the bridge SSE
stream, and plugin-scoped API routes with companyResolution now 403
(plugin_not_enabled_for_company) for company-disabled plugins;
instance-scoped bridge calls are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…blement

tools/execute resolves the owning plugin from the namespaced tool name
and 403s before either dispatch path (gateway or dispatcher);
ui-contributions?companyId filters slots of company-disabled plugins
server-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
usePluginSlots keys and forwards the (already-declared) companyId filter
so disabled plugins' slots vanish per company; adds catalog/enablement
api methods and the companyCatalog query key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/company/settings/plugins lists the per-company catalog with
enable/disable toggles (plugins:manage holders), locked plugins render
as non-interactive managed-by-instance rows, and 403s redirect to the
settings root as navigation misses.

Also wires the "Plugins" tab into CompanySettingsNav (the mobile tab
bar parallel to CompanySettingsSidebar) so the surface is reachable
and correctly highlighted on narrow viewports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…spec

This branch's plugin routes were missing from the OpenAPI specification
and caused openapi-routes test failures. Added the two new company-scoped
plugin routes to the spec following neighboring plugin-route entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s/page contributions

- server/src/routes/plugins.ts: PUT local-folders/:folderKey now calls
  enablement.ensurePluginEnabledForCompany() before upserting
  plugin_company_settings, closing a gap where a non-enabled (default-off
  or locked) plugin could get an enabled:true row written by any company
  member. Sibling GET local-folders (list) and GET .../status routes get
  the same gate since they leak stored per-company folder config for
  disabled plugins.
- ui/src/plugins/launchers.tsx: usePluginLaunchers now threads
  filters.companyId into both the query key and
  pluginsApi.listUiContributions(), mirroring usePluginSlots. Previously
  the hook silently dropped the companyId a caller (e.g.
  PluginLauncherOutlet) already passed in, fetching unfiltered
  contributions.
- ui/src/pages/PluginPage.tsx: same fix — threads resolvedCompanyId into
  the ui-contributions query key/call so a disabled plugin's page slot
  isn't fetched for that company.
- ui/src/api/plugins.ts: wrap pluginId/companyId in encodeURIComponent()
  in listCompanyPluginCatalog/setCompanyPluginEnabled for consistency
  with listUiContributions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stubbi
stubbi force-pushed the feat/company-plugin-enablement branch from a7ce503 to 1d977b3 Compare July 18, 2026 18:45
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.

Plugins cannot be enabled per company — enablement is instance-global

1 participant