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 2026-09-05 08:42:20 -05:00 committed by GitHub
parent 64d8929ce9
commit 5da6499860
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 559 additions and 85 deletions

View File

@ -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) => {

View File

@ -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);
}

View File

@ -96,6 +96,62 @@ import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayloa
type Step = "gallery" | "access" | "key" | "success";
export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error";
type EnrollmentAccessState = {
companyId: string;
grantKind: ConnectionGrantKind;
installChoice: "specific" | "all";
agentIds: string[];
};
function enrollmentAccessStorageKey(appKey: string): string {
return `paperclip.connector-enrollment-access:${appKey}`;
}
function validEnrollmentAccessState(value: unknown): value is EnrollmentAccessState {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const candidate = value as Record<string, unknown>;
if (typeof candidate.companyId !== "string" || !candidate.companyId.trim()) return false;
if (!(candidate.grantKind === "user" || candidate.grantKind === "agent" || candidate.grantKind === "organization")) {
return false;
}
if (candidate.installChoice !== "specific" && candidate.installChoice !== "all") return false;
if (!Array.isArray(candidate.agentIds) || candidate.agentIds.some((id) => typeof id !== "string" || !id.trim())) {
return false;
}
const agentIds = new Set(candidate.agentIds);
if (agentIds.size !== candidate.agentIds.length) return false;
if (candidate.grantKind === "agent") {
return candidate.installChoice === "specific" && agentIds.size === 1;
}
return candidate.installChoice === "all" ? agentIds.size === 0 : agentIds.size > 0;
}
function saveEnrollmentAccessState(
companyId: string,
appKey: string,
state: Omit<EnrollmentAccessState, "companyId">,
): void {
try {
window.sessionStorage.setItem(enrollmentAccessStorageKey(appKey), JSON.stringify({ ...state, companyId }));
} catch {
// Browser storage can be unavailable under restrictive privacy settings.
// The callback will safely use the provider's defaults in that case.
}
}
function consumeEnrollmentAccessState(appKey: string): EnrollmentAccessState | null {
const key = enrollmentAccessStorageKey(appKey);
try {
const raw = window.sessionStorage.getItem(key);
window.sessionStorage.removeItem(key);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return validEnrollmentAccessState(parsed) ? parsed : null;
} catch {
return null;
}
}
function githubRecoveryUrl(value: string | null): string | null {
if (!value) return null;
try {
@ -124,6 +180,19 @@ const ROUTE_STAGE_BY_STEP: Partial<Record<Step, string>> = {
success: "complete",
};
export function requestedConnectionInitialStep(input: {
requestedAppKey: string | undefined;
routeStage: string | null;
resumeConnectionId: string | null;
hasPrefilledLink: boolean;
zapierSource: boolean;
}): Step {
if (input.requestedAppKey) {
return input.resumeConnectionId || input.routeStage === "setup" ? "key" : "access";
}
return input.hasPrefilledLink || input.zapierSource ? "access" : "gallery";
}
export function requestedConnectionEntry(input: {
requestedAppKey: string;
galleryApps: readonly AppDefinition[];
@ -302,6 +371,17 @@ function recommendedSetupConnectionMethod(
: null;
}
function recommendedManagedConnectorMethod(
entry: AppDefinition | null | undefined,
): ConnectionMethodDef | null {
return recommendedSetupConnectionMethod(
(entry?.methods ?? []).filter((candidate) =>
candidate.oauthStrategy === "paperclip_cloud_connector"
|| candidate.oauthStrategy === "paperclip_id_connector",
),
);
}
function canUseAutomaticOAuthFastPath(entry: AppDefinition | null | undefined): boolean {
if (!entry) return false;
const methods = getAvailableConnectionMethods(entry);
@ -440,6 +520,7 @@ export function ConnectionSetupFlow({
const appKey = routeParams.appKey ?? searchParams.get("appKey") ?? undefined;
const sourceSlug = searchParams.get("source")?.trim() || null;
const createNewConnection = searchParams.get("new") === "1";
const routeStage = searchParams.get("stage")?.trim() || null;
const resumeConnectionId = searchParams.get("resume")?.trim() || null;
const oauthCallbackOutcome = searchParams.get("oauth");
const oauthCallbackCode = searchParams.get("code");
@ -464,6 +545,13 @@ export function ConnectionSetupFlow({
const zapierSource = (serviceSlug ?? sourceSlug ?? appKey) === "zapier";
const requestedAppKey = zapierSource ? undefined : routeAppKey;
const byo = host === "page" && (byoOnly || searchParams.get("byo") === "1");
const [restoredEnrollmentAccess] = useState<EnrollmentAccessState | null>(() =>
host === "page"
&& searchParams.get("cloud_connector") === "enrolled"
&& requestedAppKey
? consumeEnrollmentAccessState(requestedAppKey)
: null,
);
// Prefill arrives from the app page for reconnects; read once so later
// wizard navigation doesn't fight the URL.
@ -476,11 +564,13 @@ export function ConnectionSetupFlow({
};
});
const [step, setStep] = useState<Step>(
requestedAppKey
? resumeConnectionId ? "key" : "access"
: prefill.link || zapierSource ? "access" : "gallery",
);
const [step, setStep] = useState<Step>(() => requestedConnectionInitialStep({
requestedAppKey,
routeStage,
resumeConnectionId,
hasPrefilledLink: Boolean(prefill.link),
zapierSource,
}));
const [entry, setEntry] = useState<AppDefinition | null>(null);
const [galleryName, setGalleryName] = useState("");
const [linkUrl, setLinkUrl] = useState(prefill.link);
@ -509,7 +599,7 @@ export function ConnectionSetupFlow({
const [access, setAccess] = useState<"all" | "specific">("all");
const [agentIds, setAgentIds] = useState<Set<string>>(new Set());
const [installAgentIds, setInstallAgentIds] = useState<Set<string>>(
() => new Set(requestedAgentId ? [requestedAgentId] : []),
() => new Set(restoredEnrollmentAccess?.agentIds ?? (requestedAgentId ? [requestedAgentId] : [])),
);
/**
* Access-step selections (PAP-17835). These are chosen before the credential
@ -517,10 +607,10 @@ export function ConnectionSetupFlow({
* backwards through the wizard.
*/
const [grantKind, setGrantKind] = useState<ConnectionGrantKind>(
reconnectGrantKindHint ?? "organization",
restoredEnrollmentAccess?.grantKind ?? reconnectGrantKindHint ?? "organization",
);
const [installChoice, setInstallChoice] = useState<"specific" | "all">(
requestedAgentId ? "specific" : "all",
restoredEnrollmentAccess?.installChoice ?? (requestedAgentId ? "specific" : "all"),
);
const resumingAfterOAuthFailure = Boolean(
resumeConnectionId
@ -729,6 +819,16 @@ export function ConnectionSetupFlow({
|| candidate.oauthStrategy === "paperclip_id_connector"
),
);
// Before a self-hosted instance enrolls, the server intentionally withholds
// platform-managed methods from the advertised gallery. The setup route still
// needs the managed method's identity model, labels, and defaults because the
// next step is enrollment for that exact method—not the visible PAT/BYO
// compatibility fallback.
const preEnrollmentManagedMethod = entry
&& requestedDefinitionUsesManagedConnector
&& !entryAdvertisesManagedConnector
? recommendedManagedConnectorMethod(fullRequestedDefinition)
: null;
const connectorEnrollmentQuery = useQuery({
queryKey: ["cloud-connector", "enrollment"],
queryFn: () => toolsApi.getCloudConnectorEnrollment(),
@ -739,6 +839,14 @@ export function ConnectionSetupFlow({
),
});
const [connectorEnrollmentError, setConnectorEnrollmentError] = useState<string | null>(null);
const preserveEnrollmentAccess = useCallback(() => {
if (!selectedCompanyId || !requestedAppKey) return;
saveEnrollmentAccessState(selectedCompanyId, requestedAppKey, {
grantKind,
installChoice,
agentIds: installChoice === "specific" ? [...installAgentIds] : [],
});
}, [grantKind, installAgentIds, installChoice, requestedAppKey, selectedCompanyId]);
const openConnectorEnrollment = useCallback((verificationUrl: string) => {
const target = resolveAuthorizationTarget(verificationUrl);
if (!target.ok) {
@ -1158,19 +1266,38 @@ export function ConnectionSetupFlow({
setCuratedOAuthClientId("");
setCuratedOAuthClientSecret("");
setVercelConnector("");
const initialMethod = recommendedSetupConnectionMethod(methods);
const requestedEntryAdvertisesManagedConnector = requestedEntry.methods.some((candidate) =>
candidate.oauthStrategy === "paperclip_cloud_connector"
|| candidate.oauthStrategy === "paperclip_id_connector"
);
const initialMethod = (
requestedDefinitionUsesManagedConnector && !requestedEntryAdvertisesManagedConnector
? recommendedManagedConnectorMethod(fullRequestedDefinition)
: null
) ?? recommendedSetupConnectionMethod(methods);
setConnectionMethodKey(initialMethod?.key ?? "");
setConfigValues(defaultMethodConfig(initialMethod));
setGoogleSheetsLinks("");
setGoogleSheetsError(null);
setConnectResult(null);
setGrantKind(reconnectGrantKind ?? defaultGrantKindFor(initialMethod));
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
setInstallChoice(requestedAgentId ? "specific" : "all");
const matchingEnrollmentAccess = restoredEnrollmentAccess?.companyId === selectedCompanyId
? restoredEnrollmentAccess
: null;
setGrantKind(reconnectGrantKind ?? matchingEnrollmentAccess?.grantKind ?? defaultGrantKindFor(initialMethod));
setInstallAgentIds(new Set(
matchingEnrollmentAccess?.agentIds ?? (requestedAgentId ? [requestedAgentId] : []),
));
setInstallChoice(matchingEnrollmentAccess?.installChoice ?? (requestedAgentId ? "specific" : "all"));
// Route/service selection initializes the wizard once. Later renders must
// preserve the user's current step in both hosts instead of snapping back
// to Access after they continue.
setStep(resumeConnectionId ? "key" : "access");
setStep(requestedConnectionInitialStep({
requestedAppKey,
routeStage,
resumeConnectionId,
hasPrefilledLink: Boolean(prefill.link),
zapierSource,
}));
}
if (automaticOAuth && (
@ -1200,8 +1327,12 @@ export function ConnectionSetupFlow({
reconnectConnectionId,
reconnectSourceMatches,
resumeConnectionId,
fullRequestedDefinition,
requestedAppKey,
requestedAgentId,
restoredEnrollmentAccess,
routeStage,
zapierSource,
]);
// Resume the exact method and non-secret provider configuration that the
@ -1674,6 +1805,9 @@ export function ConnectionSetupFlow({
entry?.name ??
(linkName.trim() || defaultGenericMcpName(linkUrl) || "this app");
const credentialSourceMethods = connectionMethodsForCredentialSource(entry, credentialSource);
const setupCredentialSourceMethods = preEnrollmentManagedMethod
? [preEnrollmentManagedMethod]
: credentialSourceMethods;
const credentialSourceApps = vercelConnectMode
? (galleryQuery.data?.apps ?? []).filter(
(app) => connectionMethodsForCredentialSource(app, credentialSource).length > 0,
@ -1684,9 +1818,9 @@ export function ConnectionSetupFlow({
: null;
const stepLabels = zapierSource
? ZAPIER_STEP_LABELS
: entry && credentialSourceMethods.length > 1
: entry && setupCredentialSourceMethods.length > 1
? ["Access", "Choose connection"]
: entry && credentialSourceMethods[0]?.auth === "oauth"
: entry && setupCredentialSourceMethods[0]?.auth === "oauth"
? ["Access", "Sign in"]
: isGoogleSheetsRobotMethod(entry, connectionMethodKey)
? ["Access", "Share sheet"]
@ -1697,8 +1831,8 @@ export function ConnectionSetupFlow({
// credential, so it reads the selected method's auth kind.
const accessStepMethod = entry
? (connectionMethodKey
? credentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null
: credentialSourceMethods[0] ?? null)
? setupCredentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null
: setupCredentialSourceMethods[0] ?? null)
: null;
const accessStepAuthKind: ToolConnectionAuthKind = entry
? accessStepMethod?.auth ?? "none"
@ -1781,43 +1915,49 @@ export function ConnectionSetupFlow({
{step === "key" && entry && showConnectorEnrollmentStep ? (
<div className="mx-auto max-w-xl">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-muted p-2 text-muted-foreground">
<Cloud className="h-5 w-5" />
<div className="rounded-xl border border-border bg-card p-6">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-muted p-2 text-muted-foreground">
<Cloud className="h-5 w-5" />
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold text-foreground">
Connect with Paperclip
</h2>
<p className="mt-2 text-sm text-muted-foreground">
You must connect this instance to Paperclip to connect to {entry.name} (you only need to do this once).
</p>
</div>
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold text-foreground">
Connect with Paperclip
</h2>
{connectorEnrollmentQuery.isError || connectorEnrollmentError ? (
<InlineBanner tone="danger" className="mt-4">
{connectorEnrollmentError ?? "Paperclip couldnt check Cloud registration. Try again."}
</InlineBanner>
) : null}
<div className="mt-6 flex items-center justify-between gap-3">
<Button type="button" variant="ghost" onClick={() => setAppStep("access")}>
Back
</Button>
<Button
type="button"
disabled={connectorEnrollmentQuery.isLoading || startConnectorEnrollment.isPending}
onClick={() => {
setConnectorEnrollmentError(null);
preserveEnrollmentAccess();
const verificationUrl = connectorEnrollmentQuery.data?.verificationUrl;
if (verificationUrl) openConnectorEnrollment(verificationUrl);
else startConnectorEnrollment.mutate();
}}
>
{startConnectorEnrollment.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{connectorEnrollmentQuery.data?.status === "pending"
? "Continue"
: "Connect with Paperclip"}
</Button>
</div>
</div>
{connectorEnrollmentQuery.isError || connectorEnrollmentError ? (
<InlineBanner tone="danger" className="mt-4">
{connectorEnrollmentError ?? "Paperclip couldnt check Cloud registration. Try again."}
</InlineBanner>
) : null}
<div className="mt-6 flex items-center justify-between gap-3">
<Button type="button" variant="ghost" onClick={() => setAppStep("access")}>
Back
</Button>
<Button
type="button"
disabled={connectorEnrollmentQuery.isLoading || startConnectorEnrollment.isPending}
onClick={() => {
setConnectorEnrollmentError(null);
const verificationUrl = connectorEnrollmentQuery.data?.verificationUrl;
if (verificationUrl) openConnectorEnrollment(verificationUrl);
else startConnectorEnrollment.mutate();
}}
>
{startConnectorEnrollment.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{connectorEnrollmentQuery.data?.status === "pending"
? "Continue"
: "Connect with Paperclip"}
</Button>
</div>
</div>
) : step === "key" && entry ? (
<KeyStep
@ -3236,7 +3376,9 @@ function KeyStep({
? "Checking…"
: usingVercel
? method?.auth === "oauth" ? "Validate and continue" : "Validate and connect"
: method?.auth === "oauth" ? "Continue to sign in" : "Connect"}
: method?.auth === "oauth"
? entry.slug === "github" ? "Continue to GitHub" : "Continue to sign in"
: "Connect"}
</Button>
</div>
</div>
@ -3449,7 +3591,7 @@ export function AccessStep({
queryFn: () => agentsApi.list(companyId),
});
const allAgents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated");
// "Just agents I pick" means agents this person may actually edit. When the server
// "Only agents I choose" / "Just agents I pick" means agents this person may actually edit. When the server
// has not told us, fall back to every live agent rather than an empty list —
// an empty picker would read as "you have no agents".
const editableAgentIds = capabilities?.editableAgentIds;
@ -3479,13 +3621,22 @@ export function AccessStep({
const lockedAgentName = lockedAgentId
? allAgents.find((agent) => agent.id === lockedAgentId)?.name ?? "the requesting agent"
: null;
const identityHeading = githubIdentity ? "Connect GitHub as" : "Which humans can use this credential?";
const agentAccessHeading = grantKind === "agent"
? "Which agent owns this GitHub account?"
: githubIdentity && grantKind === "user"
? "Which agents may use your GitHub when youre responsible?"
: githubIdentity
? "Which agents may use the shared GitHub account?"
: "Which agents can use this connection?";
const agentAccessLabel = githubIdentity ? agentAccessHeading : "Which agents can use this connection?";
return (
<div className="mx-auto max-w-2xl">
<div className="overflow-hidden rounded-xl border border-border">
<div className="divide-y divide-border">
<section className="p-6">
<h2 className="text-sm font-semibold text-foreground">{githubIdentity ? "Which GitHub identity should this use?" : "Which humans can use this credential?"}</h2>
<h2 className="text-sm font-semibold text-foreground">{identityHeading}</h2>
{identityLoading ? (
<div className="mt-4 grid gap-2 sm:grid-cols-2" aria-label="Loading connection identity">
<Skeleton className="h-20 w-full rounded-md" />
@ -3500,17 +3651,28 @@ export function AccessStep({
) : (
<UsersRound className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
)}
<div className="text-sm font-medium text-foreground">
{allowedGrantKinds[0] === "user"
? githubIdentity ? "My GitHub account" : "Just me"
: allowedGrantKinds[0] === "agent"
? "A dedicated account for an agent"
: "Any human in the company"}
<div>
<div className="text-sm font-medium text-foreground">
{allowedGrantKinds[0] === "user"
? githubIdentity ? "My GitHub account" : "Just me"
: allowedGrantKinds[0] === "agent"
? "A dedicated account for an agent"
: githubIdentity ? "Shared company GitHub account (advanced)" : "Any human in the company"}
</div>
{githubIdentity ? (
<p className="mt-1 text-xs text-muted-foreground">
{allowedGrantKinds[0] === "user"
? "Agents use it only for runs where you are the responsible person."
: allowedGrantKinds[0] === "agent"
? "That agent always uses this account, regardless of who starts the run."
: "Eligible agents use one shared credential, regardless of who starts the run."}
</p>
) : null}
</div>
</div>
) : needsIdentityChoice ? (
<RadioCardGroup
ariaLabel="Which humans can use this credential?"
ariaLabel={githubIdentity ? identityHeading : "Which humans can use this credential?"}
className="mt-4 sm:grid-cols-2"
value={grantKind}
onValueChange={(next) => {
@ -3525,20 +3687,29 @@ export function AccessStep({
{
value: "user",
title: githubIdentity ? "My GitHub account" : "Just me",
description: githubIdentity
? "Agents use it only for runs where you are the responsible person."
: undefined,
icon: <UserRound className="h-4 w-4" aria-hidden="true" />,
},
{
value: "agent",
title: "A dedicated account for an agent",
description: githubIdentity
? "That agent always uses this account, regardless of who starts the run."
: undefined,
icon: <Bot className="h-4 w-4" aria-hidden="true" />,
},
{
value: "organization",
title: "Any human in the company",
title: githubIdentity ? "Shared company GitHub account (advanced)" : "Any human in the company",
description: githubIdentity
? "Eligible agents use one shared credential, regardless of who starts the run."
: undefined,
icon: <UsersRound className="h-4 w-4" aria-hidden="true" />,
accessibleLabel: canCreateOrganizationGrant
? "Any human in the company"
: `Any human in the company. Unavailable: ${capabilities?.organizationGrantReason ??
? githubIdentity ? "Shared company GitHub account (advanced)" : "Any human in the company"
: `${githubIdentity ? "Shared company GitHub account (advanced)" : "Any human in the company"}. Unavailable: ${capabilities?.organizationGrantReason ??
"Only a connection manager can share this credential with the organization."}`,
tooltip: canCreateOrganizationGrant
? undefined
@ -3556,7 +3727,7 @@ export function AccessStep({
</section>
<section className="p-6">
<h2 className="text-sm font-semibold text-foreground">{grantKind === "agent" ? "Which agent owns this GitHub account?" : "Which agents can use this connection?"}</h2>
<h2 className="text-sm font-semibold text-foreground">{agentAccessHeading}</h2>
{preserveAgentAccess ? (
<div className="mt-4 flex items-start gap-3 rounded-md border border-border bg-muted/40 p-4">
<UsersRound className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
@ -3576,19 +3747,29 @@ export function AccessStep({
grantKind === "agent" ? (
<p className="mt-2 text-sm text-muted-foreground">Choose exactly one agent. This identity cannot be shared with other agents.</p>
) : <RadioCardGroup
ariaLabel="Which agents can use this connection?"
ariaLabel={agentAccessLabel}
className="mt-4 sm:grid-cols-2"
value={installChoice}
onValueChange={(next) => setInstallChoice(next as "specific" | "all")}
options={[
{
value: "specific",
title: "Just agents I pick",
title: githubIdentity ? "Only agents I choose" : "Just agents I pick",
description: githubIdentity
? grantKind === "user"
? "Only selected agents may use your GitHub when youre responsible."
: "Only selected agents may use the shared account."
: undefined,
icon: <Bot className="h-4 w-4" aria-hidden="true" />,
},
{
value: "all",
title: "Any agent",
description: githubIdentity
? grantKind === "user"
? "Every agent may use your GitHub when youre responsible."
: "Every agent may use the shared account."
: undefined,
icon: <BotGroupIcon />,
accessibleLabel: canSetCompanyInstall
? "Any agent"

View File

@ -24,6 +24,12 @@ const mockNavigate = vi.hoisted(() => vi.fn());
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
const mockSearch = vi.hoisted(() => ({ value: "" }));
const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined }));
const mockCompany = vi.hoisted(() => ({
value: {
selectedCompanyId: "company-1" as string | undefined,
selectedCompany: { id: "company-1", name: "Paperclip" } as { id: string; name: string } | null,
},
}));
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier")!;
const GITHUB = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "github")!;
@ -78,10 +84,7 @@ vi.mock("@/lib/router", () => ({
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
}),
useCompany: () => mockCompany.value,
}));
vi.mock("@/context/BreadcrumbContext", () => ({
@ -199,6 +202,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
beforeEach(() => {
vi.resetAllMocks();
window.sessionStorage.clear();
mockCompany.value = {
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
};
mockSearch.value = "";
mockParams.appKey = undefined;
container = document.createElement("div");
@ -348,8 +356,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(container.textContent).toContain("Access");
expect(container.textContent).toContain("Which GitHub identity should this use?");
expect(container.textContent).toContain("Which agents can use this connection?");
expect(container.textContent).toContain("Connect GitHub as");
expect(container.textContent).toContain("Which agents may use your GitHub when youre responsible?");
expect(container.textContent).not.toContain("Choose access before adding credentials");
expect(container.textContent).not.toContain("Set the identity and agent reach first");
expect(container.textContent).not.toContain("Which humans can use this credential?");
@ -360,14 +368,14 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
const radios = Array.from(document.body.querySelectorAll('[role="radio"]'));
const myAccount = radios.find((r) => r.textContent?.includes("My GitHub account"));
const dedicated = radios.find((r) => r.textContent?.includes("A dedicated account for an agent"));
const agentsIPick = radios.find((r) => r.textContent?.includes("Just agents I pick"));
const agentsIPick = radios.find((r) => r.textContent?.includes("Only agents I choose"));
const anyAgent = radios.find((r) => r.textContent?.includes("Any agent"));
expect(myAccount).toBeTruthy();
expect(dedicated).toBeTruthy();
expect(myAccount?.textContent).toBe("My GitHub account");
expect(dedicated?.textContent).toBe("A dedicated account for an agent");
expect(agentsIPick?.textContent).toBe("Just agents I pick");
expect(anyAgent?.textContent).toBe("Any agent");
expect(myAccount?.textContent).toContain("Agents use it only for runs where you are the responsible person.");
expect(dedicated?.textContent).toContain("That agent always uses this account, regardless of who starts the run.");
expect(agentsIPick?.textContent).toContain("Only selected agents may use your GitHub when youre responsible.");
expect(anyAgent?.textContent).toContain("Every agent may use your GitHub when youre responsible.");
expect(myAccount?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
expect(dedicated?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
expect(agentsIPick?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
@ -408,7 +416,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await act(async () => {
Array.from(document.body.querySelectorAll('[role="radio"]'))
.find((r) => r.textContent?.includes("Just agents I pick"))
.find((r) => r.textContent?.includes("Only agents I choose"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
@ -703,6 +711,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await passAccessStep();
expect(container.textContent).toContain("Connect with Paperclip");
expect(container.textContent).toContain(
"You must connect this instance to Paperclip to connect to Gmail (you only need to do this once).",
);
expect(buttonByText("Connect with Paperclip")?.closest(".rounded-xl")?.classList.contains("border-border")).toBe(true);
expect(container.textContent).not.toContain("Required once for managed Google sign-in.");
expect(container.textContent).not.toContain("Your OAuth app");
@ -721,6 +733,173 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
);
});
it("keeps GitHub's personal identity defaults while its managed method awaits enrollment", async () => {
mockParams.appKey = "github";
listGalleryMock.mockResolvedValue({
apps: [{
...GITHUB,
methods: GITHUB.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}],
});
getCloudConnectorEnrollmentMock.mockResolvedValueOnce({
configured: false,
status: "not_configured",
brokerBaseUrl: "https://my-staging.paperclip.app",
instanceId: null,
environment: "staging",
origins: [],
});
await render();
expect(container.textContent).toContain("Access · Sign in");
expect(radioContaining("My GitHub account")?.getAttribute("aria-checked")).toBe("true");
expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("true");
expect(container.textContent).toContain("Which agents may use your GitHub when youre responsible?");
await passAccessStep();
expect(container.textContent).toContain("Connect with Paperclip");
expect(container.textContent).not.toContain("GitHub token");
});
it("restores the setup step after the one-time enrollment callback", async () => {
mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled";
listGalleryMock.mockResolvedValueOnce({ apps: [GITHUB_MANAGED] });
await render();
expect(container.textContent).toContain("Step 2 of 2");
expect(container.textContent).toContain("Continue to GitHub");
expect(container.textContent).not.toContain("Connect GitHub as");
expect(container.textContent).not.toContain("Connect with Paperclip");
});
it("preserves a dedicated agent identity across the full-page enrollment callback", async () => {
mockParams.appKey = "github";
listGalleryMock.mockResolvedValue({
apps: [{
...GITHUB,
methods: GITHUB.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}],
capabilities: {
canCreateOrganizationGrant: true,
organizationGrantReason: null,
canSetCompanyInstall: true,
companyInstallReason: null,
},
});
getCloudConnectorEnrollmentMock.mockResolvedValue({
configured: false,
status: "not_configured",
brokerBaseUrl: "https://my-staging.paperclip.app",
instanceId: null,
environment: "staging",
origins: [],
});
await render();
await act(async () => {
radioContaining("A dedicated account for an agent")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
buttonByText("Select agents")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
document.body.querySelector<HTMLElement>('[aria-label="Allow Ada"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const accessContinue = buttonByText("Save and continue") ?? buttonByText("Continue to GitHub");
expect(accessContinue?.disabled).toBe(false);
await act(async () => {
accessContinue?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
buttonByText("Connect with Paperclip")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(JSON.parse(window.sessionStorage.getItem(
"paperclip.connector-enrollment-access:github",
) ?? "null")).toEqual({
companyId: "company-1",
grantKind: "agent",
installChoice: "specific",
agentIds: ["agent-1"],
});
await act(async () => mountedRoot?.unmount());
mountedRoot = null;
container.innerHTML = "";
mockParams.appKey = undefined;
mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled";
mockCompany.value = { selectedCompanyId: undefined, selectedCompany: null };
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
getCloudConnectorEnrollmentMock.mockResolvedValue({
configured: true,
status: "active",
brokerBaseUrl: "https://my-staging.paperclip.app",
instanceId: "inst-test",
environment: "staging",
origins: ["https://paperclip.example.test"],
});
const coldLoadClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(coldLoadClient);
expect(window.sessionStorage.getItem("paperclip.connector-enrollment-access:github")).toBeNull();
mockCompany.value = {
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
};
await act(async () => {
mountedRoot?.render(
<QueryClientProvider client={coldLoadClient}>
<AppsConnect />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Step 2 of 2");
expect(window.sessionStorage.getItem(
"paperclip.connector-enrollment-access:github",
)).toBeNull();
await act(async () => {
buttonByText("Continue to GitHub")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
galleryKey: "github",
grantKind: "agent",
subjectAgentId: "agent-1",
}));
});
it("never renders self-host enrollment when the connector identity is already active", async () => {
mockSearch.value = "source=gmail&stage=setup";
listGalleryMock.mockResolvedValueOnce({
apps: [{
...GMAIL,
methods: GMAIL.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}],
});
await render();
expect(container.textContent).not.toContain("You must connect this instance to Paperclip");
expect(container.textContent).not.toContain("Connect with Paperclip");
});
it("keeps Google Drive prerequisites off access and defaults to its write-capable method", async () => {
mockParams.appKey = "google-drive";
listGalleryMock.mockResolvedValue({ apps: [GOOGLE_DRIVE] });
@ -844,7 +1023,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// A deep-linked app lands on Access first: identity and reach are chosen
// before the credential (PAP-17835).
expect(container.textContent).toContain("Which GitHub identity should this use?");
expect(container.textContent).toContain("Connect GitHub as");
await passAccessStep();
expect(container.textContent).toContain("Connect GitHub");
@ -2199,7 +2378,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Which GitHub identity should this use?");
expect(container.textContent).toContain("Connect GitHub as");
await act(async () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));