diff --git a/doc/connection-intents.md b/doc/connection-intents.md index 261589761d..ef848e2abf 100644 --- a/doc/connection-intents.md +++ b/doc/connection-intents.md @@ -11,6 +11,8 @@ Connection intents let an agent ask the responsible user for a known service con Provider-specific setup must stay in the shared feature and `AppDefinition` metadata. Do not add provider forms or connection mutations to either host. +When a task connection needs Paperclip Cloud enrollment, the shared dialog opens enrollment in a separate window. The task keeps its access selection and interaction ID. A new-tab link is available if the window does not open. The dialog reads server enrollment status and refreshes the provider catalog after approval; enrollment alone does not mark the app connected. OAuth retains the interaction ID even if setup resumes in the page host, so the verified callback can resolve the task card and queue its continuation. + ## Agent tools Every active heartbeat with a responsible user receives two run-bound tools: diff --git a/server/src/routes/tool-access-connection-intent.test.ts b/server/src/routes/tool-access-connection-intent.test.ts index 9f438528f9..6382dceffb 100644 --- a/server/src/routes/tool-access-connection-intent.test.ts +++ b/server/src/routes/tool-access-connection-intent.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { cloudConnectorEnrollmentReturnPath, + cloudConnectorEnrollmentOutcomeHtml, connectionIntentOAuthOutcomeHtml, } from "./tool-access.js"; @@ -94,3 +95,25 @@ describe("connection intent OAuth callback document", () => { expect(html).toContain('window.location.replace("/issues")'); }); }); + +describe("inline enrollment completion", () => { + it("closes enrollment without redirecting the task and retains a safe setup fallback", () => { + const html = cloudConnectorEnrollmentOutcomeHtml("GMA", "/apps/connect?source=gmail&intent=request-1&enrollment_host=dialog"); + expect(html).toContain("window.close()"); + expect(html).not.toContain("window.location"); + expect(html).toContain("/GMA/apps/connect?source=gmail&intent=request-1"); + expect(html).toContain("cloud_connector=enrolled"); + }); + + it("does not embed an external fallback or script-significant return path", () => { + expect(cloudConnectorEnrollmentOutcomeHtml("GMA", "https://evil.example/")).not.toContain("evil.example"); + expect(cloudConnectorEnrollmentOutcomeHtml("GMA", "/apps/connect?source=")).not.toContain("source="); + }); + + it("returns a task enrollment fallback to its verified task", () => { + const html = cloudConnectorEnrollmentOutcomeHtml("GMA", "/apps/connect?source=gmail", "task-1"); + expect(html).toContain("Return to task"); + expect(html).toContain("/GMA/issues/task-1"); + expect(html).not.toContain("/apps/connect"); + }); +}); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 9d6503320b..df78af3f53 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -59,6 +59,7 @@ import type { ComposioClient } from "../services/composio.js"; import type { VercelConnectClient } from "../services/vercel-connect.js"; import { isPaperclipCloudConnectorStrategy, + invalidatePaperclipCloudConnectorCapabilities, type PaperclipCloudConnector, paperclipCloudConnectorCapabilitiesFromEnv, } from "../services/paperclip-cloud-connector.js"; @@ -202,6 +203,16 @@ function normalizeCloudConnectorEnrollmentReturnTo(returnTo?: string | null): st } } +export function cloudConnectorEnrollmentOutcomeHtml(issuePrefix: string, returnTo: string, issueId?: string): string { + const fallbackPath = issueId + ? `/${encodeURIComponent(issuePrefix)}/issues/${encodeURIComponent(issueId)}` + : cloudConnectorEnrollmentReturnPath(issuePrefix, returnTo); + const fallback = JSON.stringify(fallbackPath).replaceAll("<", "\\u003c"); + // This document is served only after server-verified enrollment. The parent + // independently re-reads enrollment status; browser messages grant no access. + return `Paperclip connected

Paperclip is connected. Return to your task to finish connecting the app.

`; +} + export function cloudConnectorEnrollmentReturnPath(issuePrefix: string, returnTo?: string | null): string { const companyRoot = `/${encodeURIComponent(issuePrefix)}`; const normalizedReturnTo = normalizeCloudConnectorEnrollmentReturnTo(returnTo); @@ -1041,7 +1052,7 @@ function connectorEnrollmentPrincipal(req: Request): string { } const [company] = pending?.companyId ? await db - .select({ issuePrefix: companies.issuePrefix }) + .select({ id: companies.id, issuePrefix: companies.issuePrefix }) .from(companies) .where(eq(companies.id, pending.companyId)) .limit(1) @@ -1050,6 +1061,7 @@ function connectorEnrollmentPrincipal(req: Request): string { let status; try { status = await completePaperclipCloudConnectorEnrollment({ enrollmentId, approvalCode, state }); + invalidatePaperclipCloudConnectorCapabilities(); } catch { throw badRequest("Invalid or expired Paperclip Cloud enrollment callback"); } @@ -1064,7 +1076,24 @@ function connectorEnrollmentPrincipal(req: Request): string { details: { environment: status.environment, status: status.status }, }); } - res.redirect(303, cloudConnectorEnrollmentReturnPath(company.issuePrefix, pending?.returnTo)); + const returnTo = normalizeCloudConnectorEnrollmentReturnTo(pending?.returnTo); + if (returnTo && new URL(returnTo, "http://paperclip.local").searchParams.get("enrollment_host") === "dialog") { + res.set("Cache-Control", "no-store"); + const intent = new URL(returnTo, "http://paperclip.local").searchParams.get("intent"); + const parsedIntent = startToolOAuthSchema.safeParse({ interactionId: intent }); + const interactionId = parsedIntent.success ? parsedIntent.data.interactionId : undefined; + const [interaction] = interactionId ? await db.select({ issueId: issueThreadInteractions.issueId }) + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.id, interactionId), + eq(issueThreadInteractions.companyId, company.id), + eq(issueThreadInteractions.addresseeUserId, req.actor.userId ?? ""), + eq(issueThreadInteractions.kind, "connection_intent"), + )).limit(1) : []; + res.type("html").send(cloudConnectorEnrollmentOutcomeHtml(company.issuePrefix, returnTo, interaction?.issueId)); + return; + } + res.redirect(303, cloudConnectorEnrollmentReturnPath(company.issuePrefix, returnTo)); }); const handlePaperclipCloudConnectorCallback = async (req: Request, res: Response) => { diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index 1f52c38ad9..dc1db310b3 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from "vitest"; import { createPaperclipCloudConnector, + invalidatePaperclipCloudConnectorCapabilities, GMAIL_CONNECTOR_SCOPES, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, paperclipCloudConnectorCapabilitiesFromEnv, @@ -45,6 +46,39 @@ function config() { } describe("Paperclip Cloud connector", () => { + it("refreshes capabilities after enrollment and rejects stale cache writes", async () => { + const keys = config().config; + const env = { + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: keys.baseUrl, + PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: keys.instanceId, + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: keys.environment, + PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY: keys.signPrivateKey, + PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY: keys.sealPrivateKey, + }; + let completeOldRequest!: (response: Response) => void; + const request = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json({ status: "pending", active: false })) + .mockImplementationOnce(() => new Promise((resolve) => { completeOldRequest = resolve; })) + .mockResolvedValue(Response.json({ status: "active", active: true, profiles: ["gmail.read"] })); + invalidatePaperclipCloudConnectorCapabilities(); + try { + await expect(paperclipCloudConnectorCapabilitiesFromEnv(env)).resolves.toEqual([]); + await expect(paperclipCloudConnectorCapabilitiesFromEnv(env)).resolves.toEqual([]); + expect(request).toHaveBeenCalledTimes(1); + invalidatePaperclipCloudConnectorCapabilities(); + const oldRequest = paperclipCloudConnectorCapabilitiesFromEnv(env); + invalidatePaperclipCloudConnectorCapabilities(); + await expect(paperclipCloudConnectorCapabilitiesFromEnv(env)).resolves.toEqual(["gmail.read"]); + completeOldRequest(Response.json({ status: "pending", active: false })); + await expect(oldRequest).resolves.toEqual([]); + await expect(paperclipCloudConnectorCapabilitiesFromEnv(env)).resolves.toEqual(["gmail.read"]); + expect(request).toHaveBeenCalledTimes(3); + } finally { + request.mockRestore(); + invalidatePaperclipCloudConnectorCapabilities(); + } + }); + it("starts a signed session with exact endpoint audience and scope contract", async () => { const keys = config(); const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index d03f7011bc..3a9a018ddf 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -488,6 +488,12 @@ export function isPaperclipCloudConnectorStrategy(value: unknown): boolean { } let capabilityCache: { key: string; expiresAt: number; profiles: PaperclipCloudConnectorProfileId[] } | null = null; +let capabilityCacheGeneration = 0; + +export function invalidatePaperclipCloudConnectorCapabilities(): void { + capabilityCacheGeneration += 1; + capabilityCache = null; +} export async function paperclipCloudConnectorCapabilitiesFromEnv( env: NodeJS.ProcessEnv = process.env, @@ -506,9 +512,12 @@ export async function paperclipCloudConnectorCapabilitiesFromEnv( if (!config) return []; const key = `${config.baseUrl}|${config.instanceId}|${config.environment}`; if (capabilityCache?.key === key && capabilityCache.expiresAt > Date.now()) return capabilityCache.profiles; + const generation = capabilityCacheGeneration; const connector = createPaperclipCloudConnector({ config }); const profiles = await connector.getCapabilities(); - capabilityCache = { key, expiresAt: Date.now() + 60_000, profiles }; + if (generation === capabilityCacheGeneration) { + capabilityCache = { key, expiresAt: Date.now() + 60_000, profiles }; + } return profiles; } diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 0b40289c46..2857bbbefb 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -908,9 +908,11 @@ export function ConnectionSetupFlow({ && !entryAdvertisesManagedConnector ? recommendedManagedConnectorMethod(fullRequestedDefinition) : null; + const [enrollmentAuthorizationUrl, setEnrollmentAuthorizationUrl] = useState(null); const connectorEnrollmentQuery = useQuery({ queryKey: ["cloud-connector", "enrollment"], queryFn: () => toolsApi.getCloudConnectorEnrollment(), + refetchInterval: enrollmentAuthorizationUrl ? 2_000 : false, enabled: Boolean( selectedCompanyId && requestedDefinitionUsesManagedConnector @@ -918,6 +920,11 @@ export function ConnectionSetupFlow({ ), }); const [connectorEnrollmentError, setConnectorEnrollmentError] = useState(null); + const closeEnrollmentPopup = useCallback(() => { + oauthPopupRef.current?.close(); + oauthPopupRef.current = null; + setEnrollmentAuthorizationUrl(null); + }, []); const preserveEnrollmentAccess = useCallback(() => { if (!selectedCompanyId || !requestedAppKey) return; saveEnrollmentAccessState(selectedCompanyId, requestedAppKey, { @@ -929,11 +936,31 @@ export function ConnectionSetupFlow({ const openConnectorEnrollment = useCallback((verificationUrl: string) => { const target = resolveAuthorizationTarget(verificationUrl); if (!target.ok) { + closeEnrollmentPopup(); setConnectorEnrollmentError(target.message); return; } + if (host === "dialog") { + setEnrollmentAuthorizationUrl(target.url); + const popup = oauthPopupRef.current; + if (popup && !popup.closed) { + popup.location.assign(target.url); + popup.focus(); + } else { + setConnectorEnrollmentError("Open authorization in a new tab to continue."); + } + return; + } navigateTopLevel(target.url); - }, []); + }, [host, closeEnrollmentPopup]); + useEffect(() => { + if (!enrollmentAuthorizationUrl || connectorEnrollmentQuery.data?.status !== "active") return; + // Enrollment is only a prerequisite. Re-read the server catalog and keep + // the task's access selection and interaction binding in this dialog. + closeEnrollmentPopup(); + setConnectorEnrollmentError(null); + void galleryQuery.refetch(); + }, [enrollmentAuthorizationUrl, connectorEnrollmentQuery.data?.status, galleryQuery.refetch, closeEnrollmentPopup]); const startConnectorEnrollment = useMutation({ mutationFn: () => toolsApi.startCloudConnectorEnrollment( selectedCompanyId!, @@ -943,17 +970,19 @@ export function ConnectionSetupFlow({ resumeConnectionId, reconnectConnectionId, interactionId: connectionIntentId, - }) + }) + (host === "dialog" ? "&enrollment_host=dialog" : "") : undefined, ), onSuccess: (status) => { if (!status.verificationUrl) { + closeEnrollmentPopup(); setConnectorEnrollmentError("Paperclip Cloud did not return an enrollment link. Try again."); return; } openConnectorEnrollment(status.verificationUrl); }, onError: (error) => { + closeEnrollmentPopup(); setConnectorEnrollmentError( error instanceof Error ? error.message : "Paperclip couldn’t reach Paperclip Cloud. Try again.", ); @@ -1229,7 +1258,7 @@ export function ConnectionSetupFlow({ setGenericOAuthPending(false); return; } - if (host === "dialog") { + if (host === "dialog" || connectionIntentId) { setOAuthPhase("starting"); setGenericOAuthPending(!entry); startOAuth(result.connection); @@ -2087,6 +2116,14 @@ export function ConnectionSetupFlow({ ) : null} + {enrollmentAuthorizationUrl ? ( +

+ Finish authorization in the opened window.{' '} + + Open authorization in a new tab + +

+ ) : null}