From bf753b997af7a361d596b0d7240b2f6858c803fe Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:55:11 -0500 Subject: [PATCH] fix(connections): keep task context through Cloud enrollment and OAuth (#13098) ## Thinking Path > - Paperclip lets people manage agent work from tasks. > - Agents can request app access in a task card. > - Some apps first require Paperclip Cloud enrollment. > - Enrollment could leave the task, and OAuth could lose the task interaction ID. > - This PR keeps enrollment in a separate window and retains the interaction ID through OAuth. > - The task can then recognize the connection and continue automatically. ## Linked Issues or Issue Description **What happened?** A first Gmail connection could leave the task dialog during Cloud enrollment. Setup resumed on the Apps page. Gmail connected, but the task card could remain pending because OAuth did not retain its interaction ID. **Expected behavior** Keep the task open and preserve its access choices. Resolve the card after the server verifies connection access. Continue the agent automatically. **Steps to reproduce** 1. Start a fresh source test-drive instance without Cloud enrollment. 2. Ask an agent to read Gmail. 3. Open Connect on the task card. 4. Complete Cloud enrollment and Gmail authorization. 5. Check whether the task card updates without selecting the connection again. **Paperclip version or commit** Reproduced on 35fdc0c66b802fe8592611b5391b67e40ecdf14a. This branch applies the fix to current master. **Deployment mode** Source test-drive in local-trusted mode. The shared setup code also serves authenticated instances; live authenticated acceptance was not performed. Related PRs: #13058 introduced task connections. #12943 repaired expired enrollment links. #12906 concerns Composio service matching and does not fix this OAuth handoff. ## What Changed - Open task enrollment in a reserved window, with a new-tab fallback. - Refresh server enrollment status and the provider catalog while keeping the task dialog and access choices. - Return the enrollment callback to the verified task when available. - Retain the interaction ID when OAuth resumes from the page host. - Clear the server capability cache after enrollment and reject stale cache writes. - Close reserved popups on enrollment errors and invalid authorization URLs. - Add callback and setup regression tests, including blocked popups. Update connection-intent documentation. ## Verification - All 176 focused connector, enrollment, callback, OAuth, and setup tests pass. - Greptile gives commit `e58da6662` a 5/5 score. Both review threads are resolved. - `pnpm check:token-gates`, `pnpm build`, and `pnpm -r typecheck` pass. - All CI checks pass for `e58da6662`, including the full test matrix, browser tests, Runner verification, release registry, and canary dry run. - The serial local `pnpm test:run` was stopped after the complete CI test matrix passed. It is not counted as a full local pass. - Live browser test: fresh instance, native Codex runner, Gmail read-only access, and a real Google account. - Enrollment preserved the task dialog. The card changed to connected and the agent called Gmail search and message-read tools without another message or Run click. - The connected card persisted after refresh. Google reused existing consent during this attempt. - The runner displayed only its completion summary. Full answer delivery is a separate issue and is outside this PR. ## Risks - Browsers can block or isolate authorization windows. The new-tab fallback remains available, and the parent checks server state. - Enrollment completion is only a prerequisite. It does not grant access or resolve the task card by itself. - No database migration, runner lifecycle change, or recovery UI is included. ## Model Used OpenAI Codex (GPT-6), with code editing, shell tools, and browser automation. The session does not expose an exact runtime model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connection-intents.md | 2 + .../tool-access-connection-intent.test.ts | 23 ++++++ server/src/routes/tool-access.ts | 33 +++++++- .../paperclip-cloud-connector.test.ts | 34 ++++++++ .../src/services/paperclip-cloud-connector.ts | 11 ++- .../connections/ConnectionSetupFlow.tsx | 44 ++++++++++- ui/src/pages/apps/AppsConnect.test.tsx | 78 +++++++++++++++++++ 7 files changed, 219 insertions(+), 6 deletions(-) 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}