fix(connections): reuse one-time cloud enrollment (#12891)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Managed connections let agents use provider credentials without
exposing those credentials to the control plane UI
> - A self-hosted instance must first establish a trusted credential
destination with Paperclip Cloud
> - The GitHub connection flow repeated that trust decision before
provider consent
> - The local setup route also lost step 2 after enrollment and could
display the PAT identity defaults before enrollment
> - This pull request makes enrollment a one-time instance decision and
sends later provider starts directly to provider consent
> - The benefit is a shorter flow with one clear Paperclip approval and
no required service restart

## Linked Issues or Issue Description

Refs #12843.

Companion Cloud change:
[paperclipai/paperclip-cloud#391](https://github.com/paperclipai/paperclip-cloud/pull/391).

## What Changed

- Made `stage=setup` authoritative during initial route hydration and
enrollment return.
- Added a contained one-time enrollment screen with provider-specific
copy.
- Accepted a provider `authorizationUrl` from Paperclip Cloud only when
it matches the exact GitHub or Google OAuth endpoint.
- Preferred the direct provider URL while retaining the legacy
confirmation URL fallback.
- Preserved the company-bound identity and agent-access draft across the
full-page enrollment callback, including cold company-context hydration.
- Kept GitHub defaulted to “My GitHub account” and “Any agent,”
including before Cloud advertises the managed method.
- Updated GitHub identity and agent-access copy for responsible-person
and dedicated-agent behavior.
- Labeled the provider action “Continue to GitHub.”
- Added parser, routing, cold-hydration, access-restoration, visibility,
fallback, defaults, and copy tests.

## Verification

- `pnpm exec vitest run
server/src/services/paperclip-cloud-connector.test.ts
ui/src/pages/apps/AppsConnect.test.tsx` (114 tests passed)
- `pnpm check:token-gates`
- `pnpm -r typecheck`
- `pnpm build`
- Live browser proof used a new data directory on `127.0.0.1:3117` and
the exact Cloud PR revision on staging.
- The fresh flow selected “My GitHub account” and “Any agent,” showed
one enrollment approval, returned to local step 2, and connected GitHub
without a second Paperclip confirmation or login.
- The connected screen showed one selected repository, a long-lived
token, installation metadata, a successful access refresh, and healthy
webhook delivery.
- Gmail on the same instance went directly to Google consent without
another Paperclip approval.
- Restarting the same data directory preserved enrollment. A second new
data directory required exactly one new approval.
- A final fresh-data-dir rerun selected a dedicated GitHub identity for
Ada before enrollment, approved the instance once, returned to step 2,
retained Ada after a Back check, connected directly through GitHub, and
finished with “Used only by Ada,” one selected repository, and a
long-lived token.
- Port 3100 remained untouched throughout the proof.

## Risks

- The new Cloud field is additive and restricted to the exact GitHub and
Google OAuth origins and paths, with no embedded credentials or URL
fragment.
- An older Cloud response still works through `confirmationUrl`.
- A self-hosted instance still requires one signed Cloud enrollment.
Managed Cloud instances do not render the enrollment screen.
- Provider authentication and consent remain mandatory after instance
enrollment.
- No schema migration is included in this pull request.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, `gpt-5.6-sol`, extended reasoning, tool use, code
execution, browser control, and multi-file repository editing. The
context window size was not provided.

## 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
This commit is contained in:
Dotta authored and GitHub committed 2026-09-05 08:42:20 -05:00
1 parent 64d8929ce9
commit 5da6499860
4 files changed
+559 -85

No files matched your search

@@ -93,6 +93,78 @@ describe("Paperclip Cloud connector", () => {
});
});
it("prefers a validated HTTPS provider URL over the legacy confirmation URL", async () => {
const keys = config();
const connector = createPaperclipCloudConnector({
config: keys.config,
request: vi.fn(async () => Response.json({
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
authorizationUrl: "https://github.com/login/oauth/authorize?client_id=client&state=broker-state",
expiresAt: "2099-08-21T20:00:00.000Z",
})) as typeof fetch,
});
await expect(connector.startAuthorization({
subject,
companyId,
profile: "github.code",
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
returnState: "state-direct",
})).resolves.toMatchObject({
authorizationUrl: "https://github.com/login/oauth/authorize?client_id=client&state=broker-state",
});
});
it("accepts the fixed Google authorization endpoint for Google profiles", async () => {
const keys = config();
const connector = createPaperclipCloudConnector({
config: keys.config,
request: vi.fn(async () => Response.json({
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?client_id=client&state=broker-state",
expiresAt: "2099-08-21T20:00:00.000Z",
})) as typeof fetch,
});
await expect(connector.startAuthorization({
subject,
companyId,
profile: "gmail.draft",
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
returnState: "state-direct-google",
})).resolves.toMatchObject({
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?client_id=client&state=broker-state",
});
});
it.each([
["non-string", { href: "https://github.com/login/oauth/authorize" }],
["plaintext HTTP", "http://github.com/login/oauth/authorize"],
["embedded credentials", "https://user:password@github.com/login/oauth/authorize"],
["fragment", "https://github.com/login/oauth/authorize#unexpected"],
["unapproved HTTPS origin", "https://attacker.example.test/login/oauth/authorize"],
["unapproved provider path", "https://github.com/session/authorize"],
["not a URL", "not-a-url"],
])("rejects a malformed direct provider URL: %s", async (_label, authorizationUrl) => {
const keys = config();
const connector = createPaperclipCloudConnector({
config: keys.config,
request: vi.fn(async () => Response.json({
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
authorizationUrl,
expiresAt: "2099-08-21T20:00:00.000Z",
})) as typeof fetch,
});
await expect(connector.startAuthorization({
subject,
companyId,
profile: "github.code",
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
returnState: "state-malformed-direct",
})).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" });
});
it("binds active GitHub installations to proof from the current user token", async () => {
const keys = config();
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
@@ -93,6 +93,7 @@ type SealedEnvelope = {
type ConnectorResponse = {
confirmationUrl?: unknown;
authorizationUrl?: unknown;
handoff?: unknown;
expiresAt?: unknown;
scopes?: unknown;
@@ -348,14 +349,45 @@ export function createPaperclipCloudConnector(input: {
if (typeof response.confirmationUrl !== "string" || typeof response.expiresAt !== "string") {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid session", "CONNECTOR_BAD_RESPONSE");
}
const confirmationUrl = new URL(response.confirmationUrl);
const expectedBroker = new URL(config.baseUrl);
if (confirmationUrl.origin !== expectedBroker.origin || confirmationUrl.pathname !== "/connections/confirm") {
let confirmationUrl: URL;
try {
confirmationUrl = new URL(response.confirmationUrl);
} catch {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE");
}
const expectedBroker = new URL(config.baseUrl);
if (
confirmationUrl.origin !== expectedBroker.origin
|| confirmationUrl.pathname !== "/connections/confirm"
|| confirmationUrl.username
|| confirmationUrl.password
|| confirmationUrl.hash
) {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE");
}
let authorizationUrl: URL | undefined;
if (response.authorizationUrl !== undefined) {
if (typeof response.authorizationUrl !== "string") {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
}
try {
authorizationUrl = new URL(response.authorizationUrl);
} catch {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
}
if (
authorizationUrl.protocol !== "https:"
|| authorizationUrl.username
|| authorizationUrl.password
|| authorizationUrl.hash
|| !isExpectedProviderAuthorizationUrl(profile, authorizationUrl)
) {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
}
}
const handoff = parseCloudHandoff(response.handoff);
return {
authorizationUrl: confirmationUrl.toString(),
authorizationUrl: authorizationUrl?.toString() ?? confirmationUrl.toString(),
expiresAt: response.expiresAt,
...(handoff ? { handoff } : {}),
};
@@ -647,6 +679,16 @@ function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId):
return { provider: "google", scopes: GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes };
}
function isExpectedProviderAuthorizationUrl(
profile: PaperclipCloudConnectorProfileId,
url: URL,
): boolean {
if (isGitHubConnectorProfileId(profile)) {
return url.origin === "https://github.com" && url.pathname === "/login/oauth/authorize";
}
return url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth";
}
function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId {
return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value);
}