From 1fa36be353f961563dc5cb576d0e3a321556b833 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:45:08 -0500 Subject: [PATCH] fix(ui): use HTTP-safe clipboard copy everywhere (#10875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators often open self-hosted Paperclip over plain HTTP on a LAN or private network. > - Browser Clipboard API writes are not reliable in that insecure context. > - Paperclip already has one shared helper with a legacy copy fallback, but many current copy actions bypass it. > - This pull request routes every core UI copy action and the first-party workspace-diff plugin through the shared helper. > - The benefit is consistent copy behavior on HTTPS, localhost, and plain-HTTP private deployments. ## Linked Issues or Issue Description Refs #3529. This change supersedes the stale prior attempt in #3531. Current master has more copy surfaces and a first-party plugin UI bridge that the prior branch does not cover. ## What Changed - Replaced direct Clipboard API writes and duplicate fallback implementations across the current core UI with `copyTextToClipboard`. - Added an HTTP-safe clipboard function to the plugin UI SDK and wired the host bridge to the same implementation. - Migrated the first-party workspace-diff plugin to the plugin SDK clipboard function. - Added unit coverage for native rejection fallback and plugin host delegation. - Added a source-level regression test that rejects new direct clipboard writes outside the shared implementation. - Documented the plugin UI clipboard function. ## Verification - `NODE_ENV=test pnpm exec vitest run ...` for 14 affected suites: 164 tests passed. - `pnpm exec vitest run tests/ui-clipboard.test.ts` in `packages/plugins/sdk`: 1 test passed. - `NODE_ENV=test pnpm -r typecheck`: passed for 31 workspace projects. - `NODE_ENV=test pnpm test:run`: passed. - `NODE_ENV=production pnpm build`: passed. - `pnpm check:token-gates`: passed with all gates clean. ## Risks Low risk. Secure contexts still use the modern Clipboard API. Plain HTTP and rejected modern writes use the existing `execCommand("copy")` fallback. That API is deprecated, but it is the compatibility path required for insecure contexts. The change has no schema, API, or visual design effect. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, `gpt-5.6-sol`. The runtime did not expose a context-window size. Reasoning, tool use, repository editing, test execution, and GitHub CLI access were enabled. ## 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 (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) 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 --- .../plugin-workspace-diff/src/ui/index.tsx | 4 +- packages/plugins/sdk/README.md | 12 ++- packages/plugins/sdk/src/ui/clipboard.ts | 12 +++ packages/plugins/sdk/src/ui/index.ts | 2 + .../plugins/sdk/tests/ui-clipboard.test.ts | 23 ++++++ ui/src/components/AgentActionButtons.tsx | 5 +- ui/src/components/AgentBubbleActionRow.tsx | 13 ++-- ui/src/components/CommentThread.tsx | 24 +----- ui/src/components/CopyText.tsx | 19 +---- ui/src/components/DocumentAnnotationPanel.tsx | 5 +- ui/src/components/FileViewerSheet.tsx | 24 +----- .../components/IssueContinuationHandoff.tsx | 7 +- .../components/IssueDocumentsSection.test.tsx | 75 +++++++++++++++++++ ui/src/components/IssueDocumentsSection.tsx | 3 +- ui/src/components/IssueWorkspaceCard.tsx | 3 +- ui/src/components/MarkdownBody.tsx | 18 +---- ui/src/components/NewAgentDialog.tsx | 21 +++--- .../components/StandaloneBrowserControls.tsx | 9 +-- .../components/WorkspaceServiceControlBar.tsx | 4 +- ui/src/components/WorktreeBanner.tsx | 11 ++- .../issue-properties/IssueProperties.tsx | 3 +- ui/src/components/task-chat/TweakPanel.tsx | 3 +- ui/src/lib/clipboard-usage.test.ts | 26 +++++++ ui/src/lib/clipboard.test.ts | 14 ++++ ui/src/lib/clipboard.ts | 2 +- ui/src/pages/AgentDetail.tsx | 13 +++- ui/src/pages/CompanyInvites.tsx | 37 +-------- ui/src/pages/CompanySkills.tsx | 11 ++- ui/src/pages/RoutineDetail.tsx | 3 +- ui/src/pages/Secrets.tsx | 4 +- ui/src/pages/SkillStudio.tsx | 2 +- ui/src/pages/apps/AppsConnect.tsx | 3 +- .../apps/gateways/ConnectClientDialog.tsx | 6 +- .../gateways/panels/GatewayAdvancedPanel.tsx | 3 +- .../apps/gateways/panels/OverviewPanel.tsx | 3 +- .../apps/gateways/panels/TokensPanel.tsx | 6 +- ui/src/pages/tools/GatewaysTab.tsx | 6 +- ui/src/plugins/bridge-init.ts | 2 + ui/src/plugins/slots.tsx | 4 +- 39 files changed, 266 insertions(+), 179 deletions(-) create mode 100644 packages/plugins/sdk/src/ui/clipboard.ts create mode 100644 packages/plugins/sdk/tests/ui-clipboard.test.ts create mode 100644 ui/src/lib/clipboard-usage.test.ts diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx index 40b96249b4..203d6db0af 100644 --- a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx +++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx @@ -1,5 +1,5 @@ import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui"; -import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui"; +import { copyTextToClipboard, usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui"; import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs"; import type { PatchDiffProps } from "@pierre/diffs/react"; import { useFileDiffInstance } from "@pierre/diffs/react"; @@ -593,7 +593,7 @@ export function ChangesTab({ context }: PluginDetailTabProps) { const copyPath = async (filePath: string) => { try { - await navigator.clipboard.writeText(filePath); + await copyTextToClipboard(filePath); toast({ title: "Path copied", body: filePath }); } catch { toast({ title: "Copy failed", body: filePath, tone: "error" }); diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 8e340f416c..f352df1d90 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -15,7 +15,7 @@ Reference: `doc/plugins/PLUGIN_SPEC.md` | Import | Purpose | |--------|--------| | `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers | -| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, `useHostNavigation`, slot prop types | +| `@paperclipai/plugin-sdk/ui` | UI entry: hooks, host navigation, HTTP-safe clipboard copy, shared components, and slot prop types | | `@paperclipai/plugin-sdk/ui/hooks` | Hooks only | | `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces | | `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests | @@ -764,6 +764,16 @@ The host provides selected shared UI components through `@paperclipai/plugin-sdk Plugins can also use normal React components, their own CSS, or small design primitives inside the plugin package. +Use `copyTextToClipboard` for every plugin copy action. The host selects the +modern Clipboard API in secure contexts and a compatible fallback in plain-HTTP +deployments. + +```tsx +import { copyTextToClipboard } from "@paperclipai/plugin-sdk/ui"; + +await copyTextToClipboard("text to copy"); +``` + Use the shared components when the plugin needs to look and behave like a native Paperclip surface: diff --git a/packages/plugins/sdk/src/ui/clipboard.ts b/packages/plugins/sdk/src/ui/clipboard.ts new file mode 100644 index 0000000000..1f42069c97 --- /dev/null +++ b/packages/plugins/sdk/src/ui/clipboard.ts @@ -0,0 +1,12 @@ +import { getSdkUiRuntimeValue } from "./runtime.js"; + +/** + * Copy text through the host's HTTP-safe clipboard implementation. + * + * Plugin UI code must use this helper instead of calling the browser Clipboard + * API directly so copy actions also work in Paperclip's plain-HTTP deployments. + */ +export function copyTextToClipboard(text: string): Promise { + const copy = getSdkUiRuntimeValue<(value: string) => Promise>("copyTextToClipboard"); + return copy(text); +} diff --git a/packages/plugins/sdk/src/ui/index.ts b/packages/plugins/sdk/src/ui/index.ts index e2ce8f63ae..cffdb233dc 100644 --- a/packages/plugins/sdk/src/ui/index.ts +++ b/packages/plugins/sdk/src/ui/index.ts @@ -57,6 +57,8 @@ export { usePluginToast, } from "./hooks.js"; +export { copyTextToClipboard } from "./clipboard.js"; + export { MetricCard, StatusBadge, diff --git a/packages/plugins/sdk/tests/ui-clipboard.test.ts b/packages/plugins/sdk/tests/ui-clipboard.test.ts new file mode 100644 index 0000000000..ddfc59701c --- /dev/null +++ b/packages/plugins/sdk/tests/ui-clipboard.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { copyTextToClipboard } from "../src/ui/clipboard.js"; + +type GlobalWithPluginBridge = typeof globalThis & { + __paperclipPluginBridge__?: unknown; +}; + +afterEach(() => { + delete (globalThis as GlobalWithPluginBridge).__paperclipPluginBridge__; +}); + +describe("copyTextToClipboard", () => { + it("delegates clipboard writes to the host UI runtime", async () => { + const copy = vi.fn(async () => undefined); + (globalThis as GlobalWithPluginBridge).__paperclipPluginBridge__ = { + sdkUi: { copyTextToClipboard: copy }, + }; + + await copyTextToClipboard("src/index.ts"); + + expect(copy).toHaveBeenCalledWith("src/index.ts"); + }); +}); diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index 48e444ca92..2460e4e8eb 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -33,6 +33,7 @@ import { agentsApi } from "../api/agents"; import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; import { agentRouteRef } from "../lib/utils"; +import { copyTextToClipboard } from "../lib/clipboard"; import { useDialogActions } from "../context/DialogContext"; import { useToastActions } from "../context/ToastContext"; import { @@ -399,7 +400,9 @@ export function AgentActionButtons({ diff --git a/ui/src/pages/SkillStudio.tsx b/ui/src/pages/SkillStudio.tsx index 953d43a4ee..eb211b2894 100644 --- a/ui/src/pages/SkillStudio.tsx +++ b/ui/src/pages/SkillStudio.tsx @@ -2097,7 +2097,7 @@ function InputPane({ { const input = inputs.find((i) => i.id === id); - if (input) navigator.clipboard?.writeText(input.content).catch(() => {}); + if (input) void copyTextToClipboard(input.content).catch(() => {}); }} > Copy content diff --git a/ui/src/pages/apps/AppsConnect.tsx b/ui/src/pages/apps/AppsConnect.tsx index 0b1783d34f..a1fbd41ea3 100644 --- a/ui/src/pages/apps/AppsConnect.tsx +++ b/ui/src/pages/apps/AppsConnect.tsx @@ -39,6 +39,7 @@ import { Textarea } from "@/components/ui/textarea"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { AppLogo } from "./AppLogo"; import { parseGoogleSheetIds } from "./google-sheets"; import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs"; @@ -1092,7 +1093,7 @@ function KeyStep({ type="button" variant="outline" className="shrink-0" - onClick={() => void navigator.clipboard?.writeText(robotEmail)} + onClick={() => void copyTextToClipboard(robotEmail).catch(() => {})} > Copy diff --git a/ui/src/pages/apps/gateways/ConnectClientDialog.tsx b/ui/src/pages/apps/gateways/ConnectClientDialog.tsx index 9274b52e50..111ba52454 100644 --- a/ui/src/pages/apps/gateways/ConnectClientDialog.tsx +++ b/ui/src/pages/apps/gateways/ConnectClientDialog.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/dialog"; import { useToast } from "@/context/ToastContext"; import { cn } from "@/lib/utils"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { formatSnippetConfig, maskedTokenLabel, orderedSnippets } from "./gateway-helpers"; type PanelKey = string; // snippet client key, or "raw_url" @@ -52,10 +53,7 @@ export function ConnectClientDialog({ async function copyText(value: string, label: string) { try { - if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { - throw new Error("Clipboard access is unavailable."); - } - await navigator.clipboard.writeText(value); + await copyTextToClipboard(value); pushToast({ title: "Copied", body: label, tone: "success" }); } catch (error) { pushToast({ diff --git a/ui/src/pages/apps/gateways/panels/GatewayAdvancedPanel.tsx b/ui/src/pages/apps/gateways/panels/GatewayAdvancedPanel.tsx index f421af4967..edce968500 100644 --- a/ui/src/pages/apps/gateways/panels/GatewayAdvancedPanel.tsx +++ b/ui/src/pages/apps/gateways/panels/GatewayAdvancedPanel.tsx @@ -7,6 +7,7 @@ import { toolsApi } from "@/api/tools"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useToast } from "@/context/ToastContext"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { gatewaysQueryKey } from "../NewGatewayDialog"; /** @@ -64,7 +65,7 @@ export function GatewayAdvancedPanel({ async function copy(value: string, label: string) { try { - await navigator.clipboard.writeText(value); + await copyTextToClipboard(value); pushToast({ title: "Copied", body: label, tone: "success" }); } catch { pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" }); diff --git a/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx b/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx index cb940afe28..a1ef5cc37f 100644 --- a/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx +++ b/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { useToast } from "@/context/ToastContext"; import { cn } from "@/lib/utils"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { activeTokenCount, allowedToolsLabel, @@ -52,7 +53,7 @@ export function OverviewPanel({ async function copy(value: string, label: string) { try { - await navigator.clipboard.writeText(value); + await copyTextToClipboard(value); pushToast({ title: "Copied", body: label, tone: "success" }); } catch { pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" }); diff --git a/ui/src/pages/apps/gateways/panels/TokensPanel.tsx b/ui/src/pages/apps/gateways/panels/TokensPanel.tsx index 418fe6eff0..030bdbe504 100644 --- a/ui/src/pages/apps/gateways/panels/TokensPanel.tsx +++ b/ui/src/pages/apps/gateways/panels/TokensPanel.tsx @@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input"; import { useToast } from "@/context/ToastContext"; import { RelativeTime } from "@/pages/tools/shared"; import { cn } from "@/lib/utils"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { gatewaysQueryKey } from "../NewGatewayDialog"; import { maskedTokenLabel, TOKEN_STATUS_LABEL, tokenStatus, type TokenStatus } from "../gateway-helpers"; @@ -126,10 +127,7 @@ export function TokensPanel({ async function copyToken(value: string) { try { - if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { - throw new Error("Clipboard access is unavailable."); - } - await navigator.clipboard.writeText(value); + await copyTextToClipboard(value); pushToast({ title: "Copied", body: "Access token", tone: "success" }); } catch (error) { pushToast({ diff --git a/ui/src/pages/tools/GatewaysTab.tsx b/ui/src/pages/tools/GatewaysTab.tsx index 4c561db496..0dcab1e57d 100644 --- a/ui/src/pages/tools/GatewaysTab.tsx +++ b/ui/src/pages/tools/GatewaysTab.tsx @@ -14,6 +14,7 @@ import { toolsApi } from "@/api/tools"; import { Button } from "@/components/ui/button"; import { useToast } from "@/context/ToastContext"; import { queryKeys } from "@/lib/queryKeys"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { ErrorState, LoadingState, RelativeTime, ToolsPageHeader } from "./shared"; type CreateGatewayDraft = { @@ -215,10 +216,7 @@ export function GatewaysTab({ companyId }: { companyId: string }) { async function copyText(value: string, label: string) { try { - if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { - throw new Error("Clipboard access is unavailable."); - } - await navigator.clipboard.writeText(value); + await copyTextToClipboard(value); pushToast({ title: "Copied to clipboard", body: label, tone: "success" }); } catch (error) { pushToast({ title: "Copy failed", body: error instanceof Error ? error.message : "Clipboard access is unavailable.", tone: "error" }); diff --git a/ui/src/plugins/bridge-init.ts b/ui/src/plugins/bridge-init.ts index 4c8f876900..819287024d 100644 --- a/ui/src/plugins/bridge-init.ts +++ b/ui/src/plugins/bridge-init.ts @@ -58,6 +58,7 @@ import { trackRecentAssigneeUser, } from "@/lib/recent-assignees"; import { getRecentProjectIds, trackRecentProject } from "@/lib/recent-projects"; +import { copyTextToClipboard } from "@/lib/clipboard"; // --------------------------------------------------------------------------- // Global bridge registry @@ -682,6 +683,7 @@ export function initPluginBridge( useHostNavigation, usePluginStream, usePluginToast, + copyTextToClipboard, MarkdownBlock: ({ content, className, diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx index 82cbeb2c0a..d93455a883 100644 --- a/ui/src/plugins/slots.tsx +++ b/ui/src/plugins/slots.tsx @@ -336,7 +336,7 @@ function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" | throw new Error('Paperclip plugin UI runtime is not initialized for "' + name + '". Ensure the host loaded the plugin bridge before rendering this UI module.'); }; } - const { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast } = SDK; + const { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast, copyTextToClipboard } = SDK; const MetricCard = SDK.MetricCard ?? missing("MetricCard"); const StatusBadge = SDK.StatusBadge ?? missing("StatusBadge"); const DataTable = SDK.DataTable ?? missing("DataTable"); @@ -354,7 +354,7 @@ function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" | const AssigneePicker = SDK.AssigneePicker ?? missing("AssigneePicker"); const ProjectPicker = SDK.ProjectPicker ?? missing("ProjectPicker"); const ManagedRoutinesList = SDK.ManagedRoutinesList ?? missing("ManagedRoutinesList"); - export { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast, MetricCard, StatusBadge, DataTable, TimeseriesChart, MarkdownBlock, MarkdownEditor, KeyValueList, ActionBar, LogView, JsonTree, Spinner, ErrorBoundary, FileTree, IssuesList, AssigneePicker, ProjectPicker, ManagedRoutinesList }; + export { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast, copyTextToClipboard, MetricCard, StatusBadge, DataTable, TimeseriesChart, MarkdownBlock, MarkdownEditor, KeyValueList, ActionBar, LogView, JsonTree, Spinner, ErrorBoundary, FileTree, IssuesList, AssigneePicker, ProjectPicker, ManagedRoutinesList }; `; break; }