mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - App connections must work in both the operator UI and agent tool gateway. > - The first stack layer adds secure remote connections. > - Operators still need clear setup, test, and recovery states. > - This pull request adds the gateway behavior and the workspace connection experience. > - The benefit is a connection flow that is easier to understand and recover. ## Linked Issues or Issue Description Refs #11965 This is stack 2 of 11. It depends on stack 1 and replaces another reviewable part of #11965. ## What Changed - Improve remote tool gateway connection behavior. - Add clearer app setup, test, and recovery states. - Add focused server and UI tests for the new paths. - Keep the diff isolated from later identity and catalog work. - Stabilize DNS-pinned remote HTTP protocol fixtures and the managed-runtime public-origin fixture for this independently tested layer. ## Verification - `pnpm -r typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/tool-access-service.test.ts` (150 passed) - `pnpm test:run` - `pnpm check:token-gates` - `pnpm build` ## Risks - Gateway errors now surface through new user-facing states. - A stale connection can require a new setup attempt. - The change does not add a database migration. - The injected HTTP transport and public URL are test-only fixtures; production DNS pinning and runtime behavior are unchanged. > 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 either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/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>
208 lines
8.5 KiB
TypeScript
208 lines
8.5 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
import type { ToolMcpGatewayWithTokens, ToolRedactedValueSummary } from "@paperclipai/shared";
|
|
import { toolsApi, type ToolAuditOutcome, type ToolGatewayActivityEvent } from "@/api/tools";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { StatusBadge } from "@/components/StatusBadge";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { ErrorState, RelativeTime } from "@/pages/tools/shared";
|
|
|
|
const PAGE_SIZE = 25;
|
|
|
|
const OUTCOME_META: Record<ToolAuditOutcome, { label: string; status: string }> = {
|
|
allowed: { label: "Allowed", status: "allowed" },
|
|
blocked: { label: "Blocked", status: "denied" },
|
|
asked_first: { label: "Asked first", status: "require-approval" },
|
|
waiting: { label: "Waiting", status: "deferred" },
|
|
failed: { label: "Failed", status: "failed" },
|
|
unknown: { label: "Recorded", status: "unchecked" },
|
|
};
|
|
|
|
function detailString(details: Record<string, unknown> | null, key: string): string | null {
|
|
const value = details?.[key];
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
function formatSummary(summary: ToolRedactedValueSummary | null | undefined): string | null {
|
|
if (!summary?.summary) return null;
|
|
try {
|
|
return JSON.stringify(JSON.parse(summary.summary), null, 2);
|
|
} catch {
|
|
return summary.summary;
|
|
}
|
|
}
|
|
|
|
function summaryFromDetails(
|
|
details: Record<string, unknown> | null,
|
|
key: "argumentsSummary" | "resultSummary",
|
|
): ToolRedactedValueSummary | null {
|
|
const value = details?.[key];
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const summary = (value as Record<string, unknown>).summary;
|
|
return typeof summary === "string" ? { summary } : null;
|
|
}
|
|
|
|
function durationLabel(event: ToolGatewayActivityEvent): string | null {
|
|
const started = event.invocation?.startedAt ? new Date(event.invocation.startedAt).getTime() : Number.NaN;
|
|
const completed = event.invocation?.completedAt ? new Date(event.invocation.completedAt).getTime() : Number.NaN;
|
|
if (!Number.isFinite(started) || !Number.isFinite(completed) || completed < started) return null;
|
|
return `${completed - started} ms`;
|
|
}
|
|
|
|
function Fact({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
|
return (
|
|
<div className="flex gap-3 py-1">
|
|
<dt className="w-28 shrink-0 text-muted-foreground">{label}</dt>
|
|
<dd className={mono ? "min-w-0 break-all font-mono text-(length:--text-micro) text-foreground" : "min-w-0 text-foreground"}>
|
|
{value}
|
|
</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActivityRow({ event }: { event: ToolGatewayActivityEvent }) {
|
|
const [open, setOpen] = useState(false);
|
|
const outcome = OUTCOME_META[event.normalizedOutcome] ?? OUTCOME_META.unknown;
|
|
const actor = event.agentDisplayName ?? "Client";
|
|
const app = event.appDisplayName ?? event.connectionDisplayName ?? event.applicationDisplayName ?? "App";
|
|
const tool = event.toolDisplayName ?? event.invocation?.toolName ?? "Tool call";
|
|
const rawTool = event.invocation?.toolName ?? detailString(event.details, "tool") ?? detailString(event.details, "toolName");
|
|
const reason = detailString(event.details, "reasonCode");
|
|
const argumentsText = formatSummary(
|
|
event.invocation?.argumentsSummary ?? summaryFromDetails(event.details, "argumentsSummary"),
|
|
);
|
|
const resultText = formatSummary(
|
|
event.invocation?.resultSummary ?? summaryFromDetails(event.details, "resultSummary"),
|
|
);
|
|
const duration = durationLabel(event);
|
|
|
|
return (
|
|
<li className="text-sm">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((current) => !current)}
|
|
className="flex w-full items-start gap-2.5 px-4 py-3 text-left hover:bg-accent/50"
|
|
aria-expanded={open}
|
|
>
|
|
{open ? (
|
|
<ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-foreground">
|
|
<span className="font-medium">{actor}</span> used <span className="font-medium">{tool}</span> in {app}
|
|
</span>
|
|
</span>
|
|
<span className="flex shrink-0 items-center gap-2 whitespace-nowrap">
|
|
<StatusBadge status={outcome.status} label={outcome.label} />
|
|
<span className="text-xs text-muted-foreground">
|
|
· <RelativeTime value={event.createdAt} />
|
|
</span>
|
|
</span>
|
|
</button>
|
|
|
|
{open ? (
|
|
<div className="border-t border-border bg-muted/30 px-4 py-3 pl-10 text-xs">
|
|
<dl>
|
|
{rawTool ? <Fact label="Tool" value={rawTool} mono /> : null}
|
|
{event.invocation?.status ? <Fact label="Call status" value={event.invocation.status} /> : null}
|
|
{event.invocation?.policyDecision ? <Fact label="Decision" value={event.invocation.policyDecision} /> : null}
|
|
{reason ? <Fact label="Reason" value={reason} mono /> : null}
|
|
{duration ? <Fact label="Duration" value={duration} /> : null}
|
|
{event.invocation?.id ? <Fact label="Invocation ID" value={event.invocation.id} mono /> : null}
|
|
{event.invocation?.errorCode ? <Fact label="Error code" value={event.invocation.errorCode} mono /> : null}
|
|
{event.invocation?.errorMessage ? <Fact label="Error" value={event.invocation.errorMessage} /> : null}
|
|
</dl>
|
|
{argumentsText ? (
|
|
<div className="mt-2 space-y-1">
|
|
<div className="text-muted-foreground">Arguments (redacted)</div>
|
|
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background p-3 font-mono text-xs text-foreground">
|
|
{argumentsText}
|
|
</pre>
|
|
</div>
|
|
) : null}
|
|
{resultText ? (
|
|
<div className="mt-3 space-y-1">
|
|
<div className="text-muted-foreground">Result (redacted)</div>
|
|
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background p-3 font-mono text-xs text-foreground">
|
|
{resultText}
|
|
</pre>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export function GatewayActivityPanel({
|
|
companyId,
|
|
gateway,
|
|
}: {
|
|
companyId: string;
|
|
gateway: ToolMcpGatewayWithTokens;
|
|
}) {
|
|
const activityQuery = useInfiniteQuery({
|
|
queryKey: queryKeys.tools.activity(companyId, { gateway: gateway.id, window: "30d" }),
|
|
queryFn: ({ pageParam }) =>
|
|
toolsApi.listActivity(companyId, {
|
|
gateway: gateway.id,
|
|
window: "30d",
|
|
limit: PAGE_SIZE,
|
|
cursor: pageParam ?? undefined,
|
|
}),
|
|
initialPageParam: null as string | null,
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
|
});
|
|
|
|
const events = useMemo(
|
|
() => activityQuery.data?.pages.flatMap((page) => page.events) ?? [],
|
|
[activityQuery.data],
|
|
);
|
|
|
|
if (activityQuery.isLoading) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-12 w-full" />
|
|
<Skeleton className="h-12 w-full" />
|
|
<Skeleton className="h-12 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
if (activityQuery.isError) {
|
|
return <ErrorState error={activityQuery.error} onRetry={() => activityQuery.refetch()} />;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
Calls through this gateway from the last 30 days. Open a row to inspect its tool, redacted arguments, result, and decision.
|
|
</p>
|
|
{events.length === 0 ? (
|
|
<div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
|
No calls have gone through this gateway yet.
|
|
</div>
|
|
) : (
|
|
<ul className="divide-y divide-border rounded-lg border border-border">
|
|
{events.map((event) => <ActivityRow key={event.id} event={event} />)}
|
|
</ul>
|
|
)}
|
|
{activityQuery.hasNextPage ? (
|
|
<div className="flex justify-center">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => activityQuery.fetchNextPage()}
|
|
disabled={activityQuery.isFetchingNextPage}
|
|
>
|
|
{activityQuery.isFetchingNextPage ? "Loading…" : "Load more"}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|