Port from paperclipai/paperclip#10875: route all dashboard copy actions through the HTTP-safe clipboard helper

Self-hosted dashboards served over plain HTTP on a LAN have no
navigator.clipboard (insecure context), so every direct writeText call
silently failed. web/src/lib/clipboard.ts already ships the HTTP-safe
copyTextToClipboard fallback but only OAuthLoginModal used it; ChatPage
(OSC 52 + Ctrl/Cmd+Shift+C), ProfilesPage, SystemPage, and WebhooksPage
all bypassed it. Route them through the helper and add a source-level
regression test that rejects any new direct clipboard write outside
lib/clipboard.ts (clipboard reads are exempt: no legacy fallback exists).

Sabotage-verified: the guard test fails when a direct write is introduced.
This commit is contained in:
Teknium
2026-08-06 23:08:49 -07:00
parent 66312aec48
commit 3ef63373f8
5 changed files with 84 additions and 22 deletions

View File

@@ -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([]);
});
});

View File

@@ -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";
@@ -595,11 +596,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");
@@ -704,11 +708,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();

View File

@@ -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");
}
};

View File

@@ -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");
}
},

View File

@@ -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 (
<Button