feat(connections): add self-serve intent runtime (#12345)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents need a governed way to request app connections during issue
work.
> - The catalog now describes the available providers and setup methods.
> - A request must become a durable, company-scoped intent before an
operator acts on it.
> - This pull request adds that intent runtime across server, agent,
CLI, and shared contracts.
> - The benefit is a safe bridge from agent need to operator-approved
setup.

## Linked Issues or Issue Description

Refs #11965

This is stack 7 of 11. It depends on stack 6 and replaces another
reviewable part of #11965.

## What Changed

- Add connection intent types, validation, service logic, and routes.
- Add agent runtime tools and CLI support for connection requests.
- Add issue-thread interaction support for connection intents.
- Add runtime, route, adapter, and contract tests.
- Hold the final resolved-continuation row lock through asynchronous
adapter preparation until an actual process spawn, so parking or
reassignment cannot cross that boundary.
- Report Hermes Gateway's first remote run request through the shared
dispatch hook so the resolved-intent lock is released at the true
dispatch boundary.
- Revalidate the addressed user's live non-viewer membership and
connection-management authority for every intent mutation, including
OAuth completion.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts`
- Result: 176 tests passed.
- `pnpm build`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-stale-queue-invalidation.test.ts` (32 passed;
includes non-process dispatch lock-release coverage)
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/connection-intents-service.test.ts -t
"addressed-user mutation"` (1 passed)
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/tool-access-service.test.ts -t "binds OAuth
callback completion to the initiating board session"` (1 passed)
- `pnpm --filter @paperclipai/hermes-paperclip-adapter test --
src/gateway/server/execute.test.ts` (23 passed; includes dispatch-hook
ordering and exactly-once coverage)
- `pnpm --filter @paperclipai/hermes-paperclip-adapter typecheck`

## Risks

- A malformed intent could create an unusable operator request.
- Validators and company checks reject invalid or cross-company
requests.
- The final continuation gate holds the issue row lock through adapter
preparation until process or remote dispatch; later operator changes use
the normal active-run interruption path.
- The change does not add a database migration.

> I checked `ROADMAP.md`. This stack continues the existing app
connection work from #11965 and does not duplicate another planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime model ID and context window were not
exposed. The model used reasoning, tool use, and code execution.

## 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 linked the public source pull request with `Refs #`
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-08-29 12:08:34 -05:00
1 parent fcb2e99e8f
commit b3343dbd64
96 files changed
+8695 -971

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
import type {
ConnectionIntentInteraction,
ConnectionIntentSetupOptions,
} from "@paperclipai/shared";
import { api } from "./client";
export const connectionIntentsApi = {
setupOptions: (interactionId: string) =>
api.get<ConnectionIntentSetupOptions>(
`/connection-intents/${interactionId}/setup-options`,
),
complete: (interactionId: string, connectionId: string) =>
api.post<ConnectionIntentInteraction>(
`/connection-intents/${interactionId}/complete`,
{ connectionId },
),
decline: (interactionId: string, reason?: string) =>
api.post<ConnectionIntentInteraction>(
`/connection-intents/${interactionId}/decline`,
reason ? { reason } : {},
),
};
+7 -10
View File
@@ -6,6 +6,7 @@ import type {
} from "@/pages/apps/composio-services";
import type {
ToolApplication,
ConnectToolApp,
ToolConnection,
ToolConnectionInstall,
ToolConnectionInstallSnapshot,
@@ -267,17 +268,13 @@ export const toolsApi = {
// --- Applications ---
listGallery: (companyId: string) =>
api.get<ToolGalleryResponse>(`/companies/${companyId}/tools/gallery`),
connectApp: (companyId: string, input: {
galleryKey?: string;
link?: string;
name?: string;
credentialValues?: Record<string, string>;
configValues?: Record<string, unknown>;
applicationId?: string;
}) =>
connectApp: (companyId: string, input: ConnectToolApp) =>
api.post<ConnectToolAppResult>(`/companies/${companyId}/tools/apps/connect`, input),
startOAuth: (connectionId: string) =>
api.post<ToolOAuthStartResult>(`/tools/oauth/${connectionId}/start`, {}),
startOAuth: (connectionId: string, interactionId?: string) =>
api.post<ToolOAuthStartResult>(
`/tools/oauth/${connectionId}/start`,
interactionId ? { interactionId } : {},
),
finishApp: (companyId: string, connectionId: string, input: {
enabledCatalogEntryIds: string[];
askFirstCatalogEntryIds: string[];
@@ -0,0 +1,178 @@
import { useEffect, useState } from "react";
import { CheckCircle2, Clock, Loader2, Plug, XCircle } from "lucide-react";
import type {
ConnectionIntentInteraction,
ConnectionIntentSetupOptions,
} from "@paperclipai/shared";
import { connectionIntentsApi } from "@/api/connection-intents";
import { Link } from "@/lib/router";
import { AppLogo } from "@/pages/apps/AppLogo";
import { appSourceConnectHref, isMcpDirectOAuthConnectSlug } from "@/pages/apps/app-connect-policy";
import { Button } from "./ui/button";
export interface ConnectionIntentInteractionBodyProps {
interaction: ConnectionIntentInteraction;
currentUserId?: string | null;
addresseeLabel: string;
}
export function ConnectionIntentInteractionBody({
interaction,
currentUserId,
addresseeLabel,
}: ConnectionIntentInteractionBodyProps) {
const [current, setCurrent] = useState(interaction);
const [options, setOptions] = useState<ConnectionIntentSetupOptions | null>(null);
const [expanded, setExpanded] = useState(false);
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => setCurrent(interaction), [interaction]);
const isAddressee = Boolean(currentUserId && current.addresseeUserId === currentUserId);
const connectHref = isMcpDirectOAuthConnectSlug(current.payload.serviceSlug)
? appSourceConnectHref(current.payload.serviceSlug, current.id)
: `/apps/connect?${new URLSearchParams({
byo: "1",
appKey: current.payload.serviceSlug,
intent: current.id,
}).toString()}`;
async function loadOptions() {
setExpanded(true);
setPendingAction("load");
setError(null);
try {
setOptions(await connectionIntentsApi.setupOptions(current.id));
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Couldn’t load connection options.");
} finally {
setPendingAction(null);
}
}
async function complete(connectionId: string) {
setPendingAction(connectionId);
setError(null);
try {
setCurrent(await connectionIntentsApi.complete(current.id, connectionId));
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Couldn’t use this connection.");
} finally {
setPendingAction(null);
}
}
async function decline() {
setPendingAction("decline");
setError(null);
try {
setCurrent(await connectionIntentsApi.decline(current.id));
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Couldn’t decline this request.");
} finally {
setPendingAction(null);
}
}
if (current.status === "accepted" || current.status === "rejected" || current.status === "expired") {
const connected = current.status === "accepted";
const StatusIcon = connected ? CheckCircle2 : XCircle;
const title = connected
? `${current.payload.serviceName} connected`
: current.status === "rejected"
? "Connection declined"
: "Connection request expired";
return (
<div className="flex items-start gap-3" data-testid="connection-intent-terminal">
<StatusIcon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-foreground">{title}</p>
<p className="mt-1 text-sm text-muted-foreground">
{connected
? `${current.payload.requestingAgentName} can use this connection on its continuation run.`
: `${current.payload.requestingAgentName} can continue without this connection.`}
</p>
</div>
</div>
);
}
if (!isAddressee) {
return (
<div className="flex items-start gap-3" data-testid="connection-intent-waiting">
<Clock className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-foreground">Waiting for {addresseeLabel}</p>
<p className="mt-1 text-sm text-muted-foreground">
Only the addressed person can choose or create a connection.
</p>
</div>
</div>
);
}
return (
<div data-testid="connection-intent-actions">
<div className="flex items-start gap-3">
<AppLogo
name={current.payload.serviceName}
brandKey={current.payload.serviceSlug}
logoUrl={current.payload.serviceLogoUrl}
size={40}
/>
<div>
<p className="font-medium text-foreground">
{current.payload.requestingAgentName} needs {current.payload.serviceName}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Reuse an eligible connection or connect a new identity for this agent.
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button type="button" onClick={() => void loadOptions()} disabled={pendingAction !== null}>
{pendingAction === "load" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plug className="h-4 w-4" />}
Connect / Use existing
</Button>
<Button
type="button"
variant="ghost"
onClick={() => void decline()}
disabled={pendingAction !== null}
>
Not now
</Button>
</div>
{expanded && pendingAction === "load" ? (
<p className="mt-3 text-sm text-muted-foreground">Loading connection options…</p>
) : null}
{expanded && options ? (
<div className="mt-3 space-y-2 rounded-md border border-border bg-muted/30 p-3">
{options.existingConnections.map((connection) => (
<Button
key={connection.id}
type="button"
variant="outline"
className="w-full justify-start"
onClick={() => void complete(connection.id)}
disabled={pendingAction !== null}
>
{pendingAction === connection.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plug className="h-4 w-4" />}
Use {connection.name}
</Button>
))}
<Button asChild type="button" variant="outline" className="w-full justify-start">
<Link to={connectHref}>Connect a new {current.payload.serviceName} identity</Link>
</Button>
<p className="text-xs text-muted-foreground">
Finishing setup will grant the new identity and resolve this request automatically.
</p>
</div>
) : null}
{error ? <p className="mt-3 text-sm text-destructive" role="alert">{error}</p> : null}
</div>
);
}
@@ -161,8 +161,12 @@ describe("InteractionGovernancePanel", () => {
it("renders a select for every interaction kind", () => {
const { host } = renderPanel();
expect(host.querySelectorAll('[data-testid$="-default"]')).toHaveLength(5);
expect(host.querySelectorAll('[data-testid$="-cap"]')).toHaveLength(5);
expect(host.querySelectorAll('[data-testid$="-default"]')).toHaveLength(
INTERACTION_KINDS.length,
);
expect(host.querySelectorAll('[data-testid$="-cap"]')).toHaveLength(
INTERACTION_KINDS.length,
);
});
it("surfaces a save failure", () => {
@@ -22,6 +22,7 @@ const INTERACTION_KIND_LABELS: Record<IssueThreadInteractionKind, string> = {
request_confirmation: "Confirmations",
request_checkbox_confirmation: "Checkbox confirmations",
request_item_verdicts: "Item verdicts",
connection_intent: "Connection requests",
};
/**
@@ -47,11 +47,18 @@ import {
humanOnlyRequestConfirmationInteraction,
companyCappedRequestConfirmationInteraction,
legacyRestrictedRequestConfirmationInteraction,
pendingConnectionIntentInteraction,
} from "../fixtures/issueThreadInteractionFixtures";
let root: Root | null = null;
let container: HTMLDivElement | null = null;
const connectionIntentsApiMocks = vi.hoisted(() => ({
setupOptions: vi.fn(),
complete: vi.fn(),
decline: vi.fn(),
}));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
@@ -74,6 +81,8 @@ vi.mock("@/lib/router", () => ({
),
}));
vi.mock("@/api/connection-intents", () => ({ connectionIntentsApi: connectionIntentsApiMocks }));
function renderCard(
props: Partial<ComponentProps<typeof IssueThreadInteractionCard>> = {},
) {
@@ -107,6 +116,42 @@ afterEach(() => {
});
describe("IssueThreadInteractionCard", () => {
it("offers connection resolution actions to the addressed user", async () => {
connectionIntentsApiMocks.setupOptions.mockResolvedValue({ existingConnections: [] });
const host = renderCard({
interaction: pendingConnectionIntentInteraction,
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
});
expect(host.querySelector('[data-testid="connection-intent-actions"]')).toBeTruthy();
expect(host.textContent).toContain("Connect / Use existing");
expect(host.textContent).toContain("Not now");
const loadButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Connect / Use existing"),
);
await act(async () => {
loadButton?.click();
await Promise.resolve();
});
const connectLink = Array.from(host.querySelectorAll("a")).find((link) =>
link.textContent === "Connect a new Notion identity",
);
expect(connectLink?.getAttribute("href")).toBe(
"/apps/connect?source=notion&intent=interaction-connection-intent-default",
);
});
it("keeps connection resolution controls exclusive to the addressed user", () => {
const host = renderCard({
interaction: pendingConnectionIntentInteraction,
currentUserId: "another-user",
});
expect(host.querySelector('[data-testid="connection-intent-waiting"]')).toBeTruthy();
expect(host.textContent).not.toContain("Connect / Use existing");
expect(host.textContent).not.toContain("Not now");
});
it("exposes pending question options as selectable radio and checkbox controls", () => {
const host = renderCard({
interaction: pendingAskUserQuestionsInteraction,
@@ -41,6 +41,7 @@ import { Textarea } from "./ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { ProposalJustification } from "../pages/secrets/proposal-review";
import { ConnectionIntentInteractionBody } from "./ConnectionIntentInteractionBody";
const OTHER_ANSWER_ID = "__paperclip_other__";
@@ -4167,6 +4168,12 @@ export function IssueThreadInteractionCard({
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
externalReferences={externalReferences}
/>
) : interaction.kind === "connection_intent" ? (
<ConnectionIntentInteractionBody
interaction={interaction}
currentUserId={currentUserId}
addresseeLabel={addresseeLabel ?? "the addressed person"}
/>
) : (
<RequestConfirmationCard
interaction={interaction}
@@ -1,4 +1,5 @@
import { legacyIssueThreadInteractionResolverPolicyAlias } from "@paperclipai/shared";
import type { ConnectionIntentInteraction } from "@paperclipai/shared";
import type { LiveRunForIssue } from "../api/heartbeats";
import type {
IssueChatComment,
@@ -23,6 +24,45 @@ export const issueThreadInteractionFixtureMeta = {
currentUserId: "user-board",
} as const;
export const pendingConnectionIntentInteraction: ConnectionIntentInteraction = {
id: "interaction-connection-intent-default",
companyId: issueThreadInteractionFixtureMeta.companyId,
issueId: issueThreadInteractionFixtureMeta.issueId,
kind: "connection_intent",
title: "Connect Notion",
summary: "Researcher needs this connection to continue.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "11111111-1111-4111-8111-111111111111",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
addresseeUserId: issueThreadInteractionFixtureMeta.currentUserId,
createdAt: new Date("2026-04-20T15:08:00.000Z"),
updatedAt: new Date("2026-04-20T15:08:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
serviceSlug: "notion",
serviceName: "Notion",
serviceLogoUrl: null,
serviceDarkLogoUrl: null,
requestingAgentId: "11111111-1111-4111-8111-111111111111",
requestingAgentName: "Researcher",
phase: "requested",
},
result: null,
resolverPolicy: "human_only",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "governed_action",
legacyResolverPolicyAliases: {
requested: "board_only",
effective: "board_only",
},
};
/**
* Resolver-audience snapshot fields shared by every interaction fixture.
*
+10
View File
@@ -239,6 +239,16 @@ export function buildIssueThreadInteractionSummary(
return buildItemVerdictsSummary(interaction);
}
if (interaction.kind === "connection_intent") {
const serviceName = interaction.payload.serviceName;
const outcome = interaction.result?.outcome;
if (outcome === "connected") return `Connected ${serviceName}`;
if (outcome === "declined") return `Declined ${serviceName} connection`;
if (outcome === "superseded") return `${serviceName} connection request was superseded`;
if (outcome === "expired") return `${serviceName} connection request expired`;
return `Requested a ${serviceName} connection`;
}
const count = interaction.payload.questions.length;
if (interaction.status === "answered") {
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
+74 -7
View File
@@ -16,6 +16,7 @@ const connectAppMock = vi.hoisted(() => vi.fn());
const startOAuthMock = vi.hoisted(() => vi.fn());
const finishAppMock = vi.hoisted(() => vi.fn());
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
const completeConnectionIntentMock = vi.hoisted(() => vi.fn());
const listAgentsMock = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
@@ -28,6 +29,7 @@ const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!
const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!;
const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!;
const GMAIL = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gmail")!;
const ASANA = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "asana")!;
vi.mock("@/api/tools", () => ({
toolsApi: {
@@ -35,7 +37,9 @@ vi.mock("@/api/tools", () => ({
listApplications: (companyId: string) => listApplicationsMock(companyId),
listConnections: (companyId: string) => listConnectionsMock(companyId),
connectApp: (companyId: string, input: unknown) => connectAppMock(companyId, input),
startOAuth: (connectionId: string) => startOAuthMock(connectionId),
startOAuth: (connectionId: string, interactionId?: string) => interactionId
? startOAuthMock(connectionId, interactionId)
: startOAuthMock(connectionId),
finishApp: (companyId: string, connectionId: string, input: unknown) =>
finishAppMock(companyId, connectionId, input),
putConnectionInstalls: (connectionId: string, installs: unknown) =>
@@ -43,6 +47,13 @@ vi.mock("@/api/tools", () => ({
},
}));
vi.mock("@/api/connection-intents", () => ({
connectionIntentsApi: {
complete: (interactionId: string, connectionId: string) =>
completeConnectionIntentMock(interactionId, connectionId),
},
}));
vi.mock("@/api/agents", () => ({
agentsApi: { list: (companyId: string) => listAgentsMock(companyId) },
}));
@@ -176,6 +187,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expiresAt: "2099-01-01T00:00:00.000Z",
});
finishAppMock.mockResolvedValue({});
completeConnectionIntentMock.mockResolvedValue({ status: "accepted" });
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
connectAppMock.mockResolvedValue({
connectionId: "conn-1",
@@ -620,7 +632,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
it("resumes an existing Notion OAuth connection instead of creating another draft", async () => {
mockSearch.value = "source=notion";
const interactionId = "11111111-1111-4111-8111-111111111111";
mockSearch.value = `source=notion&intent=${interactionId}`;
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
listApplicationsMock.mockResolvedValueOnce({
applications: [{
@@ -649,7 +662,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).toHaveBeenCalledWith("conn-existing");
expect(startOAuthMock).toHaveBeenCalledWith("conn-existing", interactionId);
expect(navigateTopLevelMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=existing",
);
@@ -897,7 +910,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
);
});
it("keeps non-allowlisted OAuth apps blocked", async () => {
it("opens customer-client OAuth apps in branded setup", async () => {
const slack = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "slack")!;
mockParams.appKey = "slack";
listGalleryMock.mockResolvedValueOnce({ apps: [slack] });
@@ -905,7 +918,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(connectAppMock).not.toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect", { replace: true });
expect(container.textContent).toContain("Who is this credential for?");
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
});
it("routes the enabled Notion gallery tile through the generic source deep link", async () => {
@@ -1005,7 +1019,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
it("keeps Zapier visible and finishes without a separate access or install step", async () => {
mockSearch.value = "byo=1&source=zapier";
mockSearch.value = "byo=1&source=zapier&intent=11111111-1111-4111-8111-111111111111";
listGalleryMock.mockResolvedValueOnce({
apps: [
{ ...ZAPIER, branding: { ...ZAPIER.branding, logoUrl: "https://example.com/zapier.png" } },
@@ -1053,7 +1067,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await flushReact();
expect(connectAppMock).toHaveBeenCalledTimes(1);
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, name: "Zapier" });
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({
link: zapierUrl,
name: "Zapier",
interactionId: "11111111-1111-4111-8111-111111111111",
});
// No grantKind is sent: the pasted-URL path never offered the choice, and
// sending "user" without asking would mis-scope the credential.
expect(connectAppMock.mock.calls[0]?.[1]).not.toHaveProperty("grantKind");
@@ -1066,6 +1084,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(putConnectionInstallsMock).toHaveBeenCalledWith("conn-1", [
{ targetType: "company", targetId: "company-1" },
]);
expect(completeConnectionIntentMock).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"conn-1",
);
});
// PAP-10922: "Run your own" / "Paste a config" moved from the sidebar to rows
@@ -1206,6 +1228,51 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=zapier&stage=setup");
});
it("keeps the originating connection intent in wizard URLs", async () => {
const interactionId = "11111111-1111-4111-8111-111111111111";
mockSearch.value = `byo=1&intent=${interactionId}`;
await render();
await act(async () => {
buttonContaining("Zapier")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockNavigate).toHaveBeenCalledWith(
`/apps/connect?byo=1&appKey=zapier&stage=access&intent=${interactionId}`,
);
await passAccessStep();
expect(mockNavigate).toHaveBeenCalledWith(
`/apps/connect?byo=1&appKey=zapier&stage=setup&intent=${interactionId}`,
);
});
it("keeps an intent on its requested manual OAuth provider", async () => {
const interactionId = "11111111-1111-4111-8111-111111111111";
mockSearch.value = `byo=1&appKey=asana&intent=${interactionId}`;
listGalleryMock.mockResolvedValueOnce({
apps: [ASANA],
capabilities: {
canSetCompanyInstall: true,
companyInstallReason: null,
},
});
await render();
expect(container.textContent).toContain("Who is this credential for?");
expect(mockNavigate).not.toHaveBeenCalledWith(
`/apps/connect?byo=1&intent=${interactionId}`,
{ replace: true },
);
await passAccessStep();
expect(container.textContent).toContain("Connect Asana");
expect(mockNavigate).toHaveBeenCalledWith(
`/apps/connect?byo=1&appKey=asana&stage=setup&intent=${interactionId}`,
);
});
it("steps back from the key step to Access, and from Access to the BYO gallery", async () => {
mockSearch.value = "byo=1";
mockParams.appKey = "zapier";
+50 -13
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowUpRight,
@@ -26,7 +26,14 @@ import type {
ToolConnectionAuthKind,
ToolConnectionCreateCapabilities,
} from "@paperclipai/shared";
import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod, getAvailableConnectionMethods } from "@paperclipai/shared";
import {
connectionMethodAcceptsCustomerOAuthClient,
connectionMethodSupportsAutomaticOAuth,
credentialConfigPath,
getAppDefinitionForUrl,
getAvailableConnectionMethod,
getAvailableConnectionMethods,
} from "@paperclipai/shared";
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
@@ -34,6 +41,7 @@ import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { RadioCardGroup } from "@/components/ui/radio-card";
import { ApiError } from "@/api/client";
import { connectionIntentsApi } from "@/api/connection-intents";
import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
import { appCopyFor, credentialFieldLabel } from "@/lib/app-gallery-copy";
@@ -79,11 +87,22 @@ const ROUTE_STAGE_BY_STEP: Partial<Record<Step, string>> = {
success: "complete",
};
function appConnectHref(appKey: string, step: Step): string {
function appConnectHref(appKey: string, step: Step, interactionId?: string | null): string {
const stage = ROUTE_STAGE_BY_STEP[step] ?? "setup";
const params = new URLSearchParams({ byo: "1", appKey, stage });
if (interactionId) params.set("intent", interactionId);
return `/apps/connect?${params.toString()}`;
}
function appsConnectGalleryHref(byoOnly: boolean, interactionId?: string | null): string {
const path = byoOnly ? "/apps/byo" : "/apps/connect";
const params = new URLSearchParams();
if (!byoOnly) params.set("byo", "1");
if (interactionId) params.set("intent", interactionId);
const query = params.toString();
return query ? `${path}?${query}` : path;
}
type AppAccessSelection = "all_agents" | { agentIds: string[] };
// Access comes before credentials so the reader knows what identity and reach
@@ -178,6 +197,9 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
const [searchParams] = useSearchParams();
const appKey = routeParams.appKey ?? searchParams.get("appKey") ?? undefined;
const sourceSlug = searchParams.get("source")?.trim() || null;
// The wizard rewrites its own URL while moving between steps, so retain the
// originating request for the full setup instead of rereading the query.
const [connectionIntentId] = useState(() => searchParams.get("intent")?.trim() || null);
const createNewConnection = searchParams.get("new") === "1";
const directOAuthSource = isMcpDirectOAuthConnectSlug(sourceSlug) ? sourceSlug : null;
const requestedAppKey = appKey ?? directOAuthSource ?? undefined;
@@ -255,7 +277,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
getAvailableConnectionMethod(picked)?.auth === "oauth" &&
isMcpDirectOAuthConnectSlug(picked.slug)
) {
navigate(appSourceConnectHref(picked.slug));
navigate(appSourceConnectHref(picked.slug, connectionIntentId));
return;
}
setEntry(picked);
@@ -277,7 +299,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
setInstallChoice("specific");
setGrantKind(defaultGrantKindFor(picked, initialMethod));
setStep("access");
navigate(appConnectHref(picked.slug, "access"));
navigate(appConnectHref(picked.slug, "access", connectionIntentId));
};
const openGallery = () => {
@@ -298,7 +320,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
setInstallChoice("all");
setGrantKind("organization");
setStep("gallery");
navigate(byoOnly ? "/apps/byo" : "/apps/connect?byo=1");
navigate(appsConnectGalleryHref(byoOnly, connectionIntentId));
};
useEffect(() => {
@@ -354,11 +376,12 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
const setAppStep = (nextStep: Step) => {
setStep(nextStep);
if (entry) navigate(appConnectHref(entry.slug, nextStep));
if (entry) navigate(appConnectHref(entry.slug, nextStep, connectionIntentId));
};
const oauthStartMutation = useMutation({
mutationFn: (connectionId: string) => toolsApi.startOAuth(connectionId),
mutationFn: ({ connectionId, interactionId }: { connectionId: string; interactionId?: string }) =>
toolsApi.startOAuth(connectionId, interactionId),
onSuccess: ({ authorizationUrl }) => {
// The endpoint chose this address, so it is checked here too — this is the
// line where an unsafe scheme would actually run (PAP-17099).
@@ -386,7 +409,10 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
);
},
});
const startOAuth = oauthStartMutation.mutate;
const startOAuthMutation = oauthStartMutation.mutate;
const startOAuth = useCallback((connectionId: string, interactionId = connectionIntentId ?? undefined) => {
startOAuthMutation({ connectionId, interactionId });
}, [connectionIntentId, startOAuthMutation]);
/**
* Commit the Access step's agent reach for a connection. Shared by the
@@ -417,6 +443,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
? configValues
: undefined,
applicationId: prefill.applicationId,
...(connectionIntentId ? { interactionId: connectionIntentId } : {}),
...(grantKind === "user" ? { grantKind } : {}),
});
}
@@ -432,6 +459,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
oauthClientSecret: linkOAuthClientSecret,
}),
applicationId: prefill.applicationId,
...(connectionIntentId ? { interactionId: connectionIntentId } : {}),
...(grantKind === "user" ? { grantKind } : {}),
});
},
@@ -523,12 +551,17 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
const method = requestedEntry ? getAvailableConnectionMethod(requestedEntry) : null;
const methods = requestedEntry ? getAvailableConnectionMethods(requestedEntry) : [];
const directOAuth = method?.auth === "oauth" && isMcpDirectOAuthConnectSlug(requestedEntry?.slug);
const brokeredOAuth = method?.oauthStrategy === "paperclip_id_connector";
const unsupportedOAuth = methods.length === 1 && method?.auth === "oauth" && !brokeredOAuth && !directOAuth;
const unsupportedOAuth = methods.length === 1
&& method?.auth === "oauth"
&& !connectionMethodSupportsAutomaticOAuth(method)
&& !connectionMethodAcceptsCustomerOAuthClient(method);
if (!requestedEntry || unsupportedOAuth || requestedEntry.availability?.available === false) {
setEntry(null);
setStep("gallery");
navigate("/apps/connect", { replace: true });
navigate(
connectionIntentId ? appsConnectGalleryHref(false, connectionIntentId) : "/apps/connect",
{ replace: true },
);
return;
}
@@ -570,7 +603,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
setOAuthError(null);
setOAuthPhase("starting");
if (existingOAuthConnection) {
startOAuth(existingOAuthConnection.id);
startOAuth(existingOAuthConnection.id, connectionIntentId ?? undefined);
} else {
connectApp(requestedEntry);
}
@@ -581,6 +614,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
connectApp,
connectionsQuery.isError,
connectionsQuery.isFetchedAfterMount,
connectionIntentId,
entry?.slug,
existingOAuthConnection,
galleryQuery.data,
@@ -626,6 +660,9 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
access: selection,
});
await applyAccessInstalls(connected.connectionId);
if (connectionIntentId) {
await connectionIntentsApi.complete(connectionIntentId, connected.connectionId);
}
return finished;
},
onSuccess: () => setAppStep("success"),
@@ -24,5 +24,7 @@ describe("app connect policy", () => {
it("builds a generic source deep link", () => {
expect(appSourceConnectHref("notion")).toBe("/apps/connect?source=notion");
expect(appSourceConnectHref("notion", "intent-1"))
.toBe("/apps/connect?source=notion&intent=intent-1");
});
});
+4 -2
View File
@@ -5,8 +5,10 @@ export function isMcpDirectOAuthConnectSlug(slug: string | null | undefined): bo
return MCP_DIRECT_OAUTH_CONNECT_SLUGS.some((allowedSlug) => allowedSlug === slug);
}
export function appSourceConnectHref(slug: string): string {
return `/apps/connect?${new URLSearchParams({ source: slug }).toString()}`;
export function appSourceConnectHref(slug: string, interactionId?: string | null): string {
const params = new URLSearchParams({ source: slug });
if (interactionId) params.set("intent", interactionId);
return `/apps/connect?${params.toString()}`;
}
export function canEnterAppsConnect(searchParams: URLSearchParams): boolean {