mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
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:
1 parent
fcb2e99e8f
commit
b3343dbd64
96 files changed
+8695
-971
No files matched your search
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user