diff --git a/doc/connections/AI-CONNECTION-ROUTERS.md b/doc/connections/AI-CONNECTION-ROUTERS.md index 3075673593e..e484747e0a4 100644 --- a/doc/connections/AI-CONNECTION-ROUTERS.md +++ b/doc/connections/AI-CONNECTION-ROUTERS.md @@ -64,9 +64,10 @@ Deletion uses `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. The ordinary -connector removal endpoint uses the same pool archive transaction. Ordinary -connection updates cannot bypass pool config revisions. +available when experimental routing or the plugin is disabled. The catalog captures the reviewed pool revision before showing its removal +confirmation and uses the pool endpoint. Ordinary connection update and removal +endpoints reject pools so they cannot bypass configuration revisions. Non-managers +see the permission requirement without making a denied management request. Configuration and committed selection are recorded in activity records. Runs record the selected member/profile and override notes in the local run log and diff --git a/packages/shared/src/ai-connection-router.ts b/packages/shared/src/ai-connection-router.ts index 2920dc03f1d..9fbd89c385a 100644 --- a/packages/shared/src/ai-connection-router.ts +++ b/packages/shared/src/ai-connection-router.ts @@ -84,9 +84,9 @@ export function aiConnectionRouterSlug(pluginKey: string): string { 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: [], + categories: ["ai"], branding: { logoUrl: "/brands/apps/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" }], + methods: [], }; } diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index da32aedf637..38d8c208c7b 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -25,7 +25,8 @@ import { SELF_SERVE_MCP_CANDIDATES, SELF_SERVE_MCP_RESEARCH, } from "./self-serve-mcp-research.js"; -import { appDefinitionsSchema } from "./validators/app-definition.js"; +import { aiConnectionRouterAppDefinition } from "./ai-connection-router.js"; +import { appDefinitionSchema, appDefinitionsSchema } from "./validators/app-definition.js"; const googleScope = (scope: string) => `https://www.googleapis.com/auth/${scope}`; @@ -1064,3 +1065,12 @@ describe("tool method permission review", () => { .toEqual(["read-mcp", "read-repos", "contribute-repos", "jobs"]); }); }); + +it("validates native pool catalog entries without inventing an authentication method", () => { + const entry = aiConnectionRouterAppDefinition("example.pool", { name: "AI connection pool", description: "Use saved connections" }); + expect(appDefinitionSchema.parse(entry)).toEqual(entry); + expect(entry.methods).toEqual([]); + expect(appDefinitionSchema.safeParse({ ...entry, aiConnectionRouter: undefined }).success).toBe(false); + expect(appDefinitionSchema.safeParse({ ...entry, methods: APP_STORE_DEFINITIONS[0]!.methods }).success).toBe(false); + expect(appDefinitionSchema.safeParse({ ...entry, categories: ["developer"] }).success).toBe(false); +}); diff --git a/packages/shared/src/validators/app-definition.ts b/packages/shared/src/validators/app-definition.ts index f34c747266a..46043872c4c 100644 --- a/packages/shared/src/validators/app-definition.ts +++ b/packages/shared/src/validators/app-definition.ts @@ -7,5 +7,12 @@ 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({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 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),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()}).superRefine((value, ctx) => { + if (value.aiConnectionRouter) { + if (value.methods.length !== 0) ctx.addIssue({ code: "custom", message: "Pooled connectors use existing connections, not authentication methods", path: ["methods"] }); + if (!value.categories.includes("ai")) ctx.addIssue({ code: "custom", message: "Pooled connectors must use the AI category", path: ["categories"] }); + } else if (value.methods.length === 0) { + ctx.addIssue({ code: "custom", message: "Connectors require at least one connection method", path: ["methods"] }); + } +}); 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/server/src/__tests__/ai-connection-router.test.ts b/server/src/__tests__/ai-connection-router.test.ts index 9cdebba7fc1..893e292deeb 100644 --- a/server/src/__tests__/ai-connection-router.test.ts +++ b/server/src/__tests__/ai-connection-router.test.ts @@ -85,9 +85,11 @@ describe("durable, authorized connection routing", () => { 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); + app.use("/api", toolAccessRoutes(db)); app.use("/api", aiConnectionRoutes(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 request(app).delete(`/api/tool-connections/${pool.id}`)).status).toBe(400); + expect((await request(app).delete(`/api/companies/${companyId}/ai-connection-pools/${pool.id}`).send({ expectedRevision: pool.revision + 1 })).status).toBe(409); + expect((await request(app).delete(`/api/companies/${companyId}/ai-connection-pools/${pool.id}`).send({ expectedRevision: pool.revision })).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); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index e0c2308f545..42c59ac5def 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -2129,17 +2129,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)) { - 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; - } - + if (aiConnectionRouterPluginKey(existing)) throw badRequest("Remove this connection pool through its revision-checked pool settings."); const applicationBefore = await svc.getApplication(existing.applicationId); const { connection, removal } = await svc.archiveConnection( existing.id, diff --git a/ui/public/brands/connection-pool.svg b/ui/public/brands/apps/connection-pool.svg similarity index 100% rename from ui/public/brands/connection-pool.svg rename to ui/public/brands/apps/connection-pool.svg diff --git a/ui/src/components/ai-connections/AiConnectionPoolConnector.tsx b/ui/src/components/ai-connections/AiConnectionPoolConnector.tsx index 8bbd785f6f0..bf40642e483 100644 --- a/ui/src/components/ai-connections/AiConnectionPoolConnector.tsx +++ b/ui/src/components/ai-connections/AiConnectionPoolConnector.tsx @@ -43,7 +43,7 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string const { pushToast } = useToast(); const { setBreadcrumbs } = useBreadcrumbs(); const accountsQuery = useQuery({ queryKey: ["pool-accounts", companyId], queryFn: () => aiConnectionsApi.list(companyId) }); - const poolsQuery = useQuery({ queryKey: ["ai-connection-pools", companyId], queryFn: () => aiConnectionPoolsApi.list(companyId) }); + const poolsQuery = useQuery({ queryKey: ["ai-connection-pools", companyId], queryFn: () => aiConnectionPoolsApi.list(companyId), enabled: accountsQuery.data?.canManageConnections === true }); const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(companyId), queryFn: () => toolsApi.listGallery(companyId) }); const [editing, setEditing] = useState(); const [draft, setDraft] = useState(emptyConfig); @@ -63,21 +63,21 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string const canManage = Boolean(accountsQuery.data?.canManageConnections); const disabled = busy || !canManage || unavailable; useEffect(() => { - if (connection && !editing) { + if (connection && !editing && canManage) { const pool = poolsQuery.data?.find(pool => pool.id === connection.id && pool.pluginKey === pluginKey); if (pool) { setEditing(pool); setDraft(configOf(pool)); } } - }, [poolsQuery.data, connection, editing, pluginKey]); + }, [poolsQuery.data, connection, editing, pluginKey, canManage]); useEffect(() => { setBreadcrumbs([{ label: "Connectors", href: "/apps" }, { label: connection ? draft.name : "Add a connection pool" }]); return () => setBreadcrumbs([]); }, [connection, draft.name, setBreadcrumbs]); useEffect(() => { - if (!editing) return; + if (!editing || !canManage) return; let alive = true; void aiConnectionPoolsApi.inspect(companyId, editing.id).then(value => { if (alive) setInspection(value); }).catch(() => { if (alive) setInspectionError("Usage unavailable."); }); return () => { alive = false; }; - }, [companyId, editing]); + }, [companyId, editing, canManage]); async function invalidate() { await Promise.all([ client.invalidateQueries({ queryKey: ["ai-connection-pools", companyId] }), @@ -108,11 +108,12 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string setDraft(value => { const members = [...value.members]; [members[index], members[index + offset]] = [members[index + offset]!, members[index]!]; return { ...value, members }; }); } const loadError = accountsQuery.error ?? poolsQuery.error ?? galleryQuery.error; + if (accountsQuery.isSuccess && !canManage) return

A connection manager can view and edit connection pools.

; if (loadError) return

{loadError.message}

; if (accountsQuery.isPending || poolsQuery.isPending || galleryQuery.isPending || (connection && !editing && poolsQuery.data?.some(pool => pool.id === connection.id))) return

Loading connections…

; if (connection && !editing) return

This connection pool is no longer available.

; const name = entry?.name ?? "AI connection pool"; - const logoEntry = entry ?? aiConnectionRouterAppDefinition(pluginKey, { name, description: "" }); + const logoEntry = entry ?? aiConnectionRouterAppDefinition(pluginKey, { name, description: "Use existing AI connections." }); const orderedMembers =
    {draft.members.map((member, index) => { const account = accounts.find(account => account.id === member.binding.connectionId && account.grantId === member.binding.grantId); @@ -132,7 +133,7 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string
; return
{connection ?
- { if (!disabled) { setNameDraft(draft.name); setRenaming(true); } }} onRenameCancel={() => setRenaming(false)} onRenameSubmit={value => void save({ ...draft, name: value })} /> + { if (!disabled) { setNameDraft(draft.name); setRenaming(true); } }} onRenameCancel={() => setRenaming(false)} onRenameSubmit={value => void save({ ...draft, name: value })} /> { setError(""); setRemoving(true); }}>Remove connection
: } {unavailable &&

{entry?.availability?.reason ?? "Enable the connection pool plugin in Plugins."}

} @@ -165,7 +166,7 @@ function ConnectionPicker({ accounts, selected, disabled, onChange, onRefresh }: return
setSearch(event.target.value)} className="pl-9" />
- {filtered.map(account => { const checked = selected.some(member => member.binding.connectionId === account.id); return ; })} + {filtered.map(account => { const existing = selected.find(member => member.binding.connectionId === account.id); const checked = existing?.binding.grantId === account.grantId; return ; })} {filtered.length === 0 &&

{accounts.length ? "No matching connections." : "No AI connections yet."}

}
Connect a new account
diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index d76ff2055fe..4906d927f36 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -728,6 +728,7 @@ export function AppDetailHeader({ allowRemoteLogo, status, actionCount, + canRename = true, renaming, nameDraft, renamePending, @@ -743,6 +744,7 @@ export function AppDetailHeader({ allowRemoteLogo: boolean; status: StatusInfo; actionCount: number | null; + canRename?: boolean; renaming: boolean; nameDraft: string; renamePending: boolean; @@ -794,6 +796,7 @@ export function AppDetailHeader({ size="icon" className="h-7 w-7 text-muted-foreground" aria-label="Rename app" + disabled={!canRename} onClick={onRenameStart} > diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 35ab90ed2ff..e34a3755c0a 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -5,9 +5,13 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Browse } from "./Browse"; -import { getAppStoreDefinition } from "@paperclipai/shared"; +import { aiConnectionRouterAppDefinition, getAppStoreDefinition } from "@paperclipai/shared"; import { queryKeys } from "@/lib/queryKeys"; +const poolListMock = vi.hoisted(() => vi.fn()); +const poolRemoveMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/ai-connection-pools", () => ({ aiConnectionPoolsApi: { list: poolListMock, remove: poolRemoveMock } })); + const listGalleryMock = vi.hoisted(() => vi.fn()); const listApplicationsMock = vi.hoisted(() => vi.fn()); const listConnectionsMock = vi.hoisted(() => vi.fn()); @@ -649,6 +653,29 @@ describe("Connectors landing page", () => { ); }); + it("retains the confirmed pool revision when a concurrent edit rejects removal", async () => { + const app = aiConnectionRouterAppDefinition("example.pool", { name: "AI connection pool", description: "Use saved connections" }); + listGalleryMock.mockResolvedValue({ apps: [app] }); + listApplicationsMock.mockResolvedValue({ applications: [application({ name: app.name, metadata: { sourceTemplateKey: app.slug } })] }); + listConnectionsMock.mockResolvedValue({ connections: [connection({ name: "Research", config: { aiRouter: { pluginKey: "example.pool" } } })] }); + poolListMock.mockResolvedValue([{ id: "conn-notion", name: "Research", revision: 7 }]); + poolRemoveMock.mockRejectedValue(new Error("Pool changed; reload before deleting")); + await renderBrowse(); + await act(() => { container.querySelector('button[aria-label="Manage Research connection"]')!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); }); + await flushReact(); + await act(() => Array.from(document.querySelectorAll('[role="menuitem"]')).find(item => item.textContent?.trim() === "Remove connection")!.click()); + await flushReact(); + expect(document.body.textContent).toContain("The connections in this pool are kept."); + poolListMock.mockResolvedValue([{ id: "conn-notion", name: "Edited elsewhere", revision: 8 }]); + await act(() => Array.from(document.querySelectorAll('button')).find(button => button.textContent?.trim() === "Remove connection")!.click()); + await flushReact(); + expect(poolRemoveMock).toHaveBeenCalledWith("company-1", "conn-notion", 7); + expect(poolListMock).toHaveBeenCalledTimes(1); + expect(archiveConnectionMock).not.toHaveBeenCalled(); + expect(document.querySelector('[role="alertdialog"]')).not.toBeNull(); + expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ body: "Pool changed; reload before deleting" })); + }); + it("starts each AgentMail Add connection with a distinct setup identity", async () => { chatListMock.mockResolvedValue([{ id: "chat-draft", provider: "agentmail", status: "draft", assignedAgentName: "Ralph" }]); await renderBrowse(); diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 12709346226..cc0098bf50f 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -38,6 +38,7 @@ import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useToast } from "@/context/ToastContext"; import { queryKeys } from "@/lib/queryKeys"; +import { aiConnectionPoolsApi } from "@/api/ai-connection-pools"; import { toolsApi } from "@/api/tools"; import { emailApi } from "@/api/email"; import { @@ -116,6 +117,7 @@ type ConnectionRemovalTarget = { providerName: string; remainingConnectionCount: number; pool?: boolean; + poolRevision?: number; } & ({ kind: "chat"; provider: ChatProvider } | { kind?: undefined }); // Temporary, page-only hold until Google OAuth verification is approved. @@ -351,6 +353,9 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne } else { await chatEndpointsApi.setup(target.id, { action: "remove" }); } + } else if (target.pool) { + if (!target.poolRevision) throw new Error("Open the confirmation again before removing this pool."); + await aiConnectionPoolsApi.remove(selectedCompanyId!, target.id, target.poolRevision); } else { await toolsApi.archiveConnection(target.id); } @@ -389,6 +394,19 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne }), }); + async function requestRemoval(target: ConnectionRemovalTarget) { + if (!target.pool) { setConnectionToRemove(target); return; } + try { + const pool = (await aiConnectionPoolsApi.list(selectedCompanyId!)).find(pool => pool.id === target.id); + if (!pool) throw new Error("This connection pool is no longer available."); + // Capture the revision when presenting the confirmation. Never replace + // it at submission time: concurrent edits must invalidate this consent. + setConnectionToRemove({ ...target, accountName: pool.name, poolRevision: pool.revision }); + } catch (error) { + pushToast({ title: "Couldn't open the connection pool", body: error instanceof Error ? error.message : "Please try again.", tone: "error" }); + } + } + const gallery = ( (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[] ).filter((entry) => { @@ -731,7 +749,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne row={row} userProfileById={userProfileById} onNavigate={navigate} - onRequestRemove={setConnectionToRemove} + onRequestRemove={target => void requestRemoval(target)} preselectedAgentId={preselectedChatAgentId} chatConnectorsEnabled={chatConnectorsEnabled} /> diff --git a/ui/storybook/prototypes/ConnectionPoolConnectorHost.tsx b/ui/storybook/prototypes/ConnectionPoolConnectorHost.tsx index d3b1958ac27..1ecf11ed919 100644 --- a/ui/storybook/prototypes/ConnectionPoolConnectorHost.tsx +++ b/ui/storybook/prototypes/ConnectionPoolConnectorHost.tsx @@ -44,6 +44,7 @@ export function ConnectionPoolConnectorHost({ pluginKey = "example.pool-router", if (path === `/api/companies/${companyId}/tools/connections`) return reply({ connections: connections() }); if (path === `/api/companies/${companyId}/ai-connections`) return reply({ currentUserId: "dotta", canManageConnections: scenario !== "permission", connections: accounts }); if (path === `/api/companies/${companyId}/ai-connection-pools`) { + if (scenario === "permission") return Response.json({ error: "A connection manager is required" }, { status: 403 }); if (method === "GET") return reply(pools); if (scenario === "conflict") return Response.json({ error: "Pool changed; reload before saving" }, { status: 409 }); const previousPool = pools.find(pool => pool.id === body.id);