Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions web/src/lib/clipboard-usage.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
26 changes: 17 additions & 9 deletions web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions web/src/pages/ProfilesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
}
};
Expand Down
6 changes: 3 additions & 3 deletions web/src/pages/SystemPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
}
},
Expand Down
13 changes: 6 additions & 7 deletions web/src/pages/WebhooksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<Button
Expand Down
Loading