Add external object provider plugin contract

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-23 00:35:04 +00:00
1 parent abca7f0ef3
commit d880cb1a9a
22 files changed
+981 -6

No files matched your search

+26
View File
@@ -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:
@@ -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"
}
}
@@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as plugin } from "./worker.js";
@@ -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;
@@ -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);
@@ -0,0 +1,9 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023", "DOM"]
},
"include": ["src"]
}
+39
View File
@@ -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:
+39
View File
@@ -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<PluginApiResponse>;
/**
* 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<DetectExternalObjectsResult>;
/**
* 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<PluginExternalObjectResolveResult>;
/**
* 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<RefreshExternalObjectsResult>;
/**
* Called to validate provider-specific configuration for a plugin-hosted
* environment driver.
+13
View File
@@ -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,
+119
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
remoteVersion?: string | null;
etag?: string | null;
ttlSeconds?: number;
}
export type PluginExternalObjectResolveResult =
| { ok: true; snapshot: PluginExternalObjectResolvedSnapshot }
| {
ok: false;
liveness: Extract<ExternalObjectLivenessState, "auth_required" | "unreachable">;
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",
+2
View File
@@ -84,6 +84,8 @@ export type {
PluginDatabaseDeclaration,
PluginApiRouteDeclaration,
PluginApiRouteCompanyResolution,
PluginObjectReferenceRefreshPolicy,
PluginObjectReferenceProviderDeclaration,
PluginRecord,
PluginDatabaseNamespaceRecord,
PluginMigrationRecord,
@@ -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`),
+4
View File
@@ -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",
+2
View File
@@ -801,6 +801,8 @@ export type {
PluginDatabaseDeclaration,
PluginApiRouteCompanyResolution,
PluginApiRouteDeclaration,
PluginObjectReferenceRefreshPolicy,
PluginObjectReferenceProviderDeclaration,
PaperclipPluginManifestV1,
PluginRecord,
PluginDatabaseNamespaceRecord,
+2
View File
@@ -559,6 +559,8 @@ export type {
PluginDatabaseDeclaration,
PluginApiRouteCompanyResolution,
PluginApiRouteDeclaration,
PluginObjectReferenceRefreshPolicy,
PluginObjectReferenceProviderDeclaration,
PaperclipPluginManifestV1,
PluginRecord,
PluginDatabaseNamespaceRecord,
+27
View File
@@ -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.
+88
View File
@@ -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<typeof pluginApiRouteDeclarationSchema>;
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) {
@@ -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",
});
});
});
@@ -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<void> {
const deadline = Date.now() + 1_000;
while (responses.length < count && Date.now() < deadline) {
+3 -1
View File
@@ -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,
});
+157 -5
View File
@@ -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<ExternalObjectRecord, "providerKey" | "objectType">,
) {
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<string, unknown>,
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<string, unknown>,
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<PluginExternalObjectResolveResult | null> {
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)
@@ -99,6 +99,10 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"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<string, PluginCapability> = {
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) {