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

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