mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Plugins can add UI surfaces to the board. > - Organization navigation is still fixed in the host sidebar. > - A distribution needs a supported way to supply its own organization menu. > - This pull request adds one optional React slot with host-owned navigation controls. > - The built-in menu stays available when the optional contribution cannot render. ## Linked Issues or Issue Description **Subsystem affected** Plugin SDK, server capability validation, and sidebar UI. **Problem or motivation** An installed plugin cannot replace the organization switcher without editing the host menu. Existing sidebar and overlay slots do not provide this replacement surface. **Proposed solution** Add an `organizationSwitcher` slot that requires `ui.sidebar.register`. Pass display state, an icon renderer, and navigation/logout callbacks. Keep the built-in menu for absent, ambiguous, missing, failed, or unsupported contributions. **Roadmap alignment** This keeps distribution UI in plugins and adds a small host contract. It does not add an account system or change company authorization. The maintainer requested this extension. Related prior menu changes: #12788, #10917, and #10850. No duplicate replacement-slot PR was found. ## What Changed - Add the slot to shared validation, SDK types, and server capability checks. - Wrap both sidebar menu variants with the optional replacement. - Resolve selection against the current account query before mounting, and reset replacement state on account or company changes. - Add host-specific component props and a fallback to `PluginSlotMount`. - Document the React-only contract and its trust boundary. - Report runtime-supervisor fixture startup details when CI readiness fails. ## Verification - `pnpm -r typecheck` and `pnpm build` passed. - `pnpm check:token-gates` passed. - `pnpm exec vitest run --project @paperclipai/ui`: 6,580 tests passed. - Targeted manifest, replacement, and built-in menu tests: 26 passed, including the incoming-account selection regression. - Installed a local test contribution into an isolated server. CLI inspection reported `ready`. Browser checks covered the loaded production UI, keyboard dismissal, current-organization selection, and an expired remote session. Remote account responses were fixtures. - `pnpm test:run` was attempted. Its first server phase passed 8,323 tests but failed in 36 files due to embedded PostgreSQL startup and filesystem permission errors on this Mac. Later phases did not run. The current Linux CI run is green: 54 checks passed and two were skipped, including build, typecheck, and browser gates. See https://github.com/paperclipai/paperclip/actions/runs/35796722770. - The earlier runtime-supervisor readiness failure did not reproduce locally. The complete affected shard passed locally: 58 files and 843 tests. The six supervisor tests also passed on Node 24.21.0 with CI flags. Added fixture startup diagnostics for the selected Node executable and listener port. The affected Linux shard then passed all 843 tests. The original root cause remains unconfirmed; no production runtime behavior or timeout was changed. ## Risks - Plugin UI remains trusted same-origin code. Display props do not authorize account requests. - A replacement can change navigation behavior. The host retains the built-in menu when discovery or rendering fails and keeps logout/session cleanup host-owned. - No database migration. Existing menus and portfolio behavior remain available. - Local full-suite verification is limited by the environment failures listed above. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository inspection, code execution, and browser testing. The runtime does not expose a more specific model ID or 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 - [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 - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
528 lines
18 KiB
TypeScript
528 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",
|
||
organizationSwitcher: "ui.sidebar.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];
|
||
},
|
||
};
|
||
}
|