mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 06:15:21 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A growing part of that work runs in sandboxed environments rather than on the operator's local machine. > - Today sandbox providers can start fresh workspaces and run probes, but they do not have a shared contract for capturing and reusing prepared sandbox state. > - Operators need a way to set up tools, credentials, and project dependencies once, then reuse that prepared image for later agent runs. > - This pull request adds reusable sandbox custom images across the provider contract, server runtime, and board UI. > - It also keeps probes and sandbox copy flows aligned with pre-authenticated/custom-image environments. > - The benefit is faster, more reliable sandbox runs without repeatedly rebuilding the same environment setup. ## Linked Issues or Issue Description No public GitHub issue was found for this change. Inline feature request follows. ### Problem or motivation Sandboxed agents need reusable prepared runtime state so repeated runs do not require manual setup every time. Operators often need system packages, CLIs, SDKs, dependency caches, credentials, and project tooling available before an agent can work productively. ### Proposed solution Add a provider-level custom-image capability, server-side setup/capture lifecycle, Daytona/fake provider support, and board UI controls for creating, testing, selecting, and deleting custom images. ### Alternatives considered Leaving this as provider-specific setup outside Paperclip would keep the control plane blind to image state and would not give agents consistent environment metadata. Re-running setup commands for every lease is simpler, but slower and less reliable for interactive or credentialed setup. ### Roadmap alignment Checked `ROADMAP.md`; this aligns with the Cloud / Sandbox agents roadmap area and does not duplicate any related public issue or PR found by search. Additional context: - Subsystem affected: cross-cutting (`packages/db`, `packages/shared`, `packages/plugins`, `server`, `ui`). - Duplicate search: searched GitHub for `sandbox custom image` and `sandbox template environment`; no related public issues or PRs were found. ## What Changed - Added custom-image shared types, validators, constants, API paths, and database schema/migration. - Added server services/routes for custom-image templates and setup sessions, including runtime cleanup and provider metadata handling. - Extended plugin/sandbox provider capabilities for interactive setup, template capture, and template deletion. - Implemented custom-image support in the fake sandbox provider and Daytona provider. - Updated environment runtime/config handling so active custom images flow into leases, probes, and agent execution. - Added board UI controls and API client support for custom-image setup, capture, selection, status, and error states. - Hardened sandbox copy/probe behavior for insecure clipboard contexts and pre-authenticated sandbox images. - Added targeted coverage across shared validators, DB schema, server routes/services, provider plugins, adapter probes, and UI flows. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `pnpm vitest run packages/adapters/claude-local/src/server/test.probe.test.ts packages/adapters/claude-local/src/server/test.ts packages/adapters/codex-local/src/server/test.remote.test.ts packages/adapters/codex-local/src/server/test.ts` - `pnpm --filter @paperclipai/adapter-claude-local typecheck` - `pnpm --filter @paperclipai/adapter-codex-local typecheck` - `pnpm vitest run packages/db/src/environment-custom-images-schema.test.ts packages/shared/src/environment-custom-images.test.ts packages/shared/src/validators/plugin.test.ts server/src/__tests__/environment-custom-images-service.test.ts server/src/__tests__/workspace-runtime.test.ts packages/plugins/sandbox-providers/daytona/src/plugin.test.ts ui/src/pages/CompanyEnvironments.test.tsx ui/src/pages/CompanySettings.test.tsx` - `pnpm -r typecheck` - `pnpm build` - `rm -rf packages/db/dist && pnpm test:run` - Public-safety scan of the final diff found no internal Paperclip issue links, private instance URLs, or real secret patterns. ## Risks - Adds a database migration and new environment runtime tables, so migration ordering and rollback need care. - Provider implementations may differ in how reliably they can capture/delete images; unsupported providers surface capability-gated UI states. - Custom-image state can contain operator-prepared tooling and credentials inside the provider image, so providers must enforce their own access controls and cleanup semantics. - Broad surface area across shared contracts, server runtime, plugins, adapters, and UI means CI and Greptile review should be watched closely. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 (`gpt-5`) via Codex CLI with tool use and code execution. Assisted with branch cleanup, conflict resolution, local verification, and PR preparation. Earlier branch implementation work was assisted by Paperclip-managed Claude/Codex agents. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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>
516 lines
17 KiB
TypeScript
516 lines
17 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"],
|
||
"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.interactions.create": ["issue.interactions.create"],
|
||
"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",
|
||
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];
|
||
},
|
||
};
|
||
}
|