mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
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:
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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user