forked from farhoodlabs/paperclip
9c6f551595
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The plugin system is the extension path for optional capabilities that should not require core product changes for every integration. > - Plugins need scoped host APIs for issue orchestration, documents, wakeups, summaries, activity attribution, and isolated database state. > - Without those host APIs, richer plugins either cannot coordinate Paperclip work safely or need privileged core-side special cases. > - This pull request adds the plugin orchestration host surface, scoped route dispatch, a database namespace layer, and a smoke plugin that exercises the contract. > - The benefit is a broader plugin API that remains company-scoped, auditable, and covered by tests. ## What Changed - Added plugin orchestration host APIs for issue creation, document access, wakeups, summaries, plugin-origin activity, and scoped API route dispatch. - Added plugin database namespace tables, schema exports, migration checks, and idempotent replay coverage under migration `0059_plugin_database_namespaces`. - Added shared plugin route/API types and validators used by server and SDK boundaries. - Expanded plugin SDK types, protocol helpers, worker RPC host behavior, and testing utilities for orchestration flows. - Added the `plugin-orchestration-smoke-example` package to exercise scoped routes, restricted database namespaces, issue orchestration, documents, wakeups, summaries, and UI status surfaces. - Kept the new orchestration smoke fixture out of the root pnpm workspace importer so this PR preserves the repository policy of not committing `pnpm-lock.yaml`. - Updated plugin docs and database docs for the new orchestration and database namespace surfaces. - Rebased the branch onto `public-gh/master`, resolved conflicts, and removed `pnpm-lock.yaml` from the final PR diff. ## Verification - `pnpm install --frozen-lockfile` - `pnpm --filter @paperclipai/db typecheck` - `pnpm exec vitest run packages/db/src/client.test.ts` - `pnpm exec vitest run server/src/__tests__/plugin-database.test.ts server/src/__tests__/plugin-orchestration-apis.test.ts server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/plugin-scoped-api-routes.test.ts server/src/__tests__/plugin-sdk-orchestration-contract.test.ts` - From `packages/plugins/examples/plugin-orchestration-smoke-example`: `pnpm exec vitest run --config ./vitest.config.ts` - `pnpm --dir packages/plugins/examples/plugin-orchestration-smoke-example run typecheck` - `pnpm --filter @paperclipai/server typecheck` - PR CI on latest head `293fc67c`: `policy`, `verify`, `e2e`, and `security/snyk` all passed. ## Risks - Medium risk: this expands plugin host authority, so route auth, company scoping, and plugin-origin activity attribution need careful review. - Medium risk: database namespace migration behavior must remain idempotent for environments that may have seen earlier branch versions. - Medium risk: the orchestration smoke fixture is intentionally excluded from the root workspace importer to avoid a `pnpm-lock.yaml` PR diff; direct fixture verification remains listed above. - Low operational risk from the PR setup itself: the branch is rebased onto current `master`, the migration is ordered after upstream `0057`/`0058`, and `pnpm-lock.yaml` is not in the final diff. > 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`. Roadmap checked: this work aligns with the completed Plugin system milestone and extends the plugin surface rather than duplicating an unrelated planned core feature. ## Model Used - OpenAI Codex, GPT-5-based coding agent in a tool-enabled CLI environment. Exact hosted model build and context-window size are not exposed by the runtime; reasoning/tool use were enabled for repository inspection, editing, testing, git operations, and PR creation. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (N/A: no core UI screen change; example plugin UI contract is covered by tests) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
465 lines
15 KiB
TypeScript
465 lines
15 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"],
|
||
"project.workspaces.list": ["project.workspaces.read"],
|
||
"project.workspaces.get": ["project.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"],
|
||
"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"],
|
||
|
||
// 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"],
|
||
"activity.log": ["activity.log.write"],
|
||
"metrics.write": ["metrics.write"],
|
||
"telemetry.track": ["telemetry.track"],
|
||
"db.migrate": ["database.namespace.migrate"],
|
||
"db.execute": ["database.namespace.write"],
|
||
|
||
// 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"],
|
||
};
|
||
|
||
/**
|
||
* 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",
|
||
};
|
||
|
||
/**
|
||
* 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",
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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];
|
||
},
|
||
};
|
||
}
|