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 05735b3d87.
**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 <noreply@paperclip.ing>
This commit is contained in:
parent
b55ce03e86
commit
d293dd3d14
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue