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) {