mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Plugins extend the application without adding each integration to
Core.
> - A downstream image needs a way to supply prebuilt plugins.
> - Some plugin UI must stay mounted as users move between pages.
> - This change adds an image catalog and a persistent application slot.
> - Operators can upgrade or remove these plugins through their image
and configuration.
## Linked Issues or Issue Description
**Subsystem affected**
Plugin packaging, activation and application UI.
**Problem or motivation**
The built-in plugin catalog is fixed in Core source. Downstream images
cannot add entries through an explicit catalog. Existing page slots also
cannot preserve a small application overlay across route changes.
**Proposed solution**
Read a bounded catalog of prebuilt plugins from the image. Verify its
files before importing manifests. Use the existing managed selection and
plugin lifecycle. Add an `appShellOverlay` slot with account and company
cleanup.
**Alternatives considered**
A downstream fork adds merge work. Script injection provides no plugin
lifecycle. A separate runtime download system adds a second distribution
channel.
**Roadmap alignment**
This extends the existing plugin system. Related PR #9006 covers runtime
install replication; this change covers immutable image contents. PR
#12555 covers CLI scaffolding. Neither provides this catalog or
application slot. The maintainer requested this work directly.
## What Changed
- Validate catalog identities, confined paths, package versions and
bundle hashes before importing code.
- Apply image selection to persisted plugin installs, including removal
and rollback. Adopt the verified image path from existing npm/local
installs and bind runtime worker/UI entrypoints to verified package
declarations.
- Mount application overlays in both UI shells. Preserve route state and
clear it on account, company and onboarding changes.
- Restrict service-worker offline storage/fallback to hashed public
assets in a separate cache namespace; exclude application HTML and
extension/API data, including after worker restart.
- Document the packaging contract, trust model and rollback
requirements.
## Verification
- Passed `pnpm -r typecheck`, `pnpm build`, and `pnpm
check:token-gates`. Affected server/UI typechecks and builds, plus token
gates, passed again after rebasing onto current master; the 124 focused
tests also passed after rebase.
- Latest focused verification: 124 tests in nine files passed for
catalog/reconciliation/loader, overlay lifecycle, Layout and
service-worker policy. The broader UI/shared/SDK run passed 7,204 tests
in 690 files with canonical `TMPDIR`.
- Real disposable Core/PostgreSQL: catalog install, selection removal,
0.1.0→0.1.1→0.1.0, same-version npm/legacy-path adoption, and
preservation of disabled status passed. Added permissions entered
`upgrade_pending`, withheld UI across restart, and activated only after
explicit operator enable.
- Real Chromium: desktop/mobile layout, route draft retention and
Escape/focus passed with mocked extension responses. A persistent
browser restart retained public hashed-asset offline fallback while
refusing seeded legacy/current private entries and legacy HTML.
- Full `pnpm test:run`: 12,539 passed; 17 failed across six existing
files, stopping later phases. macOS read-only directory renames fail in
runtime-skill-cache and company-skills-service; email tests require an
absent local AgentMail fixture. Native runner/comment-redaction passed
in isolation after temporary Rust setup; agent-conversations also passed
in isolation. No unrelated source was changed to hide failures.
- After rebase, two unchanged chat timing tests failed in CI and passed
locally in isolation. Their CI shard passed on its single retry. All
other current-head CI jobs passed on the initial run; review is 5/5 with
no unresolved threads.
- No live deployment or external plugin service was used.
## Risks
- Plugins are trusted code. The catalog detects packaging errors; it
does not authenticate an untrusted image builder.
- Invalid catalogs fail startup. Images must contain the catalog and
bundles together, with stable directories.
- A host older than this contract lacks the activation guard. Disable
added plugins and remove their configuration keys before reverting to
it.
- Offline navigation now returns 503 instead of replaying cached
application HTML. Only public build assets have offline fallback.
- Rolling back an unapproved permission change retains the approval
gate; review the current manifest and explicitly enable it. A reduced
permission set cannot establish prior approval or prior enabled status.
- Plugin data migrations need their own rollback policy. This change
retains installed records and does not reverse migrations.
## Model Used
- OpenAI GPT-6 (Codex), model ID `gpt-6`, with repository inspection,
code execution and browser verification. The runtime does not expose an
exact context-window size.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (relevant suites; broad
macOS server-run exceptions are documented above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (fresh run on 488b3754ae; chat
shard passed its single retry)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(fresh review on 488b3754ae)
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
527 lines
18 KiB
TypeScript
527 lines
18 KiB
TypeScript
/**
|
||
* PluginCapabilityValidator — enforces the capability model at both
|
||
* install-time and runtime.
|
||
*
|
||
* Every plugin declares the capabilities it requires in its manifest
|
||
* (`manifest.capabilities`). This service checks those declarations
|
||
* against a mapping of operations → required capabilities so that:
|
||
*
|
||
* 1. **Install-time validation** — `validateManifestCapabilities()`
|
||
* ensures that declared features (tools, jobs, webhooks, UI slots)
|
||
* have matching capability entries, giving operators clear feedback
|
||
* before a plugin is activated.
|
||
*
|
||
* 2. **Runtime gating** — `checkOperation()` / `assertOperation()` are
|
||
* called on every worker→host bridge call to enforce least-privilege
|
||
* access. If a plugin attempts an operation it did not declare, the
|
||
* call is rejected with a 403 error.
|
||
*
|
||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||
* @see host-client-factory.ts — SDK-side capability gating
|
||
*/
|
||
import type {
|
||
PluginCapability,
|
||
PaperclipPluginManifestV1,
|
||
PluginUiSlotType,
|
||
PluginLauncherPlacementZone,
|
||
} from "@paperclipai/shared";
|
||
import { forbidden } from "../errors.js";
|
||
import { logger } from "../middleware/logger.js";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Capability requirement mappings
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Maps high-level operations to the capabilities they require.
|
||
*
|
||
* When the bridge receives a call from a plugin worker, the host looks up
|
||
* the operation in this map and checks the plugin's declared capabilities.
|
||
* If any required capability is missing, the call is rejected.
|
||
*
|
||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||
*/
|
||
const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||
// Data read operations
|
||
"companies.list": ["companies.read"],
|
||
"companies.get": ["companies.read"],
|
||
"projects.list": ["projects.read"],
|
||
"projects.get": ["projects.read"],
|
||
"projects.managed.get": ["projects.managed"],
|
||
"projects.managed.reconcile": ["projects.managed"],
|
||
"projects.managed.reset": ["projects.managed"],
|
||
"routines.managed.get": ["routines.managed"],
|
||
"routines.managed.reconcile": ["routines.managed"],
|
||
"routines.managed.reset": ["routines.managed"],
|
||
"project.workspaces.list": ["project.workspaces.read"],
|
||
"project.workspaces.get": ["project.workspaces.read"],
|
||
"execution.workspaces.get": ["execution.workspaces.read"],
|
||
"issues.list": ["issues.read"],
|
||
"issues.get": ["issues.read"],
|
||
"issues.relations.get": ["issue.relations.read"],
|
||
"issue.comments.list": ["issue.comments.read"],
|
||
"issue.comments.get": ["issue.comments.read"],
|
||
"issue.interactions.list": ["issue.interactions.read"],
|
||
"issue.interactions.read": ["issue.interactions.read"],
|
||
"issue.attachments.list": ["issue.attachments.read"],
|
||
"issue.attachments.get": ["issue.attachments.read"],
|
||
"approvals.list": ["approvals.read"],
|
||
"approvals.get": ["approvals.read"],
|
||
"agents.list": ["agents.read"],
|
||
"agents.get": ["agents.read"],
|
||
"agents.managed.get": ["agents.managed"],
|
||
"agents.managed.reconcile": ["agents.managed"],
|
||
"agents.managed.reset": ["agents.managed"],
|
||
"goals.list": ["goals.read"],
|
||
"goals.get": ["goals.read"],
|
||
"activity.list": ["activity.read"],
|
||
"activity.get": ["activity.read"],
|
||
"costs.list": ["costs.read"],
|
||
"costs.get": ["costs.read"],
|
||
"issues.summaries.getOrchestration": ["issues.orchestration.read"],
|
||
"db.namespace": ["database.namespace.read"],
|
||
"db.query": ["database.namespace.read"],
|
||
"localFolders.declarations": [],
|
||
"localFolders.configure": ["local.folders"],
|
||
"localFolders.status": ["local.folders"],
|
||
"localFolders.list": ["local.folders"],
|
||
"localFolders.readText": ["local.folders"],
|
||
"localFolders.writeTextAtomic": ["local.folders"],
|
||
|
||
// Data write operations
|
||
"issues.create": ["issues.create"],
|
||
"issues.update": ["issues.update"],
|
||
"issues.relations.setBlockedBy": ["issue.relations.write"],
|
||
"issues.relations.addBlockers": ["issue.relations.write"],
|
||
"issues.relations.removeBlockers": ["issue.relations.write"],
|
||
"issues.assertCheckoutOwner": ["issues.checkout"],
|
||
"issues.getSubtree": ["issue.subtree.read"],
|
||
"issues.requestWakeup": ["issues.wakeup"],
|
||
"issues.requestWakeups": ["issues.wakeup"],
|
||
"issue.comments.create": ["issue.comments.create"],
|
||
"issue.comments.create_human_attributed": ["issue.comments.create_human_attributed"],
|
||
"issue.interactions.create": ["issue.interactions.create"],
|
||
"issue.interactions.respond": ["issue.interactions.respond"],
|
||
"approvals.respond": ["approvals.respond"],
|
||
"approvals.decide": ["approvals.respond"],
|
||
"activity.log": ["activity.log.write"],
|
||
"metrics.write": ["metrics.write"],
|
||
"telemetry.track": ["telemetry.track"],
|
||
"db.migrate": ["database.namespace.migrate"],
|
||
"db.execute": ["database.namespace.write"],
|
||
"external.objects.detect": ["external.objects.detect"],
|
||
"external.objects.read": ["external.objects.read"],
|
||
"external.objects.write": ["external.objects.write"],
|
||
"external.objects.refresh": ["external.objects.refresh"],
|
||
|
||
// Plugin state operations
|
||
"plugin.state.get": ["plugin.state.read"],
|
||
"plugin.state.list": ["plugin.state.read"],
|
||
"plugin.state.set": ["plugin.state.write"],
|
||
"plugin.state.delete": ["plugin.state.write"],
|
||
|
||
// Runtime / Integration operations
|
||
"events.subscribe": ["events.subscribe"],
|
||
"events.emit": ["events.emit"],
|
||
"jobs.schedule": ["jobs.schedule"],
|
||
"jobs.cancel": ["jobs.schedule"],
|
||
"webhooks.receive": ["webhooks.receive"],
|
||
"http.request": ["http.outbound"],
|
||
"secrets.resolve": ["secrets.read-ref"],
|
||
|
||
// Agent tools
|
||
"agent.tools.register": ["agent.tools.register"],
|
||
"agent.tools.execute": ["agent.tools.register"],
|
||
|
||
// Environment runtime drivers
|
||
"environment.validateConfig": ["environment.drivers.register"],
|
||
"environment.probe": ["environment.drivers.register"],
|
||
"environment.acquireLease": ["environment.drivers.register"],
|
||
"environment.resumeLease": ["environment.drivers.register"],
|
||
"environment.releaseLease": ["environment.drivers.register"],
|
||
"environment.destroyLease": ["environment.drivers.register"],
|
||
"environment.realizeWorkspace": ["environment.drivers.register"],
|
||
"environment.execute": ["environment.drivers.register"],
|
||
"environment.startInteractiveSetup": ["environment.drivers.register"],
|
||
"environment.getInteractiveSetup": ["environment.drivers.register"],
|
||
"environment.captureTemplate": ["environment.drivers.register"],
|
||
"environment.cancelInteractiveSetup": ["environment.drivers.register"],
|
||
"environment.deleteTemplate": ["environment.drivers.register"],
|
||
};
|
||
|
||
/**
|
||
* Maps UI slot types to the capability required to register them.
|
||
*
|
||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||
*/
|
||
const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
|
||
sidebar: "ui.sidebar.register",
|
||
sidebarPanel: "ui.sidebar.register",
|
||
projectSidebarItem: "ui.sidebar.register",
|
||
page: "ui.page.register",
|
||
detailTab: "ui.detailTab.register",
|
||
taskDetailView: "ui.detailTab.register",
|
||
dashboardWidget: "ui.dashboardWidget.register",
|
||
globalToolbarButton: "ui.action.register",
|
||
appShellOverlay: "ui.action.register",
|
||
toolbarButton: "ui.action.register",
|
||
contextMenuItem: "ui.action.register",
|
||
commentAnnotation: "ui.commentAnnotation.register",
|
||
commentContextMenuItem: "ui.action.register",
|
||
settingsPage: "instance.settings.register",
|
||
companySettingsPage: "instance.settings.register",
|
||
routeSidebar: "ui.sidebar.register",
|
||
};
|
||
|
||
/**
|
||
* Launcher placement zones align with host UI surfaces and therefore inherit
|
||
* the same capability requirements as the equivalent slot type.
|
||
*/
|
||
const LAUNCHER_PLACEMENT_CAPABILITIES: Record<
|
||
PluginLauncherPlacementZone,
|
||
PluginCapability
|
||
> = {
|
||
page: "ui.page.register",
|
||
detailTab: "ui.detailTab.register",
|
||
taskDetailView: "ui.detailTab.register",
|
||
dashboardWidget: "ui.dashboardWidget.register",
|
||
sidebar: "ui.sidebar.register",
|
||
sidebarPanel: "ui.sidebar.register",
|
||
projectSidebarItem: "ui.sidebar.register",
|
||
globalToolbarButton: "ui.action.register",
|
||
toolbarButton: "ui.action.register",
|
||
contextMenuItem: "ui.action.register",
|
||
commentAnnotation: "ui.commentAnnotation.register",
|
||
commentContextMenuItem: "ui.action.register",
|
||
settingsPage: "instance.settings.register",
|
||
};
|
||
|
||
/**
|
||
* Maps feature declarations in the manifest to their required capabilities.
|
||
*/
|
||
const FEATURE_CAPABILITIES: Record<string, PluginCapability> = {
|
||
tools: "agent.tools.register",
|
||
jobs: "jobs.schedule",
|
||
webhooks: "webhooks.receive",
|
||
database: "database.namespace.migrate",
|
||
environmentDrivers: "environment.drivers.register",
|
||
agents: "agents.managed",
|
||
projects: "projects.managed",
|
||
routines: "routines.managed",
|
||
objectReferences: "external.objects.detect",
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Result types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Result of a capability check. When `allowed` is false, `missing` contains
|
||
* the capabilities that the plugin does not declare but the operation requires.
|
||
*/
|
||
export interface CapabilityCheckResult {
|
||
allowed: boolean;
|
||
missing: PluginCapability[];
|
||
operation?: string;
|
||
pluginId?: string;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PluginCapabilityValidator interface
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface PluginCapabilityValidator {
|
||
/**
|
||
* Check whether a plugin has a specific capability.
|
||
*/
|
||
hasCapability(
|
||
manifest: PaperclipPluginManifestV1,
|
||
capability: PluginCapability,
|
||
): boolean;
|
||
|
||
/**
|
||
* Check whether a plugin has all of the specified capabilities.
|
||
*/
|
||
hasAllCapabilities(
|
||
manifest: PaperclipPluginManifestV1,
|
||
capabilities: PluginCapability[],
|
||
): CapabilityCheckResult;
|
||
|
||
/**
|
||
* Check whether a plugin has at least one of the specified capabilities.
|
||
*/
|
||
hasAnyCapability(
|
||
manifest: PaperclipPluginManifestV1,
|
||
capabilities: PluginCapability[],
|
||
): boolean;
|
||
|
||
/**
|
||
* Check whether a plugin is allowed to perform the named operation.
|
||
*
|
||
* Operations are mapped to required capabilities via OPERATION_CAPABILITIES.
|
||
* Unknown operations are rejected by default.
|
||
*/
|
||
checkOperation(
|
||
manifest: PaperclipPluginManifestV1,
|
||
operation: string,
|
||
): CapabilityCheckResult;
|
||
|
||
/**
|
||
* Assert that a plugin is allowed to perform an operation.
|
||
* Throws a 403 HttpError if the capability check fails.
|
||
*/
|
||
assertOperation(
|
||
manifest: PaperclipPluginManifestV1,
|
||
operation: string,
|
||
): void;
|
||
|
||
/**
|
||
* Assert that a plugin has a specific capability.
|
||
* Throws a 403 HttpError if the capability is missing.
|
||
*/
|
||
assertCapability(
|
||
manifest: PaperclipPluginManifestV1,
|
||
capability: PluginCapability,
|
||
): void;
|
||
|
||
/**
|
||
* Check whether a plugin can register the given UI slot type.
|
||
*/
|
||
checkUiSlot(
|
||
manifest: PaperclipPluginManifestV1,
|
||
slotType: PluginUiSlotType,
|
||
): CapabilityCheckResult;
|
||
|
||
/**
|
||
* Validate that a manifest's declared capabilities are consistent with its
|
||
* declared features (tools, jobs, webhooks, UI slots).
|
||
*
|
||
* Returns all missing capabilities rather than failing on the first one.
|
||
* This is useful for install-time validation to give comprehensive feedback.
|
||
*/
|
||
validateManifestCapabilities(
|
||
manifest: PaperclipPluginManifestV1,
|
||
): CapabilityCheckResult;
|
||
|
||
/**
|
||
* Get the capabilities required for a named operation.
|
||
* Returns an empty array if the operation is unknown.
|
||
*/
|
||
getRequiredCapabilities(operation: string): readonly PluginCapability[];
|
||
|
||
/**
|
||
* Get the capability required for a UI slot type.
|
||
*/
|
||
getUiSlotCapability(slotType: PluginUiSlotType): PluginCapability;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Factory
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Create a PluginCapabilityValidator.
|
||
*
|
||
* This service enforces capability gates for plugin operations. The host
|
||
* uses it to verify that a plugin's declared capabilities permit the
|
||
* operation it is attempting, both at install time (manifest validation)
|
||
* and at runtime (bridge call gating).
|
||
*
|
||
* Usage:
|
||
* ```ts
|
||
* const validator = pluginCapabilityValidator();
|
||
*
|
||
* // Runtime: gate a bridge call
|
||
* validator.assertOperation(plugin.manifestJson, "issues.create");
|
||
*
|
||
* // Install time: validate manifest consistency
|
||
* const result = validator.validateManifestCapabilities(manifest);
|
||
* if (!result.allowed) {
|
||
* throw badRequest("Missing capabilities", result.missing);
|
||
* }
|
||
* ```
|
||
*/
|
||
export function pluginCapabilityValidator(): PluginCapabilityValidator {
|
||
const log = logger.child({ service: "plugin-capability-validator" });
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Internal helpers
|
||
// -----------------------------------------------------------------------
|
||
|
||
function capabilitySet(manifest: PaperclipPluginManifestV1): Set<PluginCapability> {
|
||
return new Set(manifest.capabilities);
|
||
}
|
||
|
||
function buildForbiddenMessage(
|
||
manifest: PaperclipPluginManifestV1,
|
||
operation: string,
|
||
missing: PluginCapability[],
|
||
): string {
|
||
return (
|
||
`Plugin '${manifest.id}' is not allowed to perform '${operation}'. ` +
|
||
`Missing required capabilities: ${missing.join(", ")}`
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Public API
|
||
// -----------------------------------------------------------------------
|
||
|
||
return {
|
||
hasCapability(manifest, capability) {
|
||
return manifest.capabilities.includes(capability);
|
||
},
|
||
|
||
hasAllCapabilities(manifest, capabilities) {
|
||
const declared = capabilitySet(manifest);
|
||
const missing = capabilities.filter((cap) => !declared.has(cap));
|
||
return {
|
||
allowed: missing.length === 0,
|
||
missing,
|
||
pluginId: manifest.id,
|
||
};
|
||
},
|
||
|
||
hasAnyCapability(manifest, capabilities) {
|
||
const declared = capabilitySet(manifest);
|
||
return capabilities.some((cap) => declared.has(cap));
|
||
},
|
||
|
||
checkOperation(manifest, operation) {
|
||
const required = OPERATION_CAPABILITIES[operation];
|
||
|
||
if (!required) {
|
||
log.warn(
|
||
{ pluginId: manifest.id, operation },
|
||
"capability check for unknown operation – rejecting by default",
|
||
);
|
||
return {
|
||
allowed: false,
|
||
missing: [],
|
||
operation,
|
||
pluginId: manifest.id,
|
||
};
|
||
}
|
||
|
||
const declared = capabilitySet(manifest);
|
||
const missing = required.filter((cap) => !declared.has(cap));
|
||
|
||
if (missing.length > 0) {
|
||
log.debug(
|
||
{ pluginId: manifest.id, operation, missing },
|
||
"capability check failed",
|
||
);
|
||
}
|
||
|
||
return {
|
||
allowed: missing.length === 0,
|
||
missing,
|
||
operation,
|
||
pluginId: manifest.id,
|
||
};
|
||
},
|
||
|
||
assertOperation(manifest, operation) {
|
||
const result = this.checkOperation(manifest, operation);
|
||
if (!result.allowed) {
|
||
const msg = result.missing.length > 0
|
||
? buildForbiddenMessage(manifest, operation, result.missing)
|
||
: `Plugin '${manifest.id}' attempted unknown operation '${operation}'`;
|
||
throw forbidden(msg);
|
||
}
|
||
},
|
||
|
||
assertCapability(manifest, capability) {
|
||
if (!this.hasCapability(manifest, capability)) {
|
||
throw forbidden(
|
||
`Plugin '${manifest.id}' lacks required capability '${capability}'`,
|
||
);
|
||
}
|
||
},
|
||
|
||
checkUiSlot(manifest, slotType) {
|
||
const required = UI_SLOT_CAPABILITIES[slotType];
|
||
if (!required) {
|
||
return {
|
||
allowed: false,
|
||
missing: [],
|
||
operation: `ui.${slotType}.register`,
|
||
pluginId: manifest.id,
|
||
};
|
||
}
|
||
|
||
const has = manifest.capabilities.includes(required);
|
||
return {
|
||
allowed: has,
|
||
missing: has ? [] : [required],
|
||
operation: `ui.${slotType}.register`,
|
||
pluginId: manifest.id,
|
||
};
|
||
},
|
||
|
||
validateManifestCapabilities(manifest) {
|
||
const declared = capabilitySet(manifest);
|
||
const allMissing: PluginCapability[] = [];
|
||
|
||
// Check feature declarations → required capabilities
|
||
for (const [feature, requiredCap] of Object.entries(FEATURE_CAPABILITIES)) {
|
||
const featureValue = manifest[feature as keyof PaperclipPluginManifestV1];
|
||
if (Array.isArray(featureValue) && featureValue.length > 0) {
|
||
if (!declared.has(requiredCap)) {
|
||
allMissing.push(requiredCap);
|
||
}
|
||
}
|
||
}
|
||
|
||
if ((manifest.objectReferences?.length ?? 0) > 0) {
|
||
for (const requiredCap of ["external.objects.detect", "external.objects.read"] as const) {
|
||
if (!declared.has(requiredCap) && !allMissing.includes(requiredCap)) {
|
||
allMissing.push(requiredCap);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check UI slots → required capabilities
|
||
const uiSlots = manifest.ui?.slots ?? [];
|
||
if (uiSlots.length > 0) {
|
||
for (const slot of uiSlots) {
|
||
const requiredCap = UI_SLOT_CAPABILITIES[slot.type];
|
||
if (requiredCap && !declared.has(requiredCap)) {
|
||
if (!allMissing.includes(requiredCap)) {
|
||
allMissing.push(requiredCap);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check launcher declarations → required capabilities
|
||
const launchers = [
|
||
...(manifest.launchers ?? []),
|
||
...(manifest.ui?.launchers ?? []),
|
||
];
|
||
if (launchers.length > 0) {
|
||
for (const launcher of launchers) {
|
||
const requiredCap = LAUNCHER_PLACEMENT_CAPABILITIES[launcher.placementZone];
|
||
if (requiredCap && !declared.has(requiredCap) && !allMissing.includes(requiredCap)) {
|
||
allMissing.push(requiredCap);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
allowed: allMissing.length === 0,
|
||
missing: allMissing,
|
||
pluginId: manifest.id,
|
||
};
|
||
},
|
||
|
||
getRequiredCapabilities(operation) {
|
||
return OPERATION_CAPABILITIES[operation] ?? [];
|
||
},
|
||
|
||
getUiSlotCapability(slotType) {
|
||
return UI_SLOT_CAPABILITIES[slotType];
|
||
},
|
||
};
|
||
}
|