forked from farhoodlabs/paperclip
Expand plugin host surface (#5205)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The plugin system is the extension boundary for optional product capabilities > - Rich plugins need more than a worker entrypoint: they need scoped database storage, local project folders, managed agents/routines, host navigation, and reusable UI components > - The LLM Wiki work exposed those missing host surfaces while keeping plugin code outside the core control plane > - This pull request expands the core plugin host, SDK, server APIs, and UI bridge so plugins can declare and use those surfaces > - The benefit is that future plugins can integrate with Paperclip through documented, validated contracts instead of bespoke server or UI imports ## What Changed - Added plugin-managed database namespaces and migration tracking, including Drizzle schema/migration files and SQL validation for namespace isolation. - Added server support for plugin local folders, managed agents, managed routines, scoped plugin APIs, and plugin operation visibility. - Expanded shared plugin manifest/types/validators and SDK host/testing/UI exports for richer plugin surfaces. - Added reusable UI pieces for file trees, managed routines, resizable sidebars, route sidebars, and plugin bridge initialization. - Updated plugin docs and example plugins to use the expanded host and SDK surface. ## Verification - `pnpm install --frozen-lockfile` - `pnpm run preflight:workspace-links && pnpm exec vitest run packages/shared/src/validators/plugin.test.ts server/src/__tests__/plugin-database.test.ts server/src/__tests__/plugin-local-folders.test.ts server/src/__tests__/plugin-managed-agents.test.ts server/src/__tests__/plugin-managed-routines.test.ts server/src/__tests__/plugin-orchestration-apis.test.ts ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx ui/src/components/ResizableSidebarPane.test.tsx ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed: 11 files, 67 tests. - Confirmed this PR changes 89 files and does not include `pnpm-lock.yaml` or `.github/workflows/*`. ## Risks - Medium: this expands plugin host contracts across db/shared/server/ui and includes a new core migration (`0076_useful_elektra.sql`). - The plugin database namespace validator is intentionally restrictive; plugin authors may need follow-up affordances for SQL patterns that remain blocked. - Merge this before the LLM Wiki plugin PR so the plugin can resolve the new SDK and host APIs. > 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 Codex, GPT-5 coding agent, tool-enabled shell/git/GitHub workflow. Context window size was not exposed by the runtime. ## 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 - [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>
This commit is contained in:
@@ -15,7 +15,15 @@ import {
|
||||
PLUGIN_API_ROUTE_AUTH_MODES,
|
||||
PLUGIN_API_ROUTE_CHECKOUT_POLICIES,
|
||||
PLUGIN_API_ROUTE_METHODS,
|
||||
ISSUE_PRIORITIES,
|
||||
ROUTINE_CATCH_UP_POLICIES,
|
||||
ROUTINE_CONCURRENCY_POLICIES,
|
||||
ROUTINE_STATUSES,
|
||||
ROUTINE_TRIGGER_KINDS,
|
||||
ROUTINE_TRIGGER_SIGNING_MODES,
|
||||
ISSUE_SURFACE_VISIBILITIES,
|
||||
} from "../constants.js";
|
||||
import { routineVariableSchema } from "./routine.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON Schema placeholder – a permissive validator for JSON Schema objects
|
||||
@@ -124,6 +132,106 @@ export type PluginEnvironmentDriverDeclarationInput = z.infer<
|
||||
|
||||
export type PluginToolDeclarationInput = z.infer<typeof pluginToolDeclarationSchema>;
|
||||
|
||||
export const pluginManagedAgentDeclarationSchema = z.object({
|
||||
agentKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "agentKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
displayName: z.string().min(1).max(100),
|
||||
role: z.string().min(1).max(100).optional(),
|
||||
title: z.string().max(200).nullable().optional(),
|
||||
icon: z.string().max(100).nullable().optional(),
|
||||
capabilities: z.string().max(2000).nullable().optional(),
|
||||
adapterType: z.string().min(1).max(100).optional(),
|
||||
adapterPreference: z.array(z.string().min(1).max(100)).max(10).optional(),
|
||||
adapterConfig: z.record(z.unknown()).optional(),
|
||||
runtimeConfig: z.record(z.unknown()).optional(),
|
||||
permissions: z.record(z.unknown()).optional(),
|
||||
status: z.enum(["idle", "paused"]).optional(),
|
||||
budgetMonthlyCents: z.number().int().min(0).optional(),
|
||||
instructions: z.object({
|
||||
entryFile: z.string().min(1).max(200).optional(),
|
||||
content: z.string().max(200_000).optional(),
|
||||
assetPath: z.string().min(1).max(500).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type PluginManagedAgentDeclarationInput = z.infer<typeof pluginManagedAgentDeclarationSchema>;
|
||||
|
||||
export const pluginManagedProjectDeclarationSchema = z.object({
|
||||
projectKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "projectKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
displayName: z.string().min(1).max(120),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
status: z.enum(["backlog", "planned", "in_progress", "completed", "cancelled"]).optional(),
|
||||
color: z.string().max(32).nullable().optional(),
|
||||
settings: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type PluginManagedProjectDeclarationInput = z.infer<typeof pluginManagedProjectDeclarationSchema>;
|
||||
|
||||
const pluginManagedResourceRefSchema = z.object({
|
||||
pluginKey: z.string().min(1).max(100).optional(),
|
||||
resourceKind: z.enum(["agent", "project", "routine"]),
|
||||
resourceKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "resourceKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
});
|
||||
|
||||
export const pluginManagedRoutineDeclarationSchema = z.object({
|
||||
routineKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "routineKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: z.string().max(10_000).nullable().optional(),
|
||||
assigneeRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("agent") }).nullable().optional(),
|
||||
projectRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("project") }).nullable().optional(),
|
||||
goalId: z.string().uuid().nullable().optional(),
|
||||
status: z.enum(ROUTINE_STATUSES).optional(),
|
||||
priority: z.enum(ISSUE_PRIORITIES).optional(),
|
||||
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional(),
|
||||
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional(),
|
||||
variables: z.array(routineVariableSchema).optional(),
|
||||
triggers: z.array(z.object({
|
||||
kind: z.enum(ROUTINE_TRIGGER_KINDS),
|
||||
label: z.string().trim().max(120).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
cronExpression: z.string().trim().min(1).optional().nullable(),
|
||||
timezone: z.string().trim().min(1).optional().nullable(),
|
||||
signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).optional().nullable(),
|
||||
replayWindowSec: z.number().int().min(30).max(86_400).optional().nullable(),
|
||||
})).max(20).optional(),
|
||||
issueTemplate: z.object({
|
||||
surfaceVisibility: z.enum(ISSUE_SURFACE_VISIBILITIES).optional(),
|
||||
originId: z.string().trim().max(255).nullable().optional(),
|
||||
billingCode: z.string().trim().max(200).nullable().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type PluginManagedRoutineDeclarationInput = z.infer<typeof pluginManagedRoutineDeclarationSchema>;
|
||||
|
||||
const pluginLocalFolderRelativePathSchema = z.string().min(1).max(500).refine(
|
||||
(value) =>
|
||||
!value.startsWith("/") &&
|
||||
!value.includes("..") &&
|
||||
!value.includes("\\") &&
|
||||
!value.split("/").some((segment) => segment === "" || segment === "."),
|
||||
{ message: "local folder paths must be relative paths without traversal, empty segments, or backslashes" },
|
||||
);
|
||||
|
||||
export const pluginLocalFolderDeclarationSchema = z.object({
|
||||
folderKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "folderKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
displayName: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
access: z.enum(["read", "readWrite"]).optional(),
|
||||
requiredDirectories: z.array(pluginLocalFolderRelativePathSchema).optional(),
|
||||
requiredFiles: z.array(pluginLocalFolderRelativePathSchema).optional(),
|
||||
});
|
||||
|
||||
export type PluginLocalFolderDeclarationInput = z.infer<typeof pluginLocalFolderDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin
|
||||
* fills with a React component. Includes `superRefine` checks for slot-specific
|
||||
@@ -178,10 +286,17 @@ export const pluginUiSlotDeclarationSchema = z.object({
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
if (value.routePath && value.type !== "page") {
|
||||
if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "routePath is only supported for page slots",
|
||||
message: "routePath is only supported for page and routeSidebar slots",
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
if (value.type === "routeSidebar" && !value.routePath) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "routeSidebar slots require routePath",
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
@@ -471,6 +586,10 @@ export const pluginManifestV1Schema = z.object({
|
||||
database: pluginDatabaseDeclarationSchema.optional(),
|
||||
apiRoutes: z.array(pluginApiRouteDeclarationSchema).optional(),
|
||||
environmentDrivers: z.array(pluginEnvironmentDriverDeclarationSchema).optional(),
|
||||
agents: z.array(pluginManagedAgentDeclarationSchema).optional(),
|
||||
projects: z.array(pluginManagedProjectDeclarationSchema).optional(),
|
||||
routines: z.array(pluginManagedRoutineDeclarationSchema).optional(),
|
||||
localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(),
|
||||
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||
ui: z.object({
|
||||
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
|
||||
@@ -529,6 +648,46 @@ export const pluginManifestV1Schema = z.object({
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.agents && manifest.agents.length > 0) {
|
||||
if (!manifest.capabilities.includes("agents.managed")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'agents.managed' is required when managed agents are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.projects && manifest.projects.length > 0) {
|
||||
if (!manifest.capabilities.includes("projects.managed")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'projects.managed' is required when managed projects are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.routines && manifest.routines.length > 0) {
|
||||
if (!manifest.capabilities.includes("routines.managed")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'routines.managed' is required when managed routines are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.localFolders && manifest.localFolders.length > 0) {
|
||||
if (!manifest.capabilities.includes("local.folders")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'local.folders' is required when local folders are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// jobs require jobs.schedule (PLUGIN_SPEC.md §17)
|
||||
if (manifest.jobs && manifest.jobs.length > 0) {
|
||||
if (!manifest.capabilities.includes("jobs.schedule")) {
|
||||
@@ -664,6 +823,54 @@ export const pluginManifestV1Schema = z.object({
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.localFolders) {
|
||||
const folderKeys = manifest.localFolders.map((folder) => folder.folderKey);
|
||||
const duplicates = folderKeys.filter((key, i) => folderKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate local folder keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["localFolders"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.agents) {
|
||||
const agentKeys = manifest.agents.map((agent) => agent.agentKey);
|
||||
const duplicates = agentKeys.filter((key, i) => agentKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate managed agent keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["agents"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.projects) {
|
||||
const projectKeys = manifest.projects.map((project) => project.projectKey);
|
||||
const duplicates = projectKeys.filter((key, i) => projectKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate managed project keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["projects"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.routines) {
|
||||
const routineKeys = manifest.routines.map((routine) => routine.routineKey);
|
||||
const duplicates = routineKeys.filter((key, i) => routineKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate managed routine keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["routines"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// UI slot ids must be unique within the plugin (namespaced at runtime)
|
||||
if (manifest.ui) {
|
||||
if (manifest.ui.slots) {
|
||||
|
||||
Reference in New Issue
Block a user