From a913ebe4f4bd2e2d93ed57c2c67516d8228e67ab Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 5 Oct 2026 10:40:38 -0500 Subject: [PATCH] Integrate connection pools with the native connector workflow Declare pooled connectors through plugin metadata and use the existing catalog, setup header, account page, and sidebar. Select only saved connections, preserve ordered membership, and keep optional runtime and usage settings in Advanced. Route ordinary removal through pin-preserving pool archival. Co-Authored-By: Paperclip --- doc/connections/AI-CONNECTION-ROUTERS.md | 26 ++- packages/shared/src/ai-connection-router.ts | 19 ++ packages/shared/src/index.ts | 2 + packages/shared/src/types/app-definition.ts | 2 +- packages/shared/src/types/plugin.ts | 2 + .../shared/src/validators/app-definition.ts | 2 +- packages/shared/src/validators/plugin.test.ts | 5 + packages/shared/src/validators/plugin.ts | 5 + .../__tests__/ai-connection-router.test.ts | 28 ++- server/src/routes/tool-access.ts | 18 +- server/src/services/ai-connection-router.ts | 18 +- ui/public/brands/connection-pool.svg | 1 + ui/src/api/ai-connection-pools.ts | 9 + .../AppConnectionSidebar.production.tsx | 9 +- .../AiConnectionPoolConnector.tsx | 173 ++++++++++++++++++ .../AiConnectionUsagePanel.test.tsx | 8 + .../ai-connections/AiConnectionUsagePanel.tsx | 9 +- .../connections/ConnectionSetupFlow.tsx | 72 +------- .../connections/ConnectionSetupHeader.tsx | 76 ++++++++ ui/src/pages/apps/AppDetail.tsx | 15 +- ui/src/pages/apps/AppsConnect.tsx | 11 ++ ui/src/pages/apps/Browse.tsx | 11 +- ui/src/pages/apps/app-definition-display.ts | 5 + .../ConnectionPoolConnectorHost.tsx | 82 +++++++++ .../connection-pool-connector.stories.tsx | 21 +++ .../stories/connection-pools.stories.tsx | 2 +- 26 files changed, 533 insertions(+), 98 deletions(-) create mode 100644 ui/public/brands/connection-pool.svg create mode 100644 ui/src/api/ai-connection-pools.ts create mode 100644 ui/src/components/ai-connections/AiConnectionPoolConnector.tsx create mode 100644 ui/src/features/connections/ConnectionSetupHeader.tsx create mode 100644 ui/storybook/prototypes/ConnectionPoolConnectorHost.tsx create mode 100644 ui/storybook/stories/connection-pool-connector.stories.tsx diff --git a/doc/connections/AI-CONNECTION-ROUTERS.md b/doc/connections/AI-CONNECTION-ROUTERS.md index 282b66f5dc5..3075673593e 100644 --- a/doc/connections/AI-CONNECTION-ROUTERS.md +++ b/doc/connections/AI-CONNECTION-ROUTERS.md @@ -43,18 +43,30 @@ Native recovery retains concrete routing evidence and can finish after the router flag or plugin is disabled or uninstalled; it revalidates underlying account access and never makes a new allocation. -The private Cloud plugin owns round-robin and quota policy. Its configuration -page uses authenticated, company-scoped Core pool APIs. New allocations over +The private Cloud plugin owns round-robin and quota policy. Its manifest declares +`aiConnectionRouter: { name, description }` alongside `ai.connections.route`. +Core adds that connector to the regular catalog and hosts its setup and account +management using authenticated, company-scoped pool APIs. No separate plugin +page or sidebar entry is needed. New allocations over the chosen threshold wait; known exhaustion defers pinned turns. The deferred run schedules `ai_connection_pool_wait` without spending the failure retry budget, and the UI labels that retry as **Pool exhausted**. -Operators create, list, edit and delete pools in that plugin page. Deletion uses +Operators choose **Connectors → AI connection pool → Add connection pool**, +select existing accounts, arrange their order, and create a paused connection. +The normal account page supports rename, member changes, enabling, and removal. +Optional usage and runtime defaults live under **Advanced**. Creating another +account opens the normal catalog in a new tab so the pool draft stays intact. +The catalog marks experimental-disabled or unavailable plugins as unavailable. + +Deletion uses `DELETE /api/companies/:companyId/ai-connection-pools/:poolId` with the current `expectedRevision`. Core disables and archives the virtual connection, retaining its task pins, cursor and run records. It rejects stale edits and new allocations; already admitted runs keep their concrete recovery evidence. Cleanup remains -available when experimental routing or the plugin is disabled. +available when experimental routing or the plugin is disabled. The ordinary +connector removal endpoint uses the same pool archive transaction. Ordinary +connection updates cannot bypass pool config revisions. Configuration and committed selection are recorded in activity records. Runs record the selected member/profile and override notes in the local run log and @@ -77,5 +89,7 @@ to build the preview. Pool configuration stories belong to the private Cloud plugin's own Storybook in `extensions/plugin-connection-pool/storybook/`. Both previews use fictional accounts; they do not call live providers or mutate a Paperclip instance. -The Core overview links to Cloud's pool list preview on port 6010; from there, -use **New pool** or select an existing pool to edit, inspect or delete it. +Core also owns **Connectors / Pool host** stories for the generic native +extension. The overview links to Cloud's **In Connectors** preview on port 6010; +its stories mount these same production routes, header, sidebar and tokens. +**Full Setup And Management** exercises the complete catalog-to-pool journey. diff --git a/packages/shared/src/ai-connection-router.ts b/packages/shared/src/ai-connection-router.ts index 34b12476860..2920dc03f1d 100644 --- a/packages/shared/src/ai-connection-router.ts +++ b/packages/shared/src/ai-connection-router.ts @@ -75,3 +75,22 @@ export interface AiConnectionRouterSelection { runtimeConfig: Record; notes: string[]; } + +/** Stable, collision-free catalog identity across plugin reinstallations. */ +export function aiConnectionRouterSlug(pluginKey: string): string { + return `ai-router-${Array.from(pluginKey, character => character.charCodeAt(0).toString(16).padStart(2, "0")).join("")}`; +} + +export function aiConnectionRouterAppDefinition(pluginKey: string, descriptor: { name: string; description: string }, availability = { available: true } as { available: boolean; reason?: string }): import("./types/app-definition.js").AppDefinition { + return { + schemaVersion: 1, slug: aiConnectionRouterSlug(pluginKey), ...descriptor, + categories: ["ai"], branding: { logoUrl: "/brands/connection-pool.svg" }, urlPatterns: [], + aiConnectionRouter: { pluginKey }, availability, + methods: [{ key: "pool", purpose: "ai", transport: "runtime_auth", auth: "none", ownershipModes: ["customer"], whenToUse: "Use existing AI connections", guidanceMd: "", riskTier: "S1" }], + }; +} + +export function aiConnectionRouterPluginKey(connection: { config?: Record | null }): string | null { + const router = connection.config?.aiRouter; + return router && typeof router === "object" && "pluginKey" in router && typeof router.pluginKey === "string" ? router.pluginKey : null; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index decc47ab805..32142770214 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2817,3 +2817,5 @@ export * from "./browser-use.js"; export * from "./types/skill-source.js"; export * from "./validators/skill-source.js"; export * from "./github-skill-repository.js"; + +export { aiConnectionRouterSlug, aiConnectionRouterAppDefinition, aiConnectionRouterPluginKey } from "./ai-connection-router.js"; diff --git a/packages/shared/src/types/app-definition.ts b/packages/shared/src/types/app-definition.ts index de6cc3d98fe..76c27936c80 100644 --- a/packages/shared/src/types/app-definition.ts +++ b/packages/shared/src/types/app-definition.ts @@ -3,7 +3,7 @@ export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"| export type OAuthRedirectConstraints = "https-or-loopback-http"; export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; hidden?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} } export interface ConnectionMethodDef { ai?: import("../ai-connections.js").AiConnectionMetadata; key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"|"agentmail"|"imessage-photon"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; oauthClientSecretRequired?:boolean; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] } -export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> } +export interface AppDefinition { aiConnectionRouter?: { pluginKey: string }; schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> } export type SelfServeMcpAuthMode = | "dcr" diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 68662753a33..42643d3b00e 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -687,6 +687,8 @@ export interface PaperclipPluginManifestV1 { minimumPaperclipVersion?: PluginMinimumHostVersion; /** Capabilities this plugin requires from the host. Enforced at runtime. */ capabilities: PluginCapability[]; + /** Opt into the native pooled-connection catalog, setup, and management UI. */ + aiConnectionRouter?: { name: string; description: string }; /** Entrypoint paths relative to the package root. */ entrypoints: { /** Path to the worker entrypoint (required). */ diff --git a/packages/shared/src/validators/app-definition.ts b/packages/shared/src/validators/app-definition.ts index d55b839e1f8..f34c747266a 100644 --- a/packages/shared/src/validators/app-definition.ts +++ b/packages/shared/src/validators/app-definition.ts @@ -7,5 +7,5 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{ },{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"}); const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})}); export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),oauthClientSecretRequired:z.boolean().optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})}); -export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}); +export const appDefinitionSchema=z.object({aiConnectionRouter:z.object({pluginKey:z.string().min(1)}).optional(),schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}); export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})}); diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 043a048b1ea..3c3b46cc9bf 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -33,6 +33,11 @@ describe("plugin capability constants", () => { }); describe("plugin manifest validators", () => { + it("requires routing authority for native pooled connector declarations without a custom UI bundle", () => { + const manifest = { id: "example.pool", apiVersion: 1, version: "0.1.0", displayName: "Pool", description: "Pool", author: "Tests", categories: ["connector"], entrypoints: { worker: "worker.js" }, aiConnectionRouter: { name: "AI connection pool", description: "Use saved connections" } }; + expect(pluginManifestV1Schema.safeParse({ ...manifest, capabilities: ["ui.page.register"] }).success).toBe(false); + expect(pluginManifestV1Schema.parse({ ...manifest, capabilities: ["ai.connections.route"] }).aiConnectionRouter).toEqual(manifest.aiConnectionRouter); + }); it("accepts existing-style plugins that do not request access or authorization capabilities", () => { const parsed = pluginManifestV1Schema.parse({ id: "paperclip.compat-dashboard", diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 55a915a0694..00249771642 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -782,6 +782,7 @@ export const pluginManifestV1Schema = z.object({ "minimumPaperclipVersion must follow semver (e.g. 1.0.0)", ).optional(), capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)).min(1), + aiConnectionRouter: z.object({ name: z.string().trim().min(1).max(100), description: z.string().trim().min(1).max(500) }).strict().optional(), entrypoints: z.object({ worker: z.string().min(1), ui: z.string().min(1).optional(), @@ -835,6 +836,10 @@ export const pluginManifestV1Schema = z.object({ // declare every capability it needs up-front; silently having more features // than capabilities would cause runtime rejections. + if (manifest.aiConnectionRouter && !manifest.capabilities.includes("ai.connections.route")) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "ai.connections.route is required for aiConnectionRouter", path: ["capabilities"] }); + } + // tools require agent.tools.register (PLUGIN_SPEC.md §11) if (manifest.tools && manifest.tools.length > 0) { if (!manifest.capabilities.includes("agent.tools.register")) { diff --git a/server/src/__tests__/ai-connection-router.test.ts b/server/src/__tests__/ai-connection-router.test.ts index 02650b98293..9cdebba7fc1 100644 --- a/server/src/__tests__/ai-connection-router.test.ts +++ b/server/src/__tests__/ai-connection-router.test.ts @@ -17,6 +17,7 @@ import { secretService } from "../services/secrets.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import express from "express"; import request from "supertest"; +import { toolAccessRoutes } from "../routes/tool-access.js"; import { aiConnectionRoutes } from "../routes/ai-connections.js"; import { errorHandler } from "../middleware/index.js"; import { heartbeatService, buildEffectiveRunSessionConfigMetadata, resolveTaskSessionConfigFreshness } from "../services/heartbeat.js"; @@ -29,7 +30,7 @@ let db: ReturnType; let home: string; const companyId = randomUUID(), otherCompanyId = randomUUID(), agentId = randomUUID(), secondAgentId = randomUUID(), pluginId = randomUUID(); const pluginKey = "fixture.ai-router"; -const manifest: PaperclipPluginManifestV1 = { id: pluginKey, apiVersion: 1, version: "0.1.0", displayName: "Fixture router", description: "Fixture", author: "Tests", categories: ["connector"], capabilities: ["ai.connections.route"], entrypoints: { worker: "worker.js" } }; +const manifest: PaperclipPluginManifestV1 = { id: pluginKey, apiVersion: 1, version: "0.1.0", displayName: "Fixture router", description: "Fixture", author: "Tests", categories: ["connector"], capabilities: ["ai.connections.route"], aiConnectionRouter: { name: "AI connection pool", description: "Use saved connections" }, entrypoints: { worker: "worker.js" } }; let members: AiConnectionPoolMember[]; const proposal = vi.fn(async (_id: string, _method: string, request: AiConnectionRouterRequest): Promise => { const next = (request.memberOrder.indexOf(request.lastMemberId ?? "") + 1) % request.memberOrder.length; @@ -63,10 +64,35 @@ afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) describe("durable, authorized connection routing", () => { it("defaults off and does not expose selectable pools before opt-in", async () => { expect((await instanceSettingsService(db).getExperimental()).enableAiConnectionRouters).toBe(false); + expect((await service().catalog())[0]?.availability).toEqual({ available: false, reason: "Enable AI connection routers in Experimental settings." }); await expect(makePool()).rejects.toThrow("Experimental"); expect(await service().selectable(companyId, "alice")).toEqual([]); await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true }); }); + it("registers native connectors only for declared installed routers", async () => { + const entry = (await service().catalog())[0]!; + expect(entry.name).toBe("AI connection pool"); + expect(entry.aiConnectionRouter).toEqual({ pluginKey }); + expect(entry.availability?.available).toBe(true); + await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId)); + try { + expect((await service().catalog())[0]?.availability?.available).toBe(false); + await db.update(plugins).set({ status: "uninstalled" }).where(eq(plugins.id, pluginId)); + expect(await service().catalog()).toEqual([]); + } finally { await db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId)); } + }); + it("native connector removal retains durable pins and ordinary updates cannot bypass pool revisions", async () => { + const pool = await makePool(); const selected = await resolve(pool.id, "catalog-removal"); + const app = express(); app.use(express.json()); + app.use((req, _res, next) => { req.actor = { type: "board", userId: "alice", source: "local_implicit", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "owner" }] } as typeof req.actor; next(); }); + app.use("/api", toolAccessRoutes(db)); app.use(errorHandler); + expect((await request(app).patch(`/api/tool-connections/${pool.id}`).send({ name: "Bypass" })).status).toBe(400); + expect((await request(app).delete(`/api/tool-connections/${pool.id}`)).status).toBe(200); + expect((await service().list(companyId)).some(row => row.id === pool.id)).toBe(false); + expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(1); + expect(await resolve(pool.id, "catalog-removal", { persisted: selected })).toEqual(selected); + expect((await db.select().from(toolConnections).where(eq(toolConnections.id, members[0]!.binding.connectionId)))[0]?.status).toBe("active"); + }); it("requires company connection management authority on pool configuration routes", async () => { const app = express(); app.use(express.json()); let actor = { type: "board", userId: "alice", source: "session", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "owner" }] }; diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index ecf346118b7..e0c2308f545 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -1,3 +1,5 @@ +import { aiConnectionRouterPluginKey } from "@paperclipai/shared"; +import { aiConnectionRouterService } from "../services/ai-connection-router.js"; import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclipai/db"; import { agents, companies, connectionGrants, issueThreadInteractions, toolConnectionInstalls } from "@paperclipai/db"; @@ -854,9 +856,9 @@ function connectorEnrollmentPrincipal(req: Request): string { : "Vercel Connect setup is disabled on this Paperclip instance.", }, }, - apps: APP_STORE_DEFINITIONS.filter((app) => (enableMemoryConnectors || !isMemoryConnectorId(app.slug))).map((app) => + apps: [...APP_STORE_DEFINITIONS.filter((app) => (enableMemoryConnectors || !isMemoryConnectorId(app.slug))).map((app) => appWithPaperclipCloudConnectorAvailability(app, advertisedProfiles) - ), + ), ...await aiConnectionRouterService(db).catalog()], }); }); @@ -2082,6 +2084,7 @@ function connectorEnrollmentPrincipal(req: Request): string { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; await assertToolConnectionConfigureAccess(req, existing); + if (aiConnectionRouterPluginKey(existing)) throw badRequest("Update this connection pool through its pool settings."); const connection = await svc.updateConnection(existing.id, req.body); const lifecycleChanges = classifyConnectionUpdate( { enabled: existing.enabled, config: existing.config }, @@ -2126,6 +2129,17 @@ function connectorEnrollmentPrincipal(req: Request): string { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; await assertToolConnectionConfigureAccess(req, existing); + if (aiConnectionRouterPluginKey(existing)) { + assertBoard(req); + if (!await isToolConnectionManager(req, existing.companyId)) throw forbidden("A connection manager must remove this pool"); + const router = aiConnectionRouterService(db); + const pool = (await router.list(existing.companyId)).find(pool => pool.id === existing.id); + if (!pool) throw notFound("Connection pool not found"); + await router.remove(existing.companyId, existing.id, pool.revision, getActorInfo(req).actorId); + res.json({ ...existing, status: "archived", enabled: false }); + return; + } + const applicationBefore = await svc.getApplication(existing.applicationId); const { connection, removal } = await svc.archiveConnection( existing.id, diff --git a/server/src/services/ai-connection-router.ts b/server/src/services/ai-connection-router.ts index 3db4a692cb9..df7694e6ce7 100644 --- a/server/src/services/ai-connection-router.ts +++ b/server/src/services/ai-connection-router.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { and, eq, ne, sql } from "drizzle-orm"; import { aiConnectionPools, aiConnectionRouterCursors, aiConnectionTaskPins, companySecrets, connectionGrants, instanceSettings, plugins, toolApplications, toolConnections, toolConnectionInstalls, type Db } from "@paperclipai/db"; -import { aiConnectionPoolConfigSchema, isAiConnectionCompatible, type AiConnectionPool, type AiConnectionPoolMember, type AiConnectionRouterRequest, type AiConnectionRouterSelection, type AiConnectionPoolSaveInput, type AiConnectionUsage } from "@paperclipai/shared"; +import { aiConnectionRouterAppDefinition, aiConnectionRouterSlug, aiConnectionPoolConfigSchema, isAiConnectionCompatible, type AiConnectionPool, type AiConnectionPoolMember, type AiConnectionRouterRequest, type AiConnectionRouterSelection, type AiConnectionPoolSaveInput, type AiConnectionUsage } from "@paperclipai/shared"; import { isCodexLocalKnownModel, codexLocalReasoningEffortsForModel } from "@paperclipai/adapter-codex-local"; import { models as claudeModels, claudeLocalReasoningEffortsForModel } from "@paperclipai/adapter-claude-local"; import { models as grokModels, grokLocalReasoningEffortsForModel } from "@paperclipai/adapter-grok-local"; @@ -76,6 +76,16 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa async function enabled() { if (!(await instanceSettingsService(db).getExperimental()).enableAiConnectionRouters) throw unprocessable("Enable AI connection routers in Experimental settings", { code: "ai_connection_router_disabled" }); } + async function catalog() { + const experimental = (await instanceSettingsService(db).getExperimental()).enableAiConnectionRouters; + const installed = await db.select().from(plugins); + return installed.filter(plugin => plugin.status !== "uninstalled" && plugin.manifestJson.aiConnectionRouter && plugin.manifestJson.capabilities.includes("ai.connections.route")) + .map(plugin => aiConnectionRouterAppDefinition(plugin.pluginKey, plugin.manifestJson.aiConnectionRouter!, { + available: experimental && plugin.status === "ready", + ...(!experimental ? { reason: "Enable AI connection routers in Experimental settings." } + : plugin.status !== "ready" ? { reason: "Enable the connection pool plugin in Plugins." } : {}), + })); + } async function list(companyId: string) { const rows = await db.select({ pool: aiConnectionPools }).from(aiConnectionPools) .innerJoin(toolConnections, and(eq(toolConnections.id, aiConnectionPools.id), eq(toolConnections.companyId, aiConnectionPools.companyId))) @@ -129,10 +139,10 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa await tx.update(toolConnections).set({ name: config.name, enabled: config.enabled, updatedAt: new Date() }).where(and(eq(toolConnections.id, id), eq(toolConnections.companyId, input.companyId))); } else { const key = `plugin:${pluginKey}:ai-router`; - await tx.insert(toolApplications).values({ companyId: input.companyId, applicationKey: key, name: `${plugin.manifestJson.displayName} connections`, type: "paperclip_plugin", pluginId: plugin.id }).onConflictDoNothing(); + await tx.insert(toolApplications).values({ companyId: input.companyId, applicationKey: key, name: plugin.manifestJson.aiConnectionRouter?.name ?? plugin.manifestJson.displayName, metadata: { sourceTemplateKey: aiConnectionRouterSlug(pluginKey) }, type: "paperclip_plugin", pluginId: plugin.id }).onConflictDoNothing(); const [application] = await tx.select().from(toolApplications).where(and(eq(toolApplications.companyId, input.companyId), eq(toolApplications.applicationKey, key))); if (!application) throw new Error("Router application could not be created"); - await tx.insert(toolConnections).values({ id, companyId: input.companyId, applicationId: application.id, name: config.name, uid: id, connectionPurpose: "ai", transport: "runtime_auth", enabled: config.enabled, status: "active", healthStatus: "ok", config: { aiRouter: { pluginKey } }, createdByUserId: userId }); + await tx.insert(toolConnections).values({ id, companyId: input.companyId, applicationId: application.id, name: config.name, uid: id, connectionPurpose: "ai", transport: "runtime_auth", enabled: config.enabled, status: "active", healthStatus: "ok", config: { aiRouter: { pluginKey }, sourceTemplateKey: aiConnectionRouterSlug(pluginKey) }, createdByUserId: userId }); await tx.insert(aiConnectionPools).values({ id, companyId: input.companyId, pluginKey, config }); await tx.insert(aiConnectionRouterCursors).values({ poolId: id, companyId: input.companyId }); await tx.insert(toolConnectionInstalls).values({ companyId: input.companyId, connectionId: id, targetType: "company", targetId: input.companyId, createdByUserId: userId }); @@ -269,5 +279,5 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa } throw conflict("Concurrent pool selections changed the cursor; retry this task"); } - return { list, selectable, inspect, save, remove, resolve }; + return { catalog, list, selectable, inspect, save, remove, resolve }; } diff --git a/ui/public/brands/connection-pool.svg b/ui/public/brands/connection-pool.svg new file mode 100644 index 00000000000..a3b2b9a65e2 --- /dev/null +++ b/ui/public/brands/connection-pool.svg @@ -0,0 +1 @@ + diff --git a/ui/src/api/ai-connection-pools.ts b/ui/src/api/ai-connection-pools.ts new file mode 100644 index 00000000000..20ffd060246 --- /dev/null +++ b/ui/src/api/ai-connection-pools.ts @@ -0,0 +1,9 @@ +import type { AiConnectionPool, AiConnectionPoolConfig, AiConnectionUsage } from "@paperclipai/shared"; +import { api } from "./client"; +export type PoolInspection = Record; +export const aiConnectionPoolsApi = { + list: (companyId: string) => api.get(`/companies/${companyId}/ai-connection-pools`), + save: (companyId: string, body: { pluginKey: string; id?: string; expectedRevision?: number; config: AiConnectionPoolConfig }) => api.post(`/companies/${companyId}/ai-connection-pools`, body), + inspect: (companyId: string, id: string) => api.get(`/companies/${companyId}/ai-connection-pools/${id}/inspection`), + remove: (companyId: string, id: string, expectedRevision: number) => api.delete(`/companies/${companyId}/ai-connection-pools/${id}`, { expectedRevision }), +}; diff --git a/ui/src/components/AppConnectionSidebar.production.tsx b/ui/src/components/AppConnectionSidebar.production.tsx index 8eb84c326da..a458fd0adcf 100644 --- a/ui/src/components/AppConnectionSidebar.production.tsx +++ b/ui/src/components/AppConnectionSidebar.production.tsx @@ -1,6 +1,6 @@ import { ChevronLeft } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; -import { humanizeConnectionDisplayName } from "@paperclipai/shared"; +import { aiConnectionRouterPluginKey, aiConnectionRouterSlug, humanizeConnectionDisplayName } from "@paperclipai/shared"; import type { ToolApplication, ToolConnection } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { toolsApi } from "@/api/tools"; @@ -58,6 +58,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) { }); const connection = connectionQuery.data; + const poolPluginKey = connection && aiConnectionRouterPluginKey(connection); const application = props.kind === "application" ? (applicationsQuery.data?.applications ?? []).find((app) => app.id === props.applicationId) : null; @@ -100,11 +101,11 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {