mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
feat(dot): expand governed Runner capabilities and finish pairing setup
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
426e9fac15
commit
ebc99db2f5
45 files changed
+1409
-105
No files matched your search
@@ -267,6 +267,8 @@ export function CodexLocalConfigFields({
|
||||
<Field configSection="adapter" label="Dot connection" hint="A verified event round trip is required before assigning work.">
|
||||
<DotRunnerConnection companyId={companyId} agentId={agentId} bindingId={String(runnerSchemaValue("dotBindingId", ""))} onBinding={id => updateRunnerSchemaValue("dotBindingId", id)} />
|
||||
</Field>
|
||||
<ToggleField label="Workspace files and commands" hint="Let Dot read and write its assigned workspace, run commands in an OS sandbox, and publish files. Requires a local Runner; sandboxed commands cannot read your home directory or use injected credentials."
|
||||
checked={runnerSchemaValue("dotWorkspaceAccess", false) === true} onChange={value => updateRunnerSchemaValue("dotWorkspaceAccess", value)} />
|
||||
<ToggleField label="Allow externally billed provider" hint="Dot does not report token usage or cost. Paperclip cannot enforce a provider spend ceiling; known company and agent budget limits still apply."
|
||||
checked={runnerSchemaValue("allowUnmeteredProvider", false) === true} onChange={value => updateRunnerSchemaValue("allowUnmeteredProvider", value)} />
|
||||
</>}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { DotRunnerConnection } from "./DotRunnerConnection";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
|
||||
const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), delete: vi.fn() }));
|
||||
vi.mock("../api/client", () => ({ api }));
|
||||
vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard: vi.fn() }));
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const binding = { id: "binding", status: "connected", connected: true, subscriptionVerified: true, hasPendingChallenge: false };
|
||||
let root: Root;
|
||||
@@ -90,3 +92,62 @@ it("shows event testing when a connected Dot subscribes without a manual refresh
|
||||
expect(container.textContent).toContain("Event subscription: verified");
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
|
||||
it("copies the complete Dot setup with its server URL, one-use code and event instructions", async () => {
|
||||
const expiresAt = new Date(Date.now() + 15 * 60_000).toISOString();
|
||||
api.post.mockImplementation(async () => {
|
||||
api.get.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", binding: { ...binding, status: "pairing", connected: false, subscriptionVerified: false } });
|
||||
return { bindingId: binding.id, pairingCode: "fresh-one-use-pairing-code", expiresAt };
|
||||
});
|
||||
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
|
||||
await render();
|
||||
expect(container.querySelector('[aria-label="Set up with Dot"]')).toBeNull();
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Pair Dot")!.click());
|
||||
await flush();
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Set up with Dot"]')!.click());
|
||||
expect(copyTextToClipboard).toHaveBeenCalledTimes(1);
|
||||
const prompt = vi.mocked(copyTextToClipboard).mock.calls[0][0];
|
||||
expect(prompt).toContain("Add and enable a private MCP plugin");
|
||||
expect(prompt).toContain("https://paperclip.example/mcp/runner");
|
||||
expect(prompt).toContain('"Connect Dot with pairing code"');
|
||||
expect(prompt).toContain("Paperclip company: company. Agent: agent.");
|
||||
expect(prompt).toContain("One-use pairing code:\nfresh-one-use-pairing-code");
|
||||
expect(prompt).toContain(expiresAt);
|
||||
expect(prompt).toContain("paperclip.dot.mailbox_updated");
|
||||
expect(prompt).toContain("paperclip_dot_inbox");
|
||||
expect(prompt).toContain("paperclip_dot_confirm_event");
|
||||
expect(prompt).toContain('"Test event delivery"');
|
||||
expect(document.querySelector('[aria-label="Setup prompt"]')?.textContent).toBe(prompt);
|
||||
expect(document.querySelector(".agent-setup-copy")?.textContent).toBe("Copied to clipboard");
|
||||
expect(api.post).toHaveBeenCalledTimes(1);
|
||||
// Returning to setup cannot recover the one-use code from cached config.
|
||||
await act(async () => root.render(null));
|
||||
await render();
|
||||
expect(container.querySelector('[aria-label="Set up with Dot"]')).toBeNull();
|
||||
expect(document.querySelector('[aria-label="Setup prompt"]')).toBeNull();
|
||||
expect(container.textContent).toContain("generate a fresh setup prompt");
|
||||
expect(container.textContent).not.toContain("fresh-one-use-pairing-code");
|
||||
});
|
||||
|
||||
it("removes the copy action and open preview when the pairing code expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
api.post.mockImplementation(async () => {
|
||||
api.get.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", binding: { ...binding, status: "pairing", connected: false, subscriptionVerified: false } });
|
||||
return { bindingId: binding.id, pairingCode: "soon-expiring-pairing-code", expiresAt: new Date(Date.now() + 1000).toISOString() };
|
||||
});
|
||||
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><Form /></QueryClientProvider>));
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(20); });
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Pair Dot")!.click());
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(20); });
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Set up with Dot"]')!.click());
|
||||
expect(document.querySelector('[aria-label="Setup prompt"]')?.textContent).toContain("soon-expiring-pairing-code");
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
|
||||
expect(container.querySelector('[aria-label="Set up with Dot"]')).toBeNull();
|
||||
expect(document.querySelector('[aria-label="Setup prompt"]')).toBeNull();
|
||||
expect(container.textContent).toContain("generate a fresh setup prompt");
|
||||
expect(copyTextToClipboard).toHaveBeenCalledTimes(1);
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
import { Button } from "./ui/button";
|
||||
import { AgentSetupPrompt } from "./AgentSetupPrompt";
|
||||
|
||||
interface Connection {
|
||||
enabled: boolean;
|
||||
@@ -34,6 +35,11 @@ export function DotRunnerConnection({ companyId, agentId, bindingId, onBinding }
|
||||
setPairing(null); onBinding("");
|
||||
await client.invalidateQueries({ queryKey: key });
|
||||
} });
|
||||
useEffect(() => {
|
||||
if (!pairing) return;
|
||||
const timer = window.setTimeout(() => setPairing(null), Math.max(0, Date.parse(pairing.expiresAt) - Date.now()));
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [pairing]);
|
||||
const existingBindingId = state.data?.binding?.id;
|
||||
useEffect(() => {
|
||||
if (existingBindingId && existingBindingId !== bindingId && !revoke.isPending) onBinding(existingBindingId);
|
||||
@@ -41,19 +47,45 @@ export function DotRunnerConnection({ companyId, agentId, bindingId, onBinding }
|
||||
if (!agentId) return <p className="text-sm text-muted-foreground">Save the agent, then return here to pair your Dot.</p>;
|
||||
const error = state.error ?? pair.error ?? test.error ?? revoke.error;
|
||||
const b = state.data?.binding;
|
||||
const setupPrompt = pairing && state.data?.resourceUrl ? `Add and enable a private MCP plugin named "Paperclip Dot" using this server URL:
|
||||
${state.data.resourceUrl}
|
||||
|
||||
Use OAuth authentication with CIMD. Reuse a plugin for this exact URL if one is already installed. If the name is already used for another URL, choose an available name such as "Paperclip Dot Test Drive" and keep the other plugin unchanged. Complete its connection in your browser: enter the one-use pairing code below in Paperclip’s "Pairing code" field. Entering the code automatically shows a scope preview without consuming the code or granting access. Verify the displayed company and agent match the IDs below, then click "Connect Dot with pairing code" to grant access and consume the code. This authorizes only the agent selected in Paperclip; operator sign-in is unnecessary. Paperclip company: ${companyId}. Agent: ${agentId}.
|
||||
|
||||
When you setup the plugin, I already understand that MCP servers have risk, so you check “I understand and want to continue,” and click “Create as a plugin.” I want you to create the plugin for me completely and I don't want to have to do anything.
|
||||
|
||||
One-use pairing code:
|
||||
${pairing.pairingCode}
|
||||
|
||||
Entering this code on the OAuth connection page completes agent pairing. Then use the connected plugin to call paperclip_dot_inbox with {"after":0} to verify access and obtain the binding ID. Do not call paperclip_dot_pair again: the code has already been consumed.
|
||||
|
||||
This one-use code expires at ${pairing.expiresAt}. If it has expired, ask me for a fresh setup prompt.
|
||||
|
||||
Subscribe through MCP Events to paperclip.dot.mailbox_updated using Paperclip company ${companyId} and the binding ID returned by the inbox. Complete callback verification and follow the returned eventInstructions.
|
||||
|
||||
Whenever an event arrives, drain paperclip_dot_inbox after your last cursor. Confirm readiness_challenge items with paperclip_dot_confirm_event. Read and accept assignments, then work through their provided tool catalog and follow the returned completion instructions.
|
||||
|
||||
When I ask you to do work while idle, first call paperclip_dot_capabilities. Use paperclip_dot_request_turn with my request and a stable UUID; you do not need an already assigned task. Read and accept the resulting assignment, then use its provided catalog. Use get_identity and list_people to discover people, and create_task with assigneeUserId to assign a task to a person. Read assigned skills and use assigned app tools when provided. Renew accepted assignments with paperclip_dot_renew before expiry. When follow_up items arrive, read get_task_history and incorporate the new comments at the next safe boundary. After a tool returns pending, check its receipt with the same request ID; never duplicate mutations.
|
||||
|
||||
Once the event subscription is verified, tell me to click "Test event delivery" in Paperclip. Confirm that challenge when it arrives so the connection becomes ready.` : "";
|
||||
return <div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Dot manages its model and external tools. Paperclip supplies task tools through a dedicated agent connection. Provider usage and cost are unavailable; stopping a Paperclip run revokes access without confirming an external stop.</p>
|
||||
<p className="text-sm text-muted-foreground">Dot manages its model and external tools. Paperclip supplies task coordination, people, assigned skills, and connected app tools. Dot can start a task directly from its conversation. Enable workspace files and commands below for file work. Provider usage and cost are unavailable; stopping a Paperclip run revokes access without confirming an external stop.</p>
|
||||
{state.data && !state.data.enabled && <p className="text-sm text-muted-foreground">Enable OpenAI Dot, Paperclip Runner, and Assistant connections (MCP) in experimental settings.</p>}
|
||||
{state.data?.resourceUrl && <p className="text-sm break-all">Private plugin MCP URL: <code>{state.data.resourceUrl}</code></p>}
|
||||
{b && <p className="text-sm">Connection: {b.status}. Event subscription: {b.subscriptionVerified ? "verified" : "required"}.</p>}
|
||||
{b?.assignment && <p className="text-sm">Assignment: {b.assignment.status === "offered" ? "waiting for Dot to accept" : "accepted by Dot"}.</p>}
|
||||
{b?.assignment?.attentionRequired && <p className="text-sm text-destructive" role="alert">Dot has not reported useful activity for 15 minutes. Inspect the Dot before assigning replacement work; Paperclip cannot confirm its external stop.</p>}
|
||||
{pairing && b?.status === "pairing" && <div className="space-y-2 rounded-md border border-border p-3">
|
||||
<p className="text-sm">Connect the private plugin in your Dot and approve agent access. Ask it to call <code>paperclip_dot_pair</code> with this code, then subscribe to <code>paperclip.dot.mailbox_updated</code> for the returned binding.</p>
|
||||
<code className="text-sm break-all">{pairing.pairingCode}</code>
|
||||
<p className="text-xs text-muted-foreground">Expires {new Date(pairing.expiresAt).toLocaleTimeString()}.</p>
|
||||
{pairing && setupPrompt && b?.status === "pairing" && <div className="space-y-2">
|
||||
<AgentSetupPrompt
|
||||
prompt={setupPrompt}
|
||||
label="Set up with Dot"
|
||||
title="Connect your Dot"
|
||||
description="Paste into your Dot to connect this agent."
|
||||
side="bottom"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Setup prompt expires {new Date(pairing.expiresAt).toLocaleTimeString()}.</p>
|
||||
</div>}
|
||||
{!pairing && b?.status === "pairing" && <p className="text-sm text-muted-foreground">This page no longer has the one-use code. Revoke the pending connection and pair again to generate a fresh code.</p>}
|
||||
{!pairing && b?.status === "pairing" && <p className="text-sm text-muted-foreground">Revoke the pending connection and pair again to generate a fresh setup prompt.</p>}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!b && <Button type="button" variant="outline" disabled={!state.data?.enabled || !state.data?.resourceUrl || pair.isPending} onClick={() => pair.mutate()}>Pair Dot</Button>}
|
||||
{b?.subscriptionVerified && <Button type="button" variant="outline" disabled={test.isPending || b.hasPendingChallenge} onClick={() => test.mutate()}>Test event delivery</Button>}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { api } from "../api/client";
|
||||
import { McpConnectPage, McpDevicePage } from "./McpConnect";
|
||||
|
||||
const route = vi.hoisted(() => ({ id: "request-one", companyId: null as string | null, unavailable: false, canWrite: true, requestedWrite: true, requestedConfigure: false, agentConnection: false, clientName: "Assistant", clientOrigin: null as string | null, setupUrl: "https://my.paperclip.app/orgs/new", reverseCompanies: false, hideFirst: false }));
|
||||
const route = vi.hoisted(() => ({ id: "request-one", companyId: null as string | null, unavailable: false, canWrite: true, requestedWrite: true, requestedConfigure: false, agentConnection: false, requiresSignIn: false, clientName: "Assistant", clientOrigin: null as string | null, setupUrl: "https://my.paperclip.app/orgs/new", reverseCompanies: false, hideFirst: false }));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useParams: () => ({ id: route.id }),
|
||||
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
@@ -19,7 +19,7 @@ vi.mock("@/components/CompanyPatternIcon", () => ({
|
||||
vi.mock("../api/client", () => ({ api: {
|
||||
get: vi.fn(async () => ({
|
||||
id: route.id, clientName: route.clientName, clientOrigin: route.clientOrigin, redirectOrigin: "https://assistant.example.test",
|
||||
requestedWrite: route.requestedWrite, requestedConfigure: route.requestedConfigure, agentConnection: route.agentConnection, offlineAccess: true, requiresSignIn: false, requestedCompanyId: route.companyId,
|
||||
requestedWrite: route.requestedWrite, requestedConfigure: route.requestedConfigure, agentConnection: route.agentConnection, offlineAccess: true, requiresSignIn: route.requiresSignIn, requestedCompanyId: route.companyId,
|
||||
companies: route.unavailable ? [] : [
|
||||
{ id: route.companyId ?? "company-one", name: "Acme Research", logoUrl: "/api/assets/acme-logo/content", canWrite: route.canWrite },
|
||||
...(!route.companyId ? [{ id: "company-two", name: "Design Partners", logoUrl: null, canWrite: true }] : []),
|
||||
@@ -29,7 +29,7 @@ vi.mock("../api/client", () => ({ api: {
|
||||
} }));
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(route, { id: "request-one", companyId: null, unavailable: false, canWrite: true, requestedWrite: true, requestedConfigure: false, agentConnection: false, clientName: "Assistant", clientOrigin: null, setupUrl: "https://my.paperclip.app/orgs/new", reverseCompanies: false, hideFirst: false });
|
||||
Object.assign(route, { id: "request-one", companyId: null, unavailable: false, canWrite: true, requestedWrite: true, requestedConfigure: false, agentConnection: false, requiresSignIn: false, clientName: "Assistant", clientOrigin: null, setupUrl: "https://my.paperclip.app/orgs/new", reverseCompanies: false, hideFirst: false });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -61,6 +61,69 @@ it("lets a person correct an invalid device code without reloading", async () =>
|
||||
} finally { page.cleanup(); }
|
||||
});
|
||||
|
||||
it("lets Dot use the operator pairing capability without signing into a board account", async () => {
|
||||
Object.assign(route, { agentConnection: true, requiresSignIn: true });
|
||||
vi.mocked(api.post).mockImplementationOnce(async () => ({ company: { id: "company-one", name: "Dot Test Drive" }, agent: { id: "agent-one", name: "Dot" }, permissions: "Assigned work only", accessDuration: "Ongoing until revoked", pairingExpiresAt: new Date(Date.now() + 60000).toISOString() }) as never);
|
||||
const page = setup();
|
||||
try {
|
||||
await vi.waitFor(() => expect(page.container.querySelector("#dot-pairing-code")).not.toBeNull());
|
||||
const input = page.container.querySelector<HTMLInputElement>("#dot-pairing-code")!;
|
||||
expect(input.type).toBe("password");
|
||||
const connect = Array.from(page.container.querySelectorAll("button")).find(button => button.textContent === "Connect Dot with pairing code")!;
|
||||
expect(connect.disabled).toBe(true);
|
||||
flushSync(() => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "x".repeat(32));
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await vi.waitFor(() => expect(page.container.textContent).toContain("Dot Test Drive"));
|
||||
expect(page.container.textContent).toContain("Assigned work only");
|
||||
expect(page.container.textContent).toContain("Ongoing until revoked");
|
||||
expect(connect.disabled).toBe(false);
|
||||
flushSync(() => connect.click());
|
||||
await vi.waitFor(() => expect(api.post).toHaveBeenCalledWith("/mcp/requests/request-one/dot-pairing", { pairingCode: "x".repeat(32) }));
|
||||
} finally { page.cleanup(); }
|
||||
});
|
||||
|
||||
it("keeps approval disabled while replacing a code and ignores an older preview response", async () => {
|
||||
Object.assign(route, { agentConnection: true, requiresSignIn: true });
|
||||
let resolveOld!: (value: unknown) => void;
|
||||
let resolveCurrent!: (value: unknown) => void;
|
||||
vi.mocked(api.post).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve; }) as never)
|
||||
.mockImplementationOnce(() => new Promise(resolve => { resolveCurrent = resolve; }) as never);
|
||||
const page = setup();
|
||||
try {
|
||||
await vi.waitFor(() => expect(page.container.querySelector("#dot-pairing-code")).not.toBeNull());
|
||||
const input = page.container.querySelector<HTMLInputElement>("#dot-pairing-code")!;
|
||||
const connect = Array.from(page.container.querySelectorAll("button")).find(button => button.textContent === "Connect Dot with pairing code")!;
|
||||
const enter = (value: string) => flushSync(() => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
enter("x".repeat(32));
|
||||
await vi.waitFor(() => expect(resolveOld).toBeTypeOf("function"));
|
||||
enter("y".repeat(32));
|
||||
expect(connect.disabled).toBe(true);
|
||||
await vi.waitFor(() => expect(resolveCurrent).toBeTypeOf("function"));
|
||||
resolveCurrent({ company: { id: "current", name: "Current company" }, agent: { id: "agent", name: "Dot" }, permissions: "Assigned work", accessDuration: "Until revoked" });
|
||||
await vi.waitFor(() => expect(connect.disabled).toBe(false));
|
||||
resolveOld({ company: { id: "old", name: "Old company" }, agent: { id: "other", name: "Other agent" }, permissions: "Other work", accessDuration: "Until revoked" });
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
expect(page.container.textContent).toContain("Current company");
|
||||
expect(page.container.textContent).not.toContain("Old company");
|
||||
enter("");
|
||||
expect(connect.disabled).toBe(true);
|
||||
expect(page.container.textContent).not.toContain("Current company");
|
||||
} finally { page.cleanup(); }
|
||||
});
|
||||
|
||||
it("does not offer Dot pairing-code approval on a personal connection", async () => {
|
||||
const page = setup();
|
||||
try {
|
||||
await vi.waitFor(() => expect(page.container.querySelector("h1")).not.toBeNull());
|
||||
expect(page.container.querySelector("#dot-pairing-code")).toBeNull();
|
||||
} finally { page.cleanup(); }
|
||||
});
|
||||
|
||||
it("identifies the receiving app and registered callback origin before approval", async () => {
|
||||
route.companyId = "company-one";
|
||||
route.clientName = "Claude";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Globe, Paperclip } from "lucide-react";
|
||||
import { Link, useParams } from "@/lib/router";
|
||||
import type { McpConnection, McpConnectionRequest } from "@paperclipai/shared";
|
||||
import type { McpConnection, McpConnectionRequest, McpDotPairingPreview } from "@paperclipai/shared";
|
||||
import { deriveInitials, Identity } from "@/components/Identity";
|
||||
import { assistantConnectionDisplayName } from "./apps/connection-owner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -47,10 +47,22 @@ export function McpDevicePage({ initialCode }: { initialCode?: string } = {}) {
|
||||
|
||||
function McpConnectRequest({ id, device = false, onEditCode }: { id: string; device?: boolean; onEditCode?: () => void }) {
|
||||
const [companyId, setCompanyId] = useState("");
|
||||
const [pairingCode, setPairingCode] = useState("");
|
||||
const [pairingPreview, setPairingPreview] = useState<McpDotPairingPreview | null>(null);
|
||||
const [pairingPreviewError, setPairingPreviewError] = useState("");
|
||||
const [writeEnabled, setWriteEnabled] = useState(true);
|
||||
const [deviceResult, setDeviceResult] = useState<"approved" | "denied" | null>(null);
|
||||
const request = useQuery({ queryKey: [device ? "mcp-device" : "mcp-request", id], queryFn: () => api.get<McpConnectionRequest>(device ? `/mcp/device?user_code=${encodeURIComponent(id)}` : `/mcp/requests/${encodeURIComponent(id)}`), retry: false });
|
||||
const data = request.data;
|
||||
useEffect(() => {
|
||||
setPairingPreview(null); setPairingPreviewError("");
|
||||
if (!data?.agentConnection || device || !/^[A-Za-z0-9_-]{32}$/.test(pairingCode.trim())) return;
|
||||
let current = true;
|
||||
void api.post<McpDotPairingPreview>(`/mcp/requests/${encodeURIComponent(id)}/dot-pairing/preview`, { pairingCode: pairingCode.trim() })
|
||||
.then(preview => { if (current) setPairingPreview(preview); })
|
||||
.catch(error => { if (current) setPairingPreviewError(error instanceof Error ? error.message : "Unable to verify this pairing code."); });
|
||||
return () => { current = false; };
|
||||
}, [id, device, data?.agentConnection, pairingCode]);
|
||||
const selectedCompanyId = data?.requestedCompanyId ?? (companyId || data?.companies[0]?.id || "");
|
||||
// Pin the default once loaded so a refetch cannot silently switch organizations.
|
||||
useEffect(() => {
|
||||
@@ -67,6 +79,10 @@ function McpConnectRequest({ id, device = false, onEditCode }: { id: string; dev
|
||||
mutationFn: (decision: "approve" | "deny") => api.post<{ redirectUrl?: string; status?: "approved" | "denied" }>(device ? "/mcp/device/consent" : `/mcp/requests/${encodeURIComponent(id)}/consent`, { decision, companyId: selectedCompanyId || undefined, allowWrites: decision === "approve" && allowWrites, allowConfiguration: decision === "approve" && allowConfiguration, ...(device ? { userCode: id } : {}) }),
|
||||
onSuccess: ({ redirectUrl, status }) => { if (device && status) setDeviceResult(status); else if (redirectUrl) window.location.assign(redirectUrl); },
|
||||
});
|
||||
const pairDot = useMutation({
|
||||
mutationFn: () => api.post<{ redirectUrl: string }>(`/mcp/requests/${encodeURIComponent(id)}/dot-pairing`, { pairingCode: pairingCode.trim() }),
|
||||
onSuccess: ({ redirectUrl }) => { setPairingCode(""); window.location.assign(redirectUrl); },
|
||||
});
|
||||
if (deviceResult) return <div className="mx-auto max-w-xl py-10"><Card className="block space-y-4 p-6"><Paperclip className="size-8" /><h1 className="text-xl font-semibold">{deviceResult === "approved" ? "Access approved" : "Connection declined"}</h1><p className="text-sm">{deviceResult === "approved" ? "Return to your assistant. It will finish connecting automatically." : "No access was granted. You can start a new connection from your assistant."}</p><Button variant="outline" asChild><Link to="/">Back to Paperclip</Link></Button></Card></div>;
|
||||
const returnPath = device ? `/mcp-device?user_code=${encodeURIComponent(id)}` : `/mcp-connect/${id}`;
|
||||
return <div className="mx-auto max-w-xl py-10">
|
||||
@@ -81,6 +97,20 @@ function McpConnectRequest({ id, device = false, onEditCode }: { id: string; dev
|
||||
{request.isPending && <p className="text-sm text-muted-foreground">Loading connection request…</p>}
|
||||
{request.error && <p className="text-sm text-destructive">{request.error.message} Start a new connection from your assistant.</p>}
|
||||
{device && request.error && <Button variant="outline" onClick={onEditCode}>Enter a different code</Button>}
|
||||
{data?.agentConnection && !device && <form className="space-y-3" onSubmit={event => { event.preventDefault(); pairDot.mutate(); }}>
|
||||
<p className="text-sm">Enter the one-use code from your Dot setup prompt to review this agent’s access. Access is granted when you click “Connect Dot with pairing code”.</p>
|
||||
<label htmlFor="dot-pairing-code" className="text-sm">Pairing code</label>
|
||||
<Input id="dot-pairing-code" type="password" autoComplete="off" value={pairingCode} onChange={event => { setPairingPreview(null); setPairingPreviewError(""); setPairingCode(event.target.value); }} required maxLength={32} />
|
||||
{pairingPreview && <dl className="space-y-2 rounded-md border border-border p-3 text-sm">
|
||||
<div><dt className="text-muted-foreground">Company</dt><dd>{pairingPreview.company.name} <span className="text-xs text-muted-foreground">({pairingPreview.company.id})</span></dd></div>
|
||||
<div><dt className="text-muted-foreground">Agent</dt><dd>{pairingPreview.agent.name} <span className="text-xs text-muted-foreground">({pairingPreview.agent.id})</span></dd></div>
|
||||
<div><dt className="text-muted-foreground">Permissions</dt><dd>{pairingPreview.permissions}</dd></div>
|
||||
<div><dt className="text-muted-foreground">Access duration</dt><dd>{pairingPreview.accessDuration}</dd></div>
|
||||
</dl>}
|
||||
{pairingPreviewError && <p role="alert" className="text-sm text-destructive">{pairingPreviewError}</p>}
|
||||
{pairDot.error && <p role="alert" className="text-sm text-destructive">{pairDot.error.message}</p>}
|
||||
<div className="flex justify-end"><Button type="submit" disabled={!pairingPreview || !/^[A-Za-z0-9_-]{32}$/.test(pairingCode.trim()) || pairDot.isPending}>{pairDot.isPending ? "Connecting…" : "Connect Dot with pairing code"}</Button></div>
|
||||
</form>}
|
||||
{data && <>
|
||||
{data.requiresSignIn ? <Button asChild><Link to={`/auth?next=${encodeURIComponent(returnPath)}`}>Sign in / Create account</Link></Button> : <>
|
||||
{data.requestedCompanyId ? <div className="flex items-center gap-4 rounded-md border border-border p-4">
|
||||
|
||||
Reference in new issue
Block a user