From 56a0bf0d492006a0aa2d0b99bfe7d5b86f657f3a Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 12 Oct 2026 19:32:33 -0700 Subject: [PATCH] Support test Cloud connector identities (#16033) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its Cloud connector uses an environment-bound identity to call a hosted broker. > - The identity parser and enrollment flow accept development, staging, and production. > - A separate test broker needs its own environment identity. > - This pull request accepts test identities and maps the test broker to that environment. > - Known hosted brokers still reject identities from other environments. ## Linked Issues or Issue Description **What existing behavior does this improve?** Cloud connector configuration and self-host enrollment. **Current behavior** The app rejects an identity with environment `test`. Self-host enrollment infers development for the test broker. **Proposed behavior** Accept `test` identities at `https://test.paperclip.app`. Infer test during self-host enrollment. Reject development, staging, and production identities at that broker. Continue to reject test identities at the production and staging brokers. **Reason and benefit** Branch testing needs a separate broker so test identities and requests stay in their selected environment. The existing identity, enrollment, and signature flow can provide that isolation with a new allowed environment value. **Breaking changes** Existing environment values and broker checks keep their current behavior. Older app versions cannot use test connector identities. ## What Changed - Add `test` to managed and self-hosted connector environment types and validation, and to the board API enrollment status type. - Infer test from its hosted broker and enforce the matching environment. - Test enrollment persistence, signed request environment/audience claims, and broker mismatch rejection. - Document test broker configuration in the development guide. ## Verification - Connector, enrollment, and board API-client suites passed: 63 tests across three files. Board UI typecheck and token gates passed after synchronizing its enrollment response type. - `pnpm -r typecheck` and `pnpm build` passed, including after rebasing onto current master. - `pnpm test:run` completed its general server stage: 17,867 passed, 105 skipped, and 9 failed across 5 files. The runner stops at that failing stage, so it did not run subsequent workspace/serialized stages locally. All nine failures also reproduce on the unchanged base commit in a separate checkout. Five skill-related cases resolve an unrelated ancestor skills directory; two workspace cases accept a remote main branch that their fixture expects to be absent; two permission-cleanup cases return macOS `ENOTEMPTY` instead of the expected permission error. - Current-head GitHub CI passed, including full general and serialized test suites, typecheck, build, runner checks, and browser e2e. Greptile is 5/5 with no unresolved comments. - `git diff --check` and Gitleaks passed. ## Risks The new environment value is additive. The test broker must support test identities before use. This change preserves existing signature handling, managed identity overrides, and rejection of mismatched identities at known hosted brokers. A local full-suite pass is not claimed; the nine failures above also occur without this change. ## Model Used OpenAI Codex, GPT-6, with reasoning and tool execution. The exact serving model ID and context window are not exposed in this session. ## 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 - [ ] 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 Co-authored-by: Dotta <34892728+cryppadotta@users.noreply.github.com> --- doc/DEVELOPING.md | 8 +++++ ...perclip-cloud-connector-enrollment.test.ts | 29 +++++++++++++++++++ .../paperclip-cloud-connector-enrollment.ts | 9 ++++-- .../paperclip-cloud-connector.test.ts | 18 ++++++++++++ .../src/services/paperclip-cloud-connector.ts | 5 ++-- ui/src/api/tools.ts | 2 +- 6 files changed, 65 insertions(+), 6 deletions(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 0241771ebf5..36b4cdc2b7e 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2034,6 +2034,14 @@ pnpm secrets:migrate-inline-env --apply # apply migration ## Internal Connection Token Brokers +Paperclip Cloud connector identities support `development`, `test`, `staging`, +and `production`. To enroll a self-hosted instance against the test broker, set +`PAPERCLIP_CLOUD_CONNECTOR_BASE_URL=https://test.paperclip.app`; its environment +is inferred as `test`. Managed instances also receive +`PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT=test`. Standard Cloud broker hosts reject +identities for another environment, and enrollment keys remain local to the +selected broker. + Connection token exchanges carry a resolved parent credential, so their URLs are public-only by default even in local/private deployments. To intentionally use an internal broker, configure an exact hostname allowlist: diff --git a/server/src/services/paperclip-cloud-connector-enrollment.test.ts b/server/src/services/paperclip-cloud-connector-enrollment.test.ts index 6443eafef32..8d731f426ba 100644 --- a/server/src/services/paperclip-cloud-connector-enrollment.test.ts +++ b/server/src/services/paperclip-cloud-connector-enrollment.test.ts @@ -193,6 +193,35 @@ describe("Paperclip Cloud self-host enrollment", () => { expect(paperclipCloudConnectorEnrollmentStatus({ PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app", })).toMatchObject({ environment: "staging" }); + expect(paperclipCloudConnectorEnrollmentStatus({ + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://test.paperclip.app", + })).toMatchObject({ environment: "test" }); + }); + + it.each(["development", "staging", "production"])("rejects %s identity at the test broker", (environment) => { + expect(() => paperclipCloudConnectorEnrollmentStatus({ + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://test.paperclip.app", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: environment, + })).toThrow(/broker and environment do not match/); + }); + + it("persists test enrollment identity and signs requests for the test broker", async () => { + const origin = "https://tenant.test.paperclip.app"; + const request = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + if (String(input).endsWith("/enrollments")) { + expect(body).toMatchObject({ environment: "test", origin }); + return Response.json({ enrollmentId: "enroll-test", verificationUrl: "https://test.paperclip.app/connections/enroll?id=enroll-test", expiresAt: new Date(Date.now() + 60_000).toISOString() }, { status: 201 }); + } + const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8")); + expect(claims).toMatchObject({ env: "test", aud: "https://test.paperclip.app/v1/connector/enrollment-claims" }); + return Response.json({ id: loadPaperclipCloudConnectorIdentity()!.instanceId, environment: "test", origins: [origin] }); + }); + const env = { PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://test.paperclip.app" }; + await startPaperclipCloudConnectorEnrollment({ origin, env, request: request as typeof fetch }); + const pending = loadPaperclipCloudConnectorIdentity()!; + await completePaperclipCloudConnectorEnrollment({ enrollmentId: "enroll-test", approvalCode: "approval-code", state: pending.pending!.returnState, env, request: request as typeof fetch }); + expect(paperclipCloudConnectorConfigFromEnv(env)).toMatchObject({ environment: "test", baseUrl: "https://test.paperclip.app", instanceId: pending.instanceId }); }); it("rotates a non-active identity instead of mixing enrollment targets", async () => { diff --git a/server/src/services/paperclip-cloud-connector-enrollment.ts b/server/src/services/paperclip-cloud-connector-enrollment.ts index ba5de762ba0..0fbbd9755c7 100644 --- a/server/src/services/paperclip-cloud-connector-enrollment.ts +++ b/server/src/services/paperclip-cloud-connector-enrollment.ts @@ -17,7 +17,7 @@ const IDENTITY_VERSION = 1; const ENROLLMENT_FILE = "paperclip-cloud-connector.json"; const REQUEST_TYPE = "paperclip-cloud-connector-request+jwt"; -export type LocalConnectorEnvironment = "development" | "staging" | "production"; +export type LocalConnectorEnvironment = "development" | "test" | "staging" | "production"; type PendingEnrollment = { enrollmentId: string; @@ -350,10 +350,13 @@ function connectorEnvironment( ? "production" : host === "my-staging.paperclip.app" ? "staging" - : "development"; + : host === "test.paperclip.app" + ? "test" + : "development"; const value = env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT?.trim() || fallback || inferred; if (!isEnvironment(value)) throw new Error("Paperclip Cloud connector environment is invalid"); if ((host === "my.paperclip.app" && value !== "production") + || (host === "test.paperclip.app" && value !== "test") || (host === "my-staging.paperclip.app" && value !== "staging")) { throw new Error("Paperclip Cloud connector broker and environment do not match"); } @@ -390,7 +393,7 @@ function hasManagedConnectorIdentityOverride(env: NodeJS.ProcessEnv): boolean { } function isEnvironment(value: unknown): value is LocalConnectorEnvironment { - return value === "development" || value === "staging" || value === "production"; + return value === "development" || value === "test" || value === "staging" || value === "production"; } function normalizeBrokerOrigin(value: string): string { diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index a2a0b37abe0..999236c6c54 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -55,6 +55,24 @@ function config() { } describe("Paperclip Cloud connector", () => { + it("accepts a managed test identity and rejects other environments at its broker", () => { + const identity = config().config; + const env = { + PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: identity.instanceId, + PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY: identity.signPrivateKey, + PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY: identity.sealPrivateKey, + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://test.paperclip.app", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "test", + }; + expect(paperclipCloudConnectorConfigFromEnv(env)).toMatchObject({ environment: "test", baseUrl: env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL }); + for (const environment of ["development", "staging", "production"]) { + expect(() => paperclipCloudConnectorConfigFromEnv({ ...env, PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: environment })).toThrow(/broker and environment do not match/); + } + for (const baseUrl of ["https://my.paperclip.app", "https://my-staging.paperclip.app"]) { + expect(() => paperclipCloudConnectorConfigFromEnv({ ...env, PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: baseUrl })).toThrow(/broker and environment do not match/); + } + }); + async function rejection(response: Response) { const connector = createPaperclipCloudConnector({ config: config().config, diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index 241ff937b57..3f330292dab 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -32,7 +32,7 @@ export const GMAIL_CONNECTOR_SCOPES = [ ] as const; export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES }; -export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production"; +export type PaperclipCloudConnectorEnvironment = "development" | "test" | "staging" | "production"; export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack" | "github-app"; export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "slack.manager" | "github.bot"; export type PaperclipCloudConnectorProvider = "google" | "github" | "asana" | "slack"; @@ -232,7 +232,7 @@ export function paperclipCloudConnectorConfigFromEnv( if (values.some((value) => !value)) { throw new PaperclipCloudConnectorError("Paperclip Cloud connector configuration is incomplete", "CONNECTOR_CONFIG_INCOMPLETE"); } - if (environment !== "development" && environment !== "staging" && environment !== "production") { + if (environment !== "development" && environment !== "test" && environment !== "staging" && environment !== "production") { throw new PaperclipCloudConnectorError("Paperclip Cloud connector environment is invalid", "CONNECTOR_CONFIG_INVALID"); } const parsedBaseUrl = new URL(baseUrl); @@ -244,6 +244,7 @@ export function paperclipCloudConnectorConfigFromEnv( } const brokerHost = parsedBaseUrl.hostname.toLowerCase(); if ((brokerHost === "my.paperclip.app" && environment !== "production") + || (brokerHost === "test.paperclip.app" && environment !== "test") || (brokerHost === "my-staging.paperclip.app" && environment !== "staging")) { throw new PaperclipCloudConnectorError( "Paperclip Cloud connector broker and environment do not match", diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 43cb010466d..1702478e54d 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -106,7 +106,7 @@ export type CloudConnectorEnrollmentStatus = { status: "not_configured" | "unenrolled" | "pending" | "active" | "suspended" | "unverified"; brokerBaseUrl: string; instanceId: string | null; - environment: "development" | "staging" | "production"; + environment: "development" | "test" | "staging" | "production"; origins: string[]; verificationUrl?: string; expiresAt?: string;