mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
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 <noreply@paperclip.ing> Co-authored-by: Dotta <34892728+cryppadotta@users.noreply.github.com>
This commit is contained in:
6 files changed
+65
-6
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user