mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02: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,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}
|
||||
|
||||
Reference in new issue
Block a user