diff --git a/web/src/lib/clipboard-usage.test.ts b/web/src/lib/clipboard-usage.test.ts new file mode 100644 index 0000000000000..84a7a024229a1 --- /dev/null +++ b/web/src/lib/clipboard-usage.test.ts @@ -0,0 +1,55 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +// Regression guard: every dashboard copy action must go through +// copyTextToClipboard (web/src/lib/clipboard.ts), which falls back to a +// selection-based copy when the async Clipboard API is unavailable — +// e.g. self-hosted dashboards served over plain HTTP on a LAN, where +// window.isSecureContext is false and navigator.clipboard is undefined. +// +// Clipboard READS (paste paths) have no equivalent legacy fallback and are +// exempt; they must feature-detect and fail soft instead. +// +// Ported from paperclipai/paperclip#10875. + +const SRC_ROOT = join(__dirname, ".."); + +// The helper itself is the single allowed writer. +const ALLOWLIST = new Set(["lib/clipboard.ts"]); + +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) { + collectSourceFiles(full, out); + } else if (/\.(ts|tsx)$/.test(entry) && !/\.(test|spec)\.(ts|tsx)$/.test(entry)) { + out.push(full); + } + } + return out; +} + +describe("clipboard write regression guard", () => { + it("web/src has no direct navigator.clipboard write calls outside lib/clipboard.ts", () => { + const offenders: string[] = []; + for (const file of collectSourceFiles(SRC_ROOT)) { + const rel = relative(SRC_ROOT, file).replace(/\\/g, "/"); + if (ALLOWLIST.has(rel)) continue; + const source = readFileSync(file, "utf8"); + // Match writeText / write on navigator.clipboard (incl. optional + // chaining); reads (read / readText) are intentionally not matched. + const pattern = /navigator\.clipboard\??\s*\.\s*write(Text)?\s*[(.]/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source)) !== null) { + const line = source.slice(0, match.index).split("\n").length; + offenders.push(`${rel}:${line}`); + } + } + expect( + offenders, + `Direct navigator.clipboard writes found — use copyTextToClipboard from "@/lib/clipboard" instead:\n${offenders.join("\n")}`, + ).toEqual([]); + }); +}); diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 6aeda855eda56..f100e3c0f55e2 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -36,6 +36,7 @@ import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { api } from "@/lib/api"; import { latchChatActivation } from "@/lib/chat-activation"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { normalizeSessionTitle } from "@/lib/chat-title"; import { createPtyCompositionForwarder } from "@/lib/pty-composition"; import { PtyResumeSanitizer } from "@/lib/pty-resume-sanitizer"; @@ -582,11 +583,14 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const binary = atob(payload); const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); const text = new TextDecoder("utf-8").decode(bytes); - navigator.clipboard.writeText(text).catch((err) => { - // Most common reason: the Clipboard API requires a user gesture. - // This can fail when the OSC 52 response arrives outside the - // original keydown event's activation. Log to aid debugging. - console.warn("[dashboard clipboard] OSC 52 write failed:", err.message); + // copyTextToClipboard falls back to a selection-based copy when the + // Clipboard API is unavailable (plain-HTTP deployments) or when the + // write is rejected — e.g. the OSC 52 response arriving outside the + // original keydown event's activation ("user gesture" requirement). + void copyTextToClipboard(text).then((copied) => { + if (!copied) { + console.warn("[dashboard clipboard] OSC 52 write failed"); + } }); } catch { console.warn("[dashboard clipboard] malformed OSC 52 payload"); @@ -691,11 +695,15 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { (copyModifier && ev.shiftKey && ev.key.toLowerCase() === "c")) && terminalSelection ) { - // Direct writeText inside the keydown handler preserves the user + // Direct copy inside the keydown handler preserves the user // gesture — async round-trips through OSC 52 can lose activation - // and fail with "Document is not focused". - navigator.clipboard.writeText(terminalSelection).catch((err) => { - console.warn("[dashboard clipboard] direct copy failed:", err.message); + // and fail with "Document is not focused". copyTextToClipboard + // additionally covers insecure (plain-HTTP) contexts where the + // Clipboard API is unavailable. + void copyTextToClipboard(terminalSelection).then((copied) => { + if (!copied) { + console.warn("[dashboard clipboard] direct copy failed"); + } }); // Clear xterm.js's highlight after copy (matches gnome-terminal). term.clearSelection(); diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx index faf990ddfbebf..8c50fed333800 100644 --- a/web/src/pages/ProfilesPage.tsx +++ b/web/src/pages/ProfilesPage.tsx @@ -26,6 +26,7 @@ import spinners from "unicode-animations"; import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api } from "@/lib/api"; import type { ActiveProfileInfo, ProfileInfo } from "@/lib/api"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete"; @@ -709,10 +710,9 @@ export default function ProfilesPage() { showToast(`${t.status.error}: ${e}`, "error"); return; } - try { - await navigator.clipboard.writeText(cmd); + if (await copyTextToClipboard(cmd)) { showToast(`${t.profiles.commandCopied}: ${cmd}`, "success"); - } catch { + } else { showToast(`${t.profiles.copyFailed}: ${cmd}`, "error"); } }; diff --git a/web/src/pages/SystemPage.tsx b/web/src/pages/SystemPage.tsx index bce8496654d87..ff52c16ca9afd 100644 --- a/web/src/pages/SystemPage.tsx +++ b/web/src/pages/SystemPage.tsx @@ -45,6 +45,7 @@ import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { HermesConsoleModal } from "@/components/HermesConsoleModal"; import { cn, themedBody } from "@/lib/utils"; import { api } from "@/lib/api"; +import { copyTextToClipboard } from "@/lib/clipboard"; import type { StatusResponse, MemoryStatus, @@ -474,14 +475,13 @@ export default function SystemPage() { const copyToClipboard = useCallback( async (text: string, label: string) => { - try { - await navigator.clipboard.writeText(text); + if (await copyTextToClipboard(text)) { setCopiedLabel(label); setTimeout( () => setCopiedLabel((cur) => (cur === label ? null : cur)), 1500, ); - } catch { + } else { showToast("Couldn't copy to clipboard", "error"); } }, diff --git a/web/src/pages/WebhooksPage.tsx b/web/src/pages/WebhooksPage.tsx index f1990e70b9dd6..e44198de69f8f 100644 --- a/web/src/pages/WebhooksPage.tsx +++ b/web/src/pages/WebhooksPage.tsx @@ -16,6 +16,7 @@ import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api } from "@/lib/api"; import type { WebhookRoute, WebhooksResponse } from "@/lib/api"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete"; @@ -35,13 +36,11 @@ interface CreatedWebhook { function CopyButton({ value }: { value: string }) { const [copied, setCopied] = useState(false); const handleCopy = useCallback(() => { - navigator.clipboard - .writeText(value) - .then(() => { - setCopied(true); - window.setTimeout(() => setCopied(false), 1500); - }) - .catch(() => {}); + void copyTextToClipboard(value).then((copied) => { + if (!copied) return; + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }); }, [value]); return (