diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index 5f8380f7b19..b541ff5caa4 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -12,6 +12,9 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i - Plugin UI runs as same-origin JavaScript inside the main Paperclip app. - Worker-side host APIs are capability-gated. - Plugin UI is not sandboxed by manifest capabilities. +- External object reference providers are trusted-install only in the MVP. + Capabilities gate provider detection/resolution and host API calls, but they + are not a sandbox boundary for untrusted marketplace code. - Plugin database migrations are restricted to a host-derived plugin namespace. - Plugin-managed surfaces are first-class records (agents, projects, routines, and skills) rather than private plugin-only state. @@ -22,6 +25,29 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i building custom versions. - `ctx.assets` is not supported in the current runtime. +## External object reference providers + +Plugins can contribute provider-neutral object reference detection and status +resolution for URLs and future explicit links. Declare `objectReferences` in the +manifest and add at least `external.objects.detect` and `external.objects.read`. + +```ts +objectReferences: [ + { + providerKey: "mocktracker", + displayName: "Mock Tracker", + objectTypes: ["ticket"], + urlPatterns: ["https://mock.example/tickets/:id"], + }, +], +``` + +Implement `onDetectExternalObjects()` in the worker to recognize sanitized URL +candidates and return provider-stable identities. Implement +`onResolveExternalObject()` to return normalized board-safe status metadata. +Paperclip owns inline markdown rendering; plugins must not return React, HTML, +or `dangerouslySetInnerHTML` content for inline references. + ## Scaffold a plugin Use the CLI scaffold command: diff --git a/packages/plugins/examples/plugin-mock-object-provider-example/package.json b/packages/plugins/examples/plugin-mock-object-provider-example/package.json new file mode 100644 index 00000000000..5a16968c881 --- /dev/null +++ b/packages/plugins/examples/plugin-mock-object-provider-example/package.json @@ -0,0 +1,27 @@ +{ + "name": "@paperclipai/plugin-mock-object-provider-example", + "version": "0.1.0", + "description": "Reference plugin that contributes a provider-neutral external object reference provider", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.ts" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js" + }, + "scripts": { + "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + }, + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/plugins/examples/plugin-mock-object-provider-example/src/index.ts b/packages/plugins/examples/plugin-mock-object-provider-example/src/index.ts new file mode 100644 index 00000000000..b569179db25 --- /dev/null +++ b/packages/plugins/examples/plugin-mock-object-provider-example/src/index.ts @@ -0,0 +1,2 @@ +export { default as manifest } from "./manifest.js"; +export { default as plugin } from "./worker.js"; diff --git a/packages/plugins/examples/plugin-mock-object-provider-example/src/manifest.ts b/packages/plugins/examples/plugin-mock-object-provider-example/src/manifest.ts new file mode 100644 index 00000000000..3a7950fe58c --- /dev/null +++ b/packages/plugins/examples/plugin-mock-object-provider-example/src/manifest.ts @@ -0,0 +1,32 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const manifest: PaperclipPluginManifestV1 = { + id: "paperclipai.mock-object-provider", + apiVersion: 1, + version: "0.1.0", + displayName: "Mock Object Provider", + description: "Reference external object provider for non-GitHub URL detection and status resolution.", + author: "Paperclip", + categories: ["connector"], + capabilities: [ + "external.objects.detect", + "external.objects.read", + ], + entrypoints: { + worker: "./dist/worker.js", + }, + objectReferences: [ + { + providerKey: "mocktracker", + displayName: "Mock Tracker", + objectTypes: ["ticket"], + urlPatterns: ["https://mock.example/tickets/:id"], + refreshPolicy: { + defaultTtlSeconds: 300, + staleAfterSeconds: 1800, + }, + }, + ], +}; + +export default manifest; diff --git a/packages/plugins/examples/plugin-mock-object-provider-example/src/worker.ts b/packages/plugins/examples/plugin-mock-object-provider-example/src/worker.ts new file mode 100644 index 00000000000..7058d72060f --- /dev/null +++ b/packages/plugins/examples/plugin-mock-object-provider-example/src/worker.ts @@ -0,0 +1,49 @@ +import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; + +const MOCK_TICKET_RE = /^https:\/\/mock\.example\/tickets\/([a-z0-9_-]+)$/i; + +const plugin = definePlugin({ + async setup(ctx) { + ctx.logger.info("Mock object provider ready"); + }, + + async onDetectExternalObjects(params) { + return { + detections: params.urls.flatMap((url) => { + const match = MOCK_TICKET_RE.exec(url.sanitizedCanonicalUrl); + if (!match?.[1]) return []; + const id = match[1].toUpperCase(); + return [{ + urlIdentityHash: url.canonicalIdentityHash, + providerKey: "mocktracker", + objectType: "ticket", + externalId: `MOCK-${id}`, + displayTitle: `Mock ticket ${id}`, + confidence: "exact" as const, + }]; + }), + }; + }, + + async onResolveExternalObject(params) { + return { + ok: true as const, + snapshot: { + displayTitle: `Resolved ${params.externalId}`, + statusKey: "ready", + statusLabel: "Ready", + statusCategory: "succeeded" as const, + statusTone: "success" as const, + isTerminal: false, + data: { + provider: "mocktracker", + externalId: params.externalId, + }, + ttlSeconds: 300, + }, + }; + }, +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/examples/plugin-mock-object-provider-example/tsconfig.json b/packages/plugins/examples/plugin-mock-object-provider-example/tsconfig.json new file mode 100644 index 00000000000..2ba21645416 --- /dev/null +++ b/packages/plugins/examples/plugin-mock-object-provider-example/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2023", "DOM"] + }, + "include": ["src"] +} diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 020b0b8dedc..f70b4bcf31f 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -341,6 +341,10 @@ Declare in `manifest.capabilities`. Grouped by scope: | | `telemetry.track` | | | `database.namespace.migrate` | | | `database.namespace.write` | +| | `external.objects.detect` | +| | `external.objects.read` | +| | `external.objects.write` | +| | `external.objects.refresh` | | **Instance** | `instance.settings.register` | | | `plugin.state.read` | | | `plugin.state.write` | @@ -372,6 +376,41 @@ Declare in `manifest.capabilities`. Grouped by scope: Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`. +### External Object Reference Providers + +Trusted connector plugins can declare generic external object providers in the +manifest. The host owns URL scanning, sanitized canonical URLs, core storage, +normalized status rendering, and issue/comment/document write durability. The +plugin only identifies provider-owned objects and resolves board-safe status +metadata. + +```ts +objectReferences: [ + { + providerKey: "mocktracker", + displayName: "Mock Tracker", + objectTypes: ["ticket"], + urlPatterns: ["https://mock.example/tickets/:id"], + refreshPolicy: { defaultTtlSeconds: 300, staleAfterSeconds: 1800 }, + }, +], +capabilities: ["external.objects.detect", "external.objects.read"], +``` + +Implement `onDetectExternalObjects()` to map sanitized URL candidates to +`providerKey`, `objectType`, and provider-stable `externalId`. Implement +`onResolveExternalObject()` to return a normalized snapshot with +`statusCategory`, `statusTone`, `statusLabel`, board-safe `data`, and freshness +metadata. Slow or failing plugins are isolated: Paperclip logs the failure and +continues saving the source issue, comment, or document. + +MVP security posture: provider plugins are trusted installs. Manifest +capabilities gate host APIs and provider invocation paths, but they are not a +sandbox boundary for untrusted marketplace code. Plugin UI is same-origin +JavaScript and must not be mounted inline in markdown; inline external-object +rendering uses host-owned metadata only. Treat untrusted providers as future work +that requires worker sandboxing plus isolated plugin UI. + ### Restricted Database Namespace Trusted orchestration plugins can declare a host-owned PostgreSQL namespace: diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts index e13b939acf8..b0e4ecff6e5 100644 --- a/packages/plugins/sdk/src/define-plugin.ts +++ b/packages/plugins/sdk/src/define-plugin.ts @@ -62,6 +62,12 @@ import type { PluginEnvironmentResumeLeaseParams, PluginEnvironmentValidateConfigParams, PluginEnvironmentValidationResult, + DetectExternalObjectsParams, + DetectExternalObjectsResult, + ResolveExternalObjectParams, + PluginExternalObjectResolveResult, + RefreshExternalObjectsParams, + RefreshExternalObjectsResult, } from "./protocol.js"; // --------------------------------------------------------------------------- @@ -243,6 +249,39 @@ export interface PluginDefinition { * access, capabilities, and checkout policy. */ onApiRequest?(input: PluginApiRequestInput): Promise; + + /** + * Called when Paperclip scans issue/comment/document content and asks this + * plugin whether any sanitized URL candidates belong to its external object + * providers. The host has already stripped URL userinfo, query strings, and + * fragments unless provider-safe identity components were explicitly hashed. + * + * Requires `external.objects.detect`. + */ + onDetectExternalObjects?( + params: DetectExternalObjectsParams, + ): Promise; + + /** + * Called when Paperclip needs the current normalized status for one external + * object owned by a manifest-declared provider. + * + * Requires `external.objects.read`. + */ + onResolveExternalObject?( + params: ResolveExternalObjectParams, + ): Promise; + + /** + * Optional batch resolver used by providers that can refresh many objects + * more efficiently than individual `onResolveExternalObject` calls. + * + * Requires `external.objects.refresh`. + */ + onRefreshExternalObjects?( + params: RefreshExternalObjectsParams, + ): Promise; + /** * Called to validate provider-specific configuration for a plugin-hosted * environment driver. diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 63ec9c7023f..1339edeafc6 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -155,6 +155,17 @@ export type { PluginPerformActionActorContext, PluginPerformActionContext, ExecuteToolParams, + PluginExternalObjectUrlCandidate, + PluginExternalObjectSourceContext, + DetectExternalObjectsParams, + PluginExternalObjectDetection, + DetectExternalObjectsResult, + PluginExternalObjectRecordSnapshot, + ResolveExternalObjectParams, + PluginExternalObjectResolvedSnapshot, + PluginExternalObjectResolveResult, + RefreshExternalObjectsParams, + RefreshExternalObjectsResult, PluginEnvironmentDiagnostic, PluginEnvironmentDriverBaseParams, PluginEnvironmentValidateConfigParams, @@ -313,6 +324,8 @@ export type { PluginApiRouteDeclaration, PluginLocalFolderDeclaration, PluginCompanySettings, + PluginObjectReferenceRefreshPolicy, + PluginObjectReferenceProviderDeclaration, PluginRecord, PluginDatabaseNamespaceRecord, PluginMigrationRecord, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index a3925049a7f..52560404de9 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -40,6 +40,11 @@ import type { Goal, PluginLocalFolderDeclaration, PrincipalPermissionGrant, + ExternalObjectStatusCategory, + ExternalObjectStatusTone, + ExternalObjectLivenessState, + ExternalObjectMentionConfidence, + ExternalObjectMentionSourceKind, } from "@paperclipai/shared"; export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared"; @@ -432,6 +437,105 @@ export interface ExecuteToolParams { runContext: ToolRunContext; } +export interface PluginExternalObjectUrlCandidate { + sanitizedCanonicalUrl: string; + sanitizedDisplayUrl: string; + canonicalIdentityHash: string; + canonicalIdentity: Record; + redactedMatchedText: string; +} + +export interface PluginExternalObjectSourceContext { + companyId: string; + sourceIssueId: string; + sourceKind: ExternalObjectMentionSourceKind; + sourceRecordId: string | null; + documentKey: string | null; + propertyKey: string | null; +} + +export interface DetectExternalObjectsParams { + companyId: string; + urls: PluginExternalObjectUrlCandidate[]; + sourceContext: PluginExternalObjectSourceContext; +} + +export interface PluginExternalObjectDetection { + urlIdentityHash: string; + providerKey: string; + objectType: string; + externalId: string; + displayTitle?: string | null; + confidence?: ExternalObjectMentionConfidence; +} + +export interface DetectExternalObjectsResult { + detections: PluginExternalObjectDetection[]; +} + +export interface PluginExternalObjectRecordSnapshot { + id: string; + companyId: string; + providerKey: string; + objectType: string; + externalId: string; + sanitizedCanonicalUrl: string | null; + canonicalIdentityHash: string | null; + displayTitle: string | null; + statusKey: string | null; + statusLabel: string | null; + statusCategory: ExternalObjectStatusCategory; + statusTone: ExternalObjectStatusTone; + liveness: ExternalObjectLivenessState; + isTerminal: boolean; + data: Record; + remoteVersion: string | null; + etag: string | null; +} + +export interface ResolveExternalObjectParams { + companyId: string; + providerKey: string; + objectType: string; + externalId: string; + object: PluginExternalObjectRecordSnapshot; +} + +export interface PluginExternalObjectResolvedSnapshot { + displayTitle?: string | null; + statusKey?: string | null; + statusLabel?: string | null; + statusCategory: ExternalObjectStatusCategory; + statusTone: ExternalObjectStatusTone; + isTerminal?: boolean; + data?: Record; + remoteVersion?: string | null; + etag?: string | null; + ttlSeconds?: number; +} + +export type PluginExternalObjectResolveResult = + | { ok: true; snapshot: PluginExternalObjectResolvedSnapshot } + | { + ok: false; + liveness: Extract; + errorCode: string; + errorMessage?: string | null; + retryAfterSeconds?: number; + }; + +export interface RefreshExternalObjectsParams { + companyId: string; + objects: PluginExternalObjectRecordSnapshot[]; +} + +export interface RefreshExternalObjectsResult { + results: Array<{ + objectId: string; + result: PluginExternalObjectResolveResult; + }>; +} + export interface PluginEnvironmentDiagnostic { severity: "info" | "warning" | "error"; message: string; @@ -604,6 +708,18 @@ export interface HostToWorkerMethods { performAction: [params: PerformActionParams, result: unknown]; /** @see PLUGIN_SPEC.md §13.10 */ executeTool: [params: ExecuteToolParams, result: ToolResult]; + detectExternalObjects: [ + params: DetectExternalObjectsParams, + result: DetectExternalObjectsResult, + ]; + resolveExternalObject: [ + params: ResolveExternalObjectParams, + result: PluginExternalObjectResolveResult, + ]; + refreshExternalObjects: [ + params: RefreshExternalObjectsParams, + result: RefreshExternalObjectsResult, + ]; environmentValidateConfig: [ params: PluginEnvironmentValidateConfigParams, result: PluginEnvironmentValidationResult, @@ -659,6 +775,9 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[] "getData", "performAction", "executeTool", + "detectExternalObjects", + "resolveExternalObject", + "refreshExternalObjects", "environmentValidateConfig", "environmentProbe", "environmentAcquireLease", diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 1951205526c..e449252cc67 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -84,6 +84,8 @@ export type { PluginDatabaseDeclaration, PluginApiRouteDeclaration, PluginApiRouteCompanyResolution, + PluginObjectReferenceRefreshPolicy, + PluginObjectReferenceProviderDeclaration, PluginRecord, PluginDatabaseNamespaceRecord, PluginMigrationRecord, diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index cbb223d96c2..cded95a82d7 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -82,6 +82,9 @@ import type { PluginPerformActionActorContext, PluginPerformActionContext, ExecuteToolParams, + DetectExternalObjectsParams, + ResolveExternalObjectParams, + RefreshExternalObjectsParams, PluginEnvironmentAcquireLeaseParams, PluginEnvironmentDestroyLeaseParams, PluginEnvironmentExecuteParams, @@ -1351,6 +1354,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost case "executeTool": return handleExecuteTool(params as ExecuteToolParams); + case "detectExternalObjects": + return handleDetectExternalObjects(params as DetectExternalObjectsParams); + case "resolveExternalObject": + return handleResolveExternalObject(params as ResolveExternalObjectParams); + case "refreshExternalObjects": + return handleRefreshExternalObjects(params as RefreshExternalObjectsParams); case "environmentValidateConfig": return handleEnvironmentValidateConfig(params as PluginEnvironmentValidateConfigParams); @@ -1409,6 +1418,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost if (plugin.definition.onHealth) supportedMethods.push("health"); if (plugin.definition.onShutdown) supportedMethods.push("shutdown"); if (plugin.definition.onApiRequest) supportedMethods.push("handleApiRequest"); + if (plugin.definition.onDetectExternalObjects) supportedMethods.push("detectExternalObjects"); + if (plugin.definition.onResolveExternalObject) supportedMethods.push("resolveExternalObject"); + if (plugin.definition.onRefreshExternalObjects) supportedMethods.push("refreshExternalObjects"); if (plugin.definition.onEnvironmentValidateConfig) supportedMethods.push("environmentValidateConfig"); if (plugin.definition.onEnvironmentProbe) supportedMethods.push("environmentProbe"); if (plugin.definition.onEnvironmentAcquireLease) supportedMethods.push("environmentAcquireLease"); @@ -1588,6 +1600,27 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost return entry.fn(params.parameters, params.runContext); } + async function handleDetectExternalObjects(params: DetectExternalObjectsParams) { + if (!plugin.definition.onDetectExternalObjects) { + throw methodNotImplemented("detectExternalObjects"); + } + return plugin.definition.onDetectExternalObjects(params); + } + + async function handleResolveExternalObject(params: ResolveExternalObjectParams) { + if (!plugin.definition.onResolveExternalObject) { + throw methodNotImplemented("resolveExternalObject"); + } + return plugin.definition.onResolveExternalObject(params); + } + + async function handleRefreshExternalObjects(params: RefreshExternalObjectsParams) { + if (!plugin.definition.onRefreshExternalObjects) { + throw methodNotImplemented("refreshExternalObjects"); + } + return plugin.definition.onRefreshExternalObjects(params); + } + function methodNotImplemented(method: string): Error & { code: number } { return Object.assign( new Error(`${method} is not implemented by this plugin`), diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 4d1eaeabbdb..b2b00681463 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -899,6 +899,10 @@ export const PLUGIN_CAPABILITIES = [ "telemetry.track", "database.namespace.migrate", "database.namespace.write", + "external.objects.detect", + "external.objects.read", + "external.objects.write", + "external.objects.refresh", // Plugin State "plugin.state.read", "plugin.state.write", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 600d07e6457..38fa619499f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -801,6 +801,8 @@ export type { PluginDatabaseDeclaration, PluginApiRouteCompanyResolution, PluginApiRouteDeclaration, + PluginObjectReferenceRefreshPolicy, + PluginObjectReferenceProviderDeclaration, PaperclipPluginManifestV1, PluginRecord, PluginDatabaseNamespaceRecord, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 8d943270d00..fb358d4b6a4 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -559,6 +559,8 @@ export type { PluginDatabaseDeclaration, PluginApiRouteCompanyResolution, PluginApiRouteDeclaration, + PluginObjectReferenceRefreshPolicy, + PluginObjectReferenceProviderDeclaration, PaperclipPluginManifestV1, PluginRecord, PluginDatabaseNamespaceRecord, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 351b2dda0ee..d96070b0ccc 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -498,6 +498,31 @@ export interface PluginApiRouteDeclaration { companyResolution?: PluginApiRouteCompanyResolution; } +export interface PluginObjectReferenceRefreshPolicy { + /** Default freshness window for resolved objects from this provider. */ + defaultTtlSeconds?: number; + /** UI-visible staleness window. Core still stores liveness separately from remote status. */ + staleAfterSeconds?: number; +} + +export interface PluginObjectReferenceProviderDeclaration { + /** Stable provider key such as "github", "linear", or "mocktracker". */ + providerKey: string; + /** Human-readable provider name shown in operator-facing surfaces. */ + displayName: string; + /** Provider object types this plugin can detect and resolve. */ + objectTypes: string[]; + /** + * Human-readable URL patterns this provider recognizes. + * These are metadata for operators and docs; workers still perform detection. + */ + urlPatterns?: string[]; + /** Optional default refresh behavior for this provider. */ + refreshPolicy?: PluginObjectReferenceRefreshPolicy; + /** Optional webhook endpoint keys declared under `webhooks` that can refresh these objects. */ + webhookEndpointKeys?: string[]; +} + // --------------------------------------------------------------------------- // Plugin Manifest V1 // --------------------------------------------------------------------------- @@ -564,6 +589,8 @@ export interface PaperclipPluginManifestV1 { skills?: PluginManagedSkillDeclaration[]; /** Trusted local folders this plugin can configure and access by stable key. */ localFolders?: PluginLocalFolderDeclaration[]; + /** External object reference providers this plugin contributes. */ + objectReferences?: PluginObjectReferenceProviderDeclaration[]; /** * Legacy top-level launcher declarations. * Prefer `ui.launchers` for new manifests. diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 0a857b44d82..873dfb78a1b 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -25,6 +25,7 @@ import { ISSUE_SURFACE_VISIBILITIES, } from "../constants.js"; import { routineVariableSchema } from "./routine.js"; +import { externalObjectProviderKeySchema, externalObjectTypeSchema } from "./external-object.js"; // --------------------------------------------------------------------------- // JSON Schema placeholder – a permissive validator for JSON Schema objects @@ -567,6 +568,55 @@ export const pluginApiRouteDeclarationSchema = z.object({ export type PluginApiRouteDeclarationInput = z.infer; +export const pluginObjectReferenceRefreshPolicySchema = z.object({ + defaultTtlSeconds: z.number().int().positive().max(86_400).optional(), + staleAfterSeconds: z.number().int().positive().max(604_800).optional(), +}).superRefine((value, ctx) => { + if ( + value.defaultTtlSeconds != null && + value.staleAfterSeconds != null && + value.staleAfterSeconds < value.defaultTtlSeconds + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "staleAfterSeconds must be greater than or equal to defaultTtlSeconds", + path: ["staleAfterSeconds"], + }); + } +}); + +export const pluginObjectReferenceProviderDeclarationSchema = z.object({ + providerKey: externalObjectProviderKeySchema, + displayName: z.string().min(1).max(100), + objectTypes: z.array(externalObjectTypeSchema).min(1), + urlPatterns: z.array(z.string().trim().min(1).max(500)).optional(), + refreshPolicy: pluginObjectReferenceRefreshPolicySchema.optional(), + webhookEndpointKeys: z.array(z.string().min(1)).optional(), +}).superRefine((value, ctx) => { + const duplicateObjectTypes = value.objectTypes.filter((type, i) => value.objectTypes.indexOf(type) !== i); + if (duplicateObjectTypes.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate objectTypes: ${[...new Set(duplicateObjectTypes)].join(", ")}`, + path: ["objectTypes"], + }); + } + + const webhookKeys = value.webhookEndpointKeys ?? []; + const duplicateWebhookKeys = webhookKeys.filter((key, i) => webhookKeys.indexOf(key) !== i); + if (duplicateWebhookKeys.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate webhookEndpointKeys: ${[...new Set(duplicateWebhookKeys)].join(", ")}`, + path: ["webhookEndpointKeys"], + }); + } +}); + +export type PluginObjectReferenceProviderDeclarationInput = z.infer< + typeof pluginObjectReferenceProviderDeclarationSchema +>; + // --------------------------------------------------------------------------- // Plugin Manifest V1 schema // --------------------------------------------------------------------------- @@ -646,6 +696,7 @@ export const pluginManifestV1Schema = z.object({ routines: z.array(pluginManagedRoutineDeclarationSchema).optional(), skills: z.array(pluginManagedSkillDeclarationSchema).optional(), localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(), + objectReferences: z.array(pluginObjectReferenceProviderDeclarationSchema).optional(), launchers: z.array(pluginLauncherDeclarationSchema).optional(), ui: z.object({ slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(), @@ -786,6 +837,31 @@ export const pluginManifestV1Schema = z.object({ } } + if (manifest.objectReferences && manifest.objectReferences.length > 0) { + for (const capability of ["external.objects.detect", "external.objects.read"] as const) { + if (!manifest.capabilities.includes(capability)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Capability '${capability}' is required when objectReferences are declared`, + path: ["capabilities"], + }); + } + } + + const declaredWebhookKeys = new Set((manifest.webhooks ?? []).map((webhook) => webhook.endpointKey)); + for (const [providerIndex, provider] of manifest.objectReferences.entries()) { + for (const endpointKey of provider.webhookEndpointKeys ?? []) { + if (!declaredWebhookKeys.has(endpointKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `objectReferences webhookEndpointKey "${endpointKey}" must match a declared webhook endpoint`, + path: ["objectReferences", providerIndex, "webhookEndpointKeys"], + }); + } + } + } + } + if (manifest.database) { const requiredCapabilities = [ "database.namespace.migrate", @@ -949,6 +1025,18 @@ export const pluginManifestV1Schema = z.object({ } } + if (manifest.objectReferences) { + const providerKeys = manifest.objectReferences.map((provider) => provider.providerKey); + const duplicateProviders = providerKeys.filter((key, i) => providerKeys.indexOf(key) !== i); + if (duplicateProviders.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate object reference provider keys: ${[...new Set(duplicateProviders)].join(", ")}`, + path: ["objectReferences"], + }); + } + } + // UI slot ids must be unique within the plugin (namespaced at runtime) if (manifest.ui) { if (manifest.ui.slots) { diff --git a/server/src/__tests__/external-objects-service.test.ts b/server/src/__tests__/external-objects-service.test.ts index f9343acb290..2d9dc9999d4 100644 --- a/server/src/__tests__/external-objects-service.test.ts +++ b/server/src/__tests__/external-objects-service.test.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; import { companies, createDb, @@ -7,6 +8,7 @@ import { externalObjects, issueComments, issues, + plugins, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -19,6 +21,8 @@ import { type ExternalObjectResolver, } from "../services/external-objects.js"; import { canonicalizeExternalObjectUrl } from "@paperclipai/shared"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -133,6 +137,7 @@ describeEmbeddedPostgres("externalObjectService", () => { await db.delete(externalObjects); await db.delete(issueComments); await db.delete(issues); + await db.delete(plugins); await db.delete(companies); vi.restoreAllMocks(); }); @@ -252,4 +257,97 @@ describeEmbeddedPostgres("externalObjectService", () => { expect(new Set(objectRows.map((row) => row.companyId))).toEqual(new Set([first.companyId, second.companyId])); expect(new Set(objectRows.map((row) => row.canonicalIdentityHash))).toHaveSize(1); }); + + it("uses a mock plugin provider to detect and resolve non-GitHub objects", async () => { + const { companyId, issueId } = await createIssue(); + await db + .update(issues) + .set({ + description: "Track https://mock.example/tickets/123?secret=drop", + }) + .where(eq(issues.id, issueId)); + + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.mock-object-provider", + apiVersion: 1, + version: "1.0.0", + displayName: "Mock Object Provider", + description: "Detects mock tracker tickets", + author: "Paperclip", + categories: ["connector"], + capabilities: ["external.objects.detect", "external.objects.read"], + entrypoints: { worker: "dist/worker.js" }, + objectReferences: [ + { + providerKey: "mocktracker", + displayName: "Mock Tracker", + objectTypes: ["ticket"], + urlPatterns: ["https://mock.example/tickets/:id"], + }, + ], + }; + const [plugin] = await db.insert(plugins).values({ + pluginKey: manifest.id, + packageName: "@paperclip/mock-object-provider", + version: manifest.version, + apiVersion: 1, + categories: manifest.categories, + manifestJson: manifest, + status: "ready", + installOrder: 1, + }).returning(); + + const workerManager = { + call: vi.fn(async (pluginId: string, method: string, params: any) => { + expect(pluginId).toBe(plugin!.id); + if (method === "detectExternalObjects") { + return { + detections: params.urls.map((url: any) => ({ + urlIdentityHash: url.canonicalIdentityHash, + providerKey: "mocktracker", + objectType: "ticket", + externalId: "MOCK-123", + displayTitle: "Mock ticket 123", + confidence: "exact", + })), + }; + } + if (method === "resolveExternalObject") { + return { + ok: true, + snapshot: { + displayTitle: `Resolved ${params.externalId}`, + statusKey: "ready", + statusLabel: "Ready", + statusCategory: "succeeded", + statusTone: "success", + ttlSeconds: 300, + }, + }; + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as PluginWorkerManager; + + const svc = externalObjectService(db, { pluginWorkerManager: workerManager }); + await svc.syncIssue(issueId); + + const object = await db.select().from(externalObjects).then((rows) => rows[0]!); + expect(object).toMatchObject({ + companyId, + providerKey: "mocktracker", + objectType: "ticket", + externalId: "MOCK-123", + pluginId: plugin!.id, + sanitizedCanonicalUrl: "https://mock.example/tickets/123", + }); + expect(JSON.stringify(object)).not.toContain("secret"); + + const refreshed = await svc.refreshObject(object.id, { companyId, force: true }); + expect(refreshed.object).toMatchObject({ + statusCategory: "succeeded", + statusTone: "success", + liveness: "fresh", + }); + }); }); diff --git a/server/src/__tests__/plugin-environment-driver-seam.test.ts b/server/src/__tests__/plugin-environment-driver-seam.test.ts index ec89adc75ec..013994099c1 100644 --- a/server/src/__tests__/plugin-environment-driver-seam.test.ts +++ b/server/src/__tests__/plugin-environment-driver-seam.test.ts @@ -159,6 +159,203 @@ describe("plugin environment driver seam", () => { }); }); +const objectReferenceManifest: PaperclipPluginManifestV1 = { + id: "test.external-object-provider", + apiVersion: 1, + version: "1.0.0", + displayName: "External Object Provider", + description: "Test external object provider plugin", + author: "Paperclip", + categories: ["connector"], + capabilities: ["external.objects.detect", "external.objects.read"], + entrypoints: { worker: "dist/worker.js" }, + objectReferences: [ + { + providerKey: "mocktracker", + displayName: "Mock Tracker", + objectTypes: ["ticket"], + urlPatterns: ["https://mock.example/tickets/:id"], + refreshPolicy: { defaultTtlSeconds: 300, staleAfterSeconds: 1800 }, + }, + ], +}; + +describe("plugin external object provider seam", () => { + it("validates provider manifest declarations and capabilities", () => { + expect(pluginManifestV1Schema.safeParse(objectReferenceManifest).success).toBe(true); + + const missingCapability = pluginManifestV1Schema.safeParse({ + ...objectReferenceManifest, + capabilities: ["external.objects.detect"], + }); + expect(missingCapability.success).toBe(false); + expect(JSON.stringify(missingCapability.error?.issues)).toContain("external.objects.read"); + + const duplicateProvider = pluginManifestV1Schema.safeParse({ + ...objectReferenceManifest, + objectReferences: [ + objectReferenceManifest.objectReferences![0], + { ...objectReferenceManifest.objectReferences![0], displayName: "Duplicate" }, + ], + }); + expect(duplicateProvider.success).toBe(false); + expect(JSON.stringify(duplicateProvider.error?.issues)).toContain( + "Duplicate object reference provider keys", + ); + }); + + it("enforces provider capability requirements", () => { + const validator = pluginCapabilityValidator(); + expect(validator.getRequiredCapabilities("external.objects.detect")).toEqual([ + "external.objects.detect", + ]); + expect(validator.getRequiredCapabilities("external.objects.read")).toEqual([ + "external.objects.read", + ]); + expect(validator.checkOperation(objectReferenceManifest, "external.objects.read").allowed).toBe(true); + + const withoutCapability = { + ...objectReferenceManifest, + capabilities: ["external.objects.detect"], + } satisfies PaperclipPluginManifestV1; + + expect(validator.checkOperation(withoutCapability, "external.objects.read")).toMatchObject({ + allowed: false, + missing: ["external.objects.read"], + }); + expect(validator.validateManifestCapabilities(withoutCapability)).toMatchObject({ + allowed: false, + missing: ["external.objects.read"], + }); + }); + + it("dispatches provider detection and resolution worker hooks", async () => { + const plugin = definePlugin({ + async setup() {}, + async onDetectExternalObjects(params) { + return { + detections: params.urls.map((url) => ({ + urlIdentityHash: url.canonicalIdentityHash, + providerKey: "mocktracker", + objectType: "ticket", + externalId: "MOCK-123", + displayTitle: "Mock ticket", + confidence: "exact", + })), + }; + }, + async onResolveExternalObject(params) { + return { + ok: true, + snapshot: { + displayTitle: `Resolved ${params.externalId}`, + statusKey: "ready", + statusLabel: "Ready", + statusCategory: "succeeded", + statusTone: "success", + isTerminal: true, + ttlSeconds: 600, + }, + }; + }, + }); + + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const host = startWorkerRpcHost({ plugin, stdin, stdout }); + const responses: unknown[] = []; + stdout.on("data", (chunk) => { + const lines = String(chunk).split("\n").filter(Boolean); + for (const line of lines) { + responses.push(parseMessage(line)); + } + }); + + stdin.write(serializeMessage(createRequest("initialize", { + manifest: objectReferenceManifest, + config: {}, + instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" }, + apiVersion: 1, + }, 1))); + await waitForResponses(responses, 1); + + const initializeResponse = responses[0]; + expect(isJsonRpcSuccessResponse(initializeResponse)).toBe(true); + if (!isJsonRpcSuccessResponse(initializeResponse)) return; + expect(initializeResponse.result.supportedMethods).toContain("detectExternalObjects"); + expect(initializeResponse.result.supportedMethods).toContain("resolveExternalObject"); + + stdin.write(serializeMessage(createRequest("detectExternalObjects", { + companyId: "company-1", + urls: [{ + sanitizedCanonicalUrl: "https://mock.example/tickets/123", + sanitizedDisplayUrl: "https://mock.example/tickets/123", + canonicalIdentityHash: "hash-123", + canonicalIdentity: { scheme: "https", host: "mock.example", path: "/tickets/123" }, + redactedMatchedText: "https://mock.example/tickets/123", + }], + sourceContext: { + companyId: "company-1", + sourceIssueId: "issue-1", + sourceKind: "description", + sourceRecordId: null, + documentKey: null, + propertyKey: null, + }, + }, 2))); + await waitForResponses(responses, 2); + + const detectResponse = responses[1]; + expect(isJsonRpcSuccessResponse(detectResponse)).toBe(true); + if (!isJsonRpcSuccessResponse(detectResponse)) return; + expect(detectResponse.result.detections[0]).toMatchObject({ + providerKey: "mocktracker", + objectType: "ticket", + externalId: "MOCK-123", + }); + + stdin.write(serializeMessage(createRequest("resolveExternalObject", { + companyId: "company-1", + providerKey: "mocktracker", + objectType: "ticket", + externalId: "MOCK-123", + object: { + id: "object-1", + companyId: "company-1", + providerKey: "mocktracker", + objectType: "ticket", + externalId: "MOCK-123", + sanitizedCanonicalUrl: "https://mock.example/tickets/123", + canonicalIdentityHash: "hash-123", + displayTitle: "Mock ticket", + statusKey: null, + statusLabel: null, + statusCategory: "unknown", + statusTone: "neutral", + liveness: "unknown", + isTerminal: false, + data: {}, + remoteVersion: null, + etag: null, + }, + }, 3))); + await waitForResponses(responses, 3); + + const resolveResponse = responses[2]; + expect(isJsonRpcSuccessResponse(resolveResponse)).toBe(true); + if (!isJsonRpcSuccessResponse(resolveResponse)) return; + expect(resolveResponse.result).toMatchObject({ + ok: true, + snapshot: { + statusCategory: "succeeded", + statusTone: "success", + }, + }); + + host.stop(); + }); +}); + async function waitForResponses(responses: unknown[], count: number): Promise { const deadline = Date.now() + 1_000; while (responses.length < count && Date.now() < deadline) { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index cabc521d22a..29d56e29728 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1108,7 +1108,9 @@ export function issueRoutes( ? heartbeat.wakeup : opts.taskWatchdogEnqueueWakeup ?? undefined, }) ?? noopTaskWatchdogService(); - const externalObjectsSvc = externalObjectService(db); + const externalObjectsSvc = externalObjectService(db, { + pluginWorkerManager: opts.pluginWorkerManager, + }); const routinesSvc = routineService(db, { pluginWorkerManager: opts.pluginWorkerManager, }); diff --git a/server/src/services/external-objects.ts b/server/src/services/external-objects.ts index ba202da59df..e9deef7ca97 100644 --- a/server/src/services/external-objects.ts +++ b/server/src/services/external-objects.ts @@ -1,6 +1,6 @@ import { and, asc, eq, inArray, isNull, lte } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { documents, externalObjectMentions, externalObjects, issueComments, issueDocuments, issues } from "@paperclipai/db"; +import { documents, externalObjectMentions, externalObjects, issueComments, issueDocuments, issues, plugins } from "@paperclipai/db"; import { extractExternalObjectCanonicalUrls, formatExternalObjectMentionSourceLabel, @@ -10,11 +10,14 @@ import { type ExternalObjectMentionSourceKind, type ExternalObjectStatusCategory, type ExternalObjectStatusTone, + type PaperclipPluginManifestV1, } from "@paperclipai/shared"; +import type { PluginExternalObjectRecordSnapshot, PluginExternalObjectResolveResult } from "@paperclipai/plugin-sdk"; import { notFound } from "../errors.js"; import { logger } from "../middleware/logger.js"; import { logActivity, type LogActivityInput } from "./activity-log.js"; import { publishLiveEvent } from "./live-events.js"; +import type { PluginWorkerManager } from "./plugin-worker-manager.js"; export interface ExternalObjectSourceContext { companyId: string; @@ -193,14 +196,162 @@ export function createExternalObjectResolverRegistry(resolvers: ExternalObjectRe return { find }; } +function manifestProvidesObject( + manifest: PaperclipPluginManifestV1, + object: Pick, +) { + return (manifest.objectReferences ?? []).some( + (provider) => + provider.providerKey === object.providerKey && + provider.objectTypes.includes(object.objectType), + ); +} + +function objectSnapshot(object: ExternalObjectRecord): PluginExternalObjectRecordSnapshot { + return { + id: object.id, + companyId: object.companyId, + providerKey: object.providerKey, + objectType: object.objectType, + externalId: object.externalId, + sanitizedCanonicalUrl: object.sanitizedCanonicalUrl, + canonicalIdentityHash: object.canonicalIdentityHash, + displayTitle: object.displayTitle, + statusKey: object.statusKey, + statusLabel: object.statusLabel, + statusCategory: object.statusCategory, + statusTone: object.statusTone, + liveness: object.liveness, + isTerminal: object.isTerminal, + data: object.data as Record, + remoteVersion: object.remoteVersion, + etag: object.etag, + }; +} + +async function readyObjectReferencePlugins(db: Db) { + return db + .select({ + id: plugins.id, + pluginKey: plugins.pluginKey, + manifestJson: plugins.manifestJson, + }) + .from(plugins) + .where(eq(plugins.status, "ready")) + .orderBy(asc(plugins.installOrder)) + .then((rows) => + rows.filter((row) => (row.manifestJson.objectReferences?.length ?? 0) > 0), + ); +} + +function createPluginProviderDetector( + db: Db, + pluginWorkerManager: PluginWorkerManager, +): ExternalObjectDetector { + return { + key: "plugin-object-reference-providers", + async detect(input) { + const providers = await readyObjectReferencePlugins(db); + const detections: ExternalObjectDetection[] = []; + + for (const provider of providers) { + const manifest = provider.manifestJson; + if (!manifest.capabilities.includes("external.objects.detect")) continue; + try { + const result = await pluginWorkerManager.call(provider.id, "detectExternalObjects", { + companyId: input.companyId, + urls: input.urls.map((url) => ({ + sanitizedCanonicalUrl: url.sanitizedCanonicalUrl, + sanitizedDisplayUrl: url.sanitizedDisplayUrl, + canonicalIdentityHash: url.canonicalIdentityHash, + canonicalIdentity: url.canonicalIdentity as unknown as Record, + redactedMatchedText: url.redactedMatchedText, + })), + sourceContext: input.sourceContext, + }); + const urlsByHash = new Map(input.urls.map((url) => [url.canonicalIdentityHash, url])); + const declaredProviderKeys = new Set((manifest.objectReferences ?? []).map((entry) => entry.providerKey)); + + for (const detection of result.detections ?? []) { + const canonical = urlsByHash.get(detection.urlIdentityHash); + if (!canonical) continue; + if (!declaredProviderKeys.has(detection.providerKey)) continue; + const declaration = (manifest.objectReferences ?? []).find((entry) => entry.providerKey === detection.providerKey); + if (!declaration?.objectTypes.includes(detection.objectType)) continue; + detections.push({ + canonical, + detectorKey: `${provider.pluginKey}:${detection.providerKey}`, + providerKey: detection.providerKey, + objectType: detection.objectType, + externalId: detection.externalId, + displayTitle: detection.displayTitle, + confidence: detection.confidence ?? "exact", + pluginId: provider.id, + }); + } + } catch (err) { + logger.warn( + { err, pluginId: provider.id, pluginKey: provider.pluginKey }, + "plugin external object detector failed", + ); + } + } + + return detections; + }, + }; +} + +async function resolveViaPluginProvider( + db: Db, + pluginWorkerManager: PluginWorkerManager | undefined, + object: ExternalObjectRecord, +): Promise { + if (!pluginWorkerManager) return null; + const providers = await readyObjectReferencePlugins(db); + for (const provider of providers) { + const manifest = provider.manifestJson; + if (!manifest.capabilities.includes("external.objects.read")) continue; + if (!manifestProvidesObject(manifest, object)) continue; + try { + return await pluginWorkerManager.call(provider.id, "resolveExternalObject", { + companyId: object.companyId, + providerKey: object.providerKey, + objectType: object.objectType, + externalId: object.externalId, + object: objectSnapshot(object), + }); + } catch (err) { + logger.warn( + { err, pluginId: provider.id, pluginKey: provider.pluginKey, objectId: object.id }, + "plugin external object resolver failed", + ); + return { + ok: false, + liveness: "unreachable", + errorCode: "plugin_resolver_failed", + errorMessage: err instanceof Error ? err.message : String(err), + }; + } + } + return null; +} + export function externalObjectService( db: Db, opts: { detectors?: ExternalObjectDetector[]; resolvers?: ExternalObjectResolver[]; + pluginWorkerManager?: PluginWorkerManager; } = {}, ) { - const detectorRegistry = createExternalObjectDetectorRegistry(opts.detectors ?? []); + const pluginProviderDetector = opts.pluginWorkerManager + ? createPluginProviderDetector(db, opts.pluginWorkerManager) + : null; + const detectorRegistry = createExternalObjectDetectorRegistry([ + ...(pluginProviderDetector ? [pluginProviderDetector] : []), + ...(opts.detectors ?? []), + ]); const resolverRegistry = createExternalObjectResolverRegistry(opts.resolvers ?? []); async function issueById(issueId: string, dbOrTx: any = db) { @@ -558,8 +709,9 @@ export function externalObjectService( return { object: toObjectPayload(object, now), refreshed: false, reason: "backoff" as const }; } - const resolver = resolverRegistry.find(object); - if (!resolver) { + const pluginResult = await resolveViaPluginProvider(db, opts.pluginWorkerManager, object); + const resolver = pluginResult ? null : resolverRegistry.find(object); + if (!pluginResult && !resolver) { const [updated] = await db .update(externalObjects) .set({ @@ -572,7 +724,7 @@ export function externalObjectService( return { object: toObjectPayload(updated ?? object, now), refreshed: false, reason: "no_resolver" as const }; } - const result = await resolver.resolve({ companyId: object.companyId, object }); + const result = pluginResult ?? await resolver!.resolve({ companyId: object.companyId, object }); if (!result.ok) { const [updated] = await db .update(externalObjects) diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index f8b47866c35..9e01b696b5a 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -99,6 +99,10 @@ const OPERATION_CAPABILITIES: Record = { "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"], @@ -188,6 +192,7 @@ const FEATURE_CAPABILITIES: Record = { agents: "agents.managed", projects: "projects.managed", routines: "routines.managed", + objectReferences: "external.objects.detect", }; // --------------------------------------------------------------------------- @@ -452,6 +457,14 @@ export function pluginCapabilityValidator(): PluginCapabilityValidator { } } + 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) {