From d293dd3d144ef50d973d8832d67966754cab789a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:05:43 -0500 Subject: [PATCH] fix(connections): refresh expired pending enrollment links (#12943) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Managed connections deliver credentials to an approved instance. > - A self-hosted instance starts with a time-limited approval link. > - The setup page cached that link and reused it after expiry. > - This pull request lets the existing server choose a valid enrollment link. > - Users can recover without another setup step or a service restart. ## Linked Issues or Issue Description Related: #12891 (one-time enrollment) and #12907 (sandbox GitHub identity). No duplicate open enrollment-recovery PR or issue was found. **What happened?** After a pending Cloud enrollment expired, Continue opened the same expired approval link. Cloud returned ENROLLMENT_NOT_AVAILABLE. Returning to setup and selecting Continue repeated the failure. **Expected behavior** Continue must ask the server for a valid enrollment. The server must reuse a live pending enrollment and replace an expired one. Existing approved instances must not need another approval. **Steps to reproduce** 1. Start GitHub setup on a fresh self-hosted instance. 2. Start Cloud enrollment but leave it unapproved until the link expires. 3. Return to step 2 and select Continue. 4. Before this change, the browser opens the expired link again. **Paperclip version or commit** Reproduced from master commit 05735b3d87a240fefb13233a18efbc93da5b72cd. **Deployment mode** Fresh source test-drive instance, connected to staging Cloud. ## What Changed - Always call the existing enrollment-start endpoint from Continue. - Retain the server's expiry, pending-enrollment reuse, and concurrency rules. - Test both expired and future-dated cached enrollment responses. - Document recovery from an expired unapproved link. ## Verification - Five targeted connection suites: 150 tests passed. - `pnpm check:token-gates`: passed. - Live browser: reproduced the expired-link loop, then recovered with this fix. - Live browser: approved once, returned to step 2, connected GitHub without a restart or repeated Cloud approval, and ran GitHub MCP get_me successfully. - Separate new managed staging stack: connected without self-hosted enrollment; an actual Daytona task verified the responsible user's GitHub identity, authenticated API access, both token variables, Git helper configuration, shallow clone/fetch, and author/committer metadata. No repository writes. - Repository-wide typecheck and build passed. - The full local unit command is not claimed green: macOS temporary-path fixture failures, Linux `/proc` listener checks, and overlapping local service-port tests affected the runs. The two isolated workspace-control and remote-profile suites pass unchanged with `TMPDIR=/private/tmp` (30 tests). A further canonical-path run passed 216 of 220 tests; remaining failures were in the unrelated runtime-exposure suite. Superseded full runs were stopped after exact-head CI completed successfully. - Exact-head CI is green: all general and serialized test shards, typecheck, build, canary dry run, and all three browser shards passed. Storybook was skipped by its normal path filter. Greptile is 5/5 with no findings or review threads. The PR is mergeable. ## Risks - Low risk: one additional local API request before navigation when a cached pending URL exists. The request already exists for first enrollment. - Cloud remains the approval authority. This change does not extend link expiry, bypass approval, change permissions, or change active enrollment. - No schema, Cloud service, dependency, or telemetry changes. - The first sandbox agent guessed the wrong Paperclip API hostname when reporting its result. Recovery succeeded with the supplied API URL. A second bounded task verified GitHub identity, reported its result, and reached Done in 36 seconds without recovery. No service change was needed for that mistake. ## Model Used OpenAI Codex. The runtime does not expose the exact model identifier or context window to this task. Used reasoning, source editing, shell tests, and live browser verification. No model version or window size is inferred. ## 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/connections/GITHUB.md | 11 +++++ .../connections/ConnectionSetupFlow.tsx | 6 +-- ui/src/pages/apps/AppsConnect.test.tsx | 41 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/doc/connections/GITHUB.md b/doc/connections/GITHUB.md index 2b7b4df0c6..f79febf07f 100644 --- a/doc/connections/GITHUB.md +++ b/doc/connections/GITHUB.md @@ -5,6 +5,17 @@ compatibility method. Cloud owns the fixed public OAuth callback and signed webhook inbox; provider tokens are sealed to the enrolled instance and stored only in its existing encrypted secret system. +## Self-hosted setup + +A self-hosted instance needs one Paperclip Cloud approval before its first +managed connection. After approval, setup returns to step 2 and continues to +GitHub without another instance approval or a service restart. + +If an unapproved enrollment link expires, return to setup and select +**Continue**. Paperclip asks the server for a valid link. The server reuses a +live pending enrollment or replaces an expired one; this does not revoke or +repeat an existing instance approval. + ## Identity resolution Every MCP call, `gh` invocation, native Git operation, checkout, health check, diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 295de8a693..266dd58039 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -1973,9 +1973,9 @@ export function ConnectionSetupFlow({ onClick={() => { setConnectorEnrollmentError(null); preserveEnrollmentAccess(); - const verificationUrl = connectorEnrollmentQuery.data?.verificationUrl; - if (verificationUrl) openConnectorEnrollment(verificationUrl); - else startConnectorEnrollment.mutate(); + // Let the server reuse a live enrollment or replace an expired + // one. A cached verification URL may expire while this page is open. + startConnectorEnrollment.mutate(); }} > {startConnectorEnrollment.isPending ? : null} diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index 14ce1ae01a..5cd3d6a28d 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -733,6 +733,47 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { ); }); + it.each(["2020-01-01T00:00:00.000Z", "2099-01-01T00:00:00.000Z"])( + "revalidates a cached pending enrollment before continuing (expiry %s)", + async (expiresAt) => { + mockSearch.value = "source=github&stage=setup"; + listGalleryMock.mockResolvedValue({ + apps: [{ + ...GITHUB, + methods: GITHUB.methods.filter((method) => !method.oauthStrategy), + ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, + }], + }); + getCloudConnectorEnrollmentMock.mockResolvedValue({ + configured: false, + status: "pending", + brokerBaseUrl: "https://my-staging.paperclip.app", + instanceId: "inst-test", + environment: "staging", + origins: [], + verificationUrl: "https://my-staging.paperclip.app/connections/enroll?id=cached", + expiresAt, + }); + + await render(); + expect(container.textContent).toContain("Step 2 of 2"); + await act(async () => { + buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(startCloudConnectorEnrollmentMock).toHaveBeenCalledWith( + "company-1", "Paperclip", "/apps/connect?source=github&stage=setup", + ); + expect(navigateTopLevelMock).toHaveBeenCalledWith( + "https://my-staging.paperclip.app/connections/enroll?id=enroll-test", + ); + expect(navigateTopLevelMock).not.toHaveBeenCalledWith( + "https://my-staging.paperclip.app/connections/enroll?id=cached", + ); + }, + ); + it("keeps GitHub's personal identity defaults while its managed method awaits enrollment", async () => { mockParams.appKey = "github"; listGalleryMock.mockResolvedValue({