fix: guard browser APIs in insecure contexts (#2157)

This commit is contained in:
Carlos Florêncio
2026-08-02 14:15:29 +01:00
committed by GitHub
parent 190eeddbc4
commit 40376226cf
37 changed files with 623 additions and 223 deletions
@@ -7,6 +7,7 @@ import { Button } from "@kandev/ui/button";
import { Skeleton } from "@kandev/ui/skeleton";
import { previewAgentCommandAction, type CommandPreviewResponse } from "@/app/actions/agents";
import type { CLIFlag, ProfileEnvVar } from "@/lib/types/http";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
type CommandPreviewCardProps = {
agentName: string;
@@ -84,17 +85,7 @@ function CommandPreviewContent({ preview, cliPassthrough, envPrefix }: CommandPr
const handleCopy = async () => {
if (!displayCommand) return;
try {
await navigator.clipboard.writeText(displayCommand);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const textArea = document.createElement("textarea");
textArea.value = displayCommand;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
if (await copyToClipboard(displayCommand)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
+4 -14
View File
@@ -30,6 +30,7 @@ import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api";
import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery";
import { useAgentRuntimeUpdates } from "@/hooks/domains/settings/use-agent-runtime-updates";
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { AgentLogo } from "@/components/agent-logo";
import { AddTUIAgentDialog } from "@/components/settings/add-tui-agent-dialog";
import { HostShellDialog } from "@/components/settings/host-shell-dialog";
@@ -48,21 +49,10 @@ import type {
function useCopyCommand() {
const [copiedValue, setCopiedValue] = useState<string | null>(null);
const copy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch {
// fallback
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
if (await copyToClipboard(text)) {
setCopiedValue(text);
setTimeout(() => setCopiedValue(null), 2000);
}
setCopiedValue(text);
setTimeout(() => setCopiedValue(null), 2000);
}, []);
return { copiedValue, copy };
}
@@ -1,5 +1,5 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Workflow, WorkflowTemplate, Workspace } from "@/lib/types/http";
import { createDraftWorkflowSteps, useWorkflowCreation } from "./use-workflow-creation";
@@ -27,6 +27,11 @@ beforeEach(() => {
vi.spyOn(crypto, "randomUUID").mockReturnValue("00000000-0000-4000-8000-000000000001");
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("useWorkflowCreation", () => {
it("remaps template transition references to client step identities", () => {
const steps = createDraftWorkflowSteps("temp-workflow-1", [
@@ -67,6 +72,21 @@ describe("useWorkflowCreation", () => {
expect(result.current.initialStepsByWorkflowId.get(workflow.id)).toHaveLength(4);
});
it("creates a workflow when crypto.randomUUID is unavailable", () => {
vi.stubGlobal("crypto", {});
const { result, getWorkflows } = renderCreationHook();
act(() => {
result.current.setNewWorkflowName("HTTP Workflow");
result.current.setSelectedTemplateId(null);
});
act(() => result.current.handleCreateWorkflow());
const [workflow] = getWorkflows();
expect(workflow.id).toMatch(/^temp-workflow-[0-9a-f-]{36}$/);
expect(result.current.initialStepsByWorkflowId.get(workflow.id)).toHaveLength(4);
});
it("uses template fields without persisting from the dialog", () => {
const { result, getWorkflows } = renderCreationHook([template]);
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { generateUUID } from "@/lib/utils";
import {
workflowId as toWorkflowId,
workspaceId as toWorkspaceId,
@@ -25,7 +26,7 @@ type WorkflowCreationArgs = {
};
function newClientId(prefix: string): string {
return `${prefix}-${crypto.randomUUID()}`;
return `${prefix}-${generateUUID()}`;
}
function remapStepReferences<T>(value: T, mappings: Map<string, string>): T {
@@ -8,6 +8,7 @@ import { Label } from "@kandev/ui/label";
import { Textarea } from "@kandev/ui/textarea";
import { IconCopy, IconCheck, IconEye, IconEyeOff } from "@tabler/icons-react";
import { revealWebhookSecret } from "@/lib/api/domains/automation-api";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
type WebhookConfigProps = {
automationId: string | null;
@@ -62,9 +63,10 @@ export function WebhookConfig({ automationId, workspaceId }: WebhookConfigProps)
const [samplePayload, setSamplePayload] = useState("");
const copyValue = useCallback(async (value: string, kind: "url" | "secret") => {
await navigator.clipboard.writeText(value);
setCopied(kind);
setTimeout(() => setCopied(null), 2000);
if (await copyToClipboard(value)) {
setCopied(kind);
setTimeout(() => setCopied(null), 2000);
}
}, []);
const detectedKeys = useMemo(() => extractKeys(samplePayload), [samplePayload]);
@@ -19,6 +19,7 @@ import type { FileDiffMetadata } from "@pierre/diffs";
import { ExternalVcsFileLink } from "@/components/editors/external-vcs-file-link";
import type { ViewMode } from "@/hooks/use-global-view-mode";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
const iconBtn = "h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100";
@@ -201,7 +202,7 @@ export function useDiffHeaderToolbar(opts: DiffHeaderToolbarOptions) {
<DiffHeaderToolbarButtons
resolvedPath={resolvedPath}
isMarkdownFile={checkIsMarkdown(resolvedPath)}
onCopyDiff={() => navigator.clipboard.writeText(diff || "")}
onCopyDiff={() => void copyToClipboard(diff || "")}
wordWrap={wordWrap}
onToggleWordWrap={onToggleWordWrap}
viewMode={viewMode}
@@ -16,6 +16,7 @@ import { useOpenSessionInEditor } from "@/hooks/use-open-session-in-editor";
import { useOpenSessionFolder } from "@/hooks/use-open-session-folder";
import { useAppStore } from "@/components/state-provider";
import type { EditorOption } from "@/lib/types/http";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
type FileActionsDropdownProps = {
/** File path to open / copy */
@@ -77,8 +78,9 @@ function useFileActions({
}, [worktreePath, filePath]);
const handleCopyPath = useCallback(() => {
navigator.clipboard.writeText(absolutePath);
onCopied?.();
void copyToClipboard(absolutePath).then((success) => {
if (success) onCopied?.();
});
}, [absolutePath, onCopied]);
const handleOpenInEditor = useCallback(
@@ -1,4 +1,5 @@
import { cn } from "@/lib/utils";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
export type ContextMenuState = {
x: number;
@@ -48,7 +49,7 @@ export function DiffViewerContextMenu({
role="menuitem"
className={itemCls}
onClick={() => {
navigator.clipboard.writeText(contextMenu.lineContent);
void copyToClipboard(contextMenu.lineContent);
onClose();
}}
>
@@ -6,6 +6,7 @@ import { useTheme } from "@/components/theme/app-theme";
import { cn } from "@kandev/ui/lib/utils";
import type { FileDiffData, DiffComment, DiffCommentUpdate } from "@/lib/diff/types";
import { getMonacoLanguage } from "@/lib/editor/language-map";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { useCommandPanelOpen } from "@/lib/commands/command-registry";
import { useDiffEditorHeight } from "@/hooks/use-diff-editor-height";
import { useGlobalViewMode } from "@/hooks/use-global-view-mode";
@@ -174,7 +175,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
setWordWrap={state.setWordWrap}
globalViewMode={state.globalViewMode}
setGlobalViewMode={state.setGlobalViewMode}
onCopyDiff={() => navigator.clipboard.writeText(state.diff ?? "")}
onCopyDiff={() => void copyToClipboard(state.diff ?? "")}
onOpenFile={onOpenFile}
onRevert={onRevert}
sessionId={props.sessionId}
@@ -3,6 +3,7 @@ import type { editor as monacoEditor } from "monaco-editor";
import type { DiffOnMount } from "@monaco-editor/react";
import type { DiffComment, DiffCommentUpdate } from "@/lib/diff/types";
import { buildDiffComment, useCommentedLines, useCommentActions } from "@/lib/diff/comment-utils";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { useDiffComments } from "@/components/diff/use-diff-comments";
import { useGutterComments } from "@/hooks/use-gutter-comments";
import type { ContextMenuState } from "./diff-viewer-context-menu";
@@ -323,7 +324,7 @@ export function useDiffViewerComments(opts: UseDiffViewerCommentsOpts) {
}
}
}
navigator.clipboard.writeText(changedLines.join("\n"));
void copyToClipboard(changedLines.join("\n"));
setContextMenu(null);
}, []);
+4 -13
View File
@@ -16,6 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { AgentLogo } from "@/components/agent-logo";
import { ProfileFormFields, type ProfileFormData } from "@/components/settings/profile-form-fields";
import type { AvailableAgent, ToolStatus } from "@/lib/types/http";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
export type AgentSetting = {
profileId: string;
@@ -26,20 +27,10 @@ export type AgentSetting = {
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
if (await copyToClipboard(text)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [text]);
return (
@@ -33,6 +33,7 @@ import {
ExternalVcsFileMenuItem,
} from "@/components/editors/external-vcs-file-link";
import { useGlobalViewMode } from "@/hooks/use-global-view-mode";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { isMarkdownFile } from "@/lib/utils/file-types";
@@ -203,7 +204,7 @@ function MobileFileActionsMenu(props: FileDiffToolbarProps) {
repo,
} = props;
const handleCopyDiff = useCallback(() => {
void navigator.clipboard.writeText(diff || "");
void copyToClipboard(diff || "");
}, [diff]);
const [open, setOpen] = useState(false);
@@ -304,7 +305,7 @@ function DesktopFileDiffToolbar(props: FileDiffToolbarProps) {
} = props;
const [globalViewMode, setGlobalViewMode] = useGlobalViewMode();
const handleCopyDiff = useCallback(() => {
void navigator.clipboard.writeText(diff || "");
void copyToClipboard(diff || "");
}, [diff]);
const handleToggleViewMode = useCallback(
() => setGlobalViewMode(globalViewMode === "split" ? "unified" : "split"),
@@ -17,6 +17,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
import { ApiError } from "@/lib/api/client";
import { listTokens, mintToken, revokeToken, type ApiToken } from "@/lib/api/domains/auth-api";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
function useTokensList() {
const [tokens, setTokens] = useState<ApiToken[]>([]);
@@ -193,8 +194,9 @@ function MintTokenDialog({
rawToken={rawToken}
copied={copied}
onCopy={() => {
void navigator.clipboard.writeText(rawToken);
setCopied(true);
void copyToClipboard(rawToken).then((success) => {
if (success) setCopied(true);
});
}}
onDone={() => onOpenChange(false)}
/>
@@ -15,6 +15,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/
import { Separator } from "@kandev/ui/separator";
import { SettingsSection } from "@/components/settings/settings-section";
import { getBackendConfig } from "@/lib/config";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import {
buildAuggieCliCommand,
buildAuggieConfig,
@@ -35,16 +36,12 @@ export function ExternalMcpSettings() {
const [copied, setCopied] = useState<string | null>(null);
function handleCopy(text: string) {
if (typeof navigator === "undefined") return;
navigator.clipboard
.writeText(text)
.then(() => {
void copyToClipboard(text).then((success) => {
if (success) {
setCopied(text);
setTimeout(() => setCopied(null), 2000);
})
.catch(() => {
// Best-effort: clipboard may be unavailable in non-secure contexts.
});
}
});
}
return (
@@ -11,6 +11,7 @@ import { IconCheck, IconCopy, IconLoader2, IconX } from "@tabler/icons-react";
import { toast } from "sonner";
import { probeSSHAgents, probeSSHShells } from "@/lib/api/domains/ssh-api";
import type { SSHAgentReadinessRow } from "@/lib/types/http-ssh";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { SettingsCard } from "@/components/settings/settings-card";
export interface SSHAgentReadinessCardProps {
@@ -374,7 +375,9 @@ function InstallHint({ hint, available }: { hint?: string; available: boolean })
className="cursor-pointer text-muted-foreground hover:text-foreground"
aria-label="Copy install hint"
onClick={() => {
void navigator.clipboard.writeText(hint).then(() => toast.success("Install hint copied"));
void copyToClipboard(hint).then((success) => {
if (success) toast.success("Install hint copied");
});
}}
>
<IconCopy className="h-3 w-3" />
@@ -15,6 +15,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { ApiError } from "@/lib/api/client";
import { createInvite } from "@/lib/api/domains/auth-api";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
type Props = {
open: boolean;
@@ -103,8 +104,7 @@ function InviteForm({
function InviteLinkResult({ url, onDone }: { url: string; onDone: () => void }) {
const [copied, setCopied] = useState(false);
const onCopy = async () => {
await navigator.clipboard.writeText(url);
setCopied(true);
if (await copyToClipboard(url)) setCopied(true);
};
return (
<>
@@ -9,6 +9,7 @@ import { IconCopy, IconDownload, IconFileText, IconRefresh } from "@tabler/icons
import { useLogFiles } from "@/hooks/domains/system/use-log-files";
import { useLogTail } from "@/hooks/domains/system/use-log-tail";
import { buildLogDownloadUrl } from "@/lib/api/domains/system-api";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { formatBytes } from "@/lib/utils/format-bytes";
import { useActionFeedback, type ActionFeedbackState } from "@/hooks/use-action-feedback";
import { ActionButtonContent } from "./action-button-content";
@@ -177,10 +178,9 @@ export function LogViewer() {
const onCopy = () =>
void copyFeedback.run(async () => {
if (typeof navigator === "undefined" || !navigator.clipboard) {
throw new Error("Clipboard API not available");
if (!(await copyToClipboard(tail.join("\n") + "\n"))) {
throw new Error("Clipboard copy failed");
}
await navigator.clipboard.writeText(tail.join("\n") + "\n");
});
const current = files.find((f) => f.current);
@@ -1,5 +1,5 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createWorkflowAction,
createWorkflowStepAction,
@@ -38,6 +38,10 @@ beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
const workflow = {
id: "wf-1",
workspace_id: "ws-1",
@@ -143,6 +147,32 @@ describe("useWorkflowStepActions", () => {
});
expect(createWorkflowStepAction).not.toHaveBeenCalled();
});
it("adds a client-only step when crypto.randomUUID is unavailable", async () => {
vi.stubGlobal("crypto", {});
const setWorkflowSteps = vi.fn();
const { result } = renderHook(() =>
useWorkflowStepActions({
workflow,
workflowSteps: [step("step-1", "Todo", 0, true)],
setWorkflowSteps,
refreshWorkflowSteps: vi.fn(),
setStepToDelete: vi.fn(),
setStepTaskCount: vi.fn(),
setTargetStepForMigration: vi.fn(),
setStepDeleteOpen: vi.fn(),
toast: vi.fn(),
}),
);
await act(() => result.current.handleAddWorkflowStep());
const updater = setWorkflowSteps.mock.calls[0][0] as (steps: WorkflowStep[]) => WorkflowStep[];
expect(updater([])[0]).toMatchObject({
id: expect.stringMatching(/^temp-step-[0-9a-f-]{36}$/),
name: "New Step",
});
});
});
describe("useWorkflowStepActions destructive and ordering paths", () => {
@@ -2,6 +2,7 @@
import { useEffect, useRef } from "react";
import type { Workflow, WorkflowStep } from "@/lib/types/http";
import { generateUUID } from "@/lib/utils";
import { useToast } from "@/components/toast-provider";
import { useSerializedMutationQueue } from "./use-serialized-mutation-queue";
import type { WorkflowMutationGuardController } from "./workflow-mutation-guard";
@@ -74,7 +75,7 @@ const NEW_STEP_DEFAULTS = { name: "New Step", color: "bg-slate-500" } as const;
function createDraftStep(workflow: Workflow, position: number): WorkflowStep {
return {
id: `temp-step-${crypto.randomUUID()}`,
id: `temp-step-${generateUUID()}`,
workflow_id: workflow.id,
...NEW_STEP_DEFAULTS,
position,
@@ -5,6 +5,7 @@ import { IconTerminal2, IconCopy, IconCheck } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import type { RecoveryAuthMethod } from "@/components/task/chat/types";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
function buildFullCommand(termAuth: RecoveryAuthMethod["terminal_auth"]): string | null {
if (!termAuth) return null;
@@ -65,12 +66,9 @@ function AuthMethodRow({
const handleCopy = useCallback(async () => {
if (!fullCommand) return;
try {
await navigator.clipboard.writeText(fullCommand);
if (await copyToClipboard(fullCommand)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard API unavailable
}
}, [fullCommand]);
@@ -11,6 +11,7 @@ import {
} from "@tabler/icons-react";
import { GridSpinner } from "@/components/grid-spinner";
import { transformPathsInText } from "@/lib/utils";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import type { Message } from "@/lib/types/http";
import { DiffViewBlock } from "./diff-view-block";
import { ExpandableRow } from "./expandable-row";
@@ -181,9 +182,12 @@ export const ToolEditMessage = memo(function ToolEditMessage({
const handleCopyPath = (e: React.MouseEvent) => {
e.stopPropagation();
if (filePath) {
navigator.clipboard?.writeText(filePath);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
void copyToClipboard(filePath).then((success) => {
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
});
}
};
@@ -10,6 +10,7 @@ import {
type SSHLiveStatus,
type TaskEnvironment,
} from "@/lib/api/domains/task-environment-api";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { resolveExecutorEnvironmentStatus, type StatusTone } from "./executor-environment-status";
const TONE_CLASSES: Record<StatusTone, string> = {
@@ -129,9 +130,9 @@ function Field({
className="cursor-pointer text-muted-foreground hover:text-foreground"
aria-label={`Copy ${label}`}
onClick={() => {
void navigator.clipboard
.writeText(copyValue ?? value)
.then(() => toast.success(`${label} copied`));
void copyToClipboard(copyValue ?? value).then((success) => {
if (success) toast.success(`${label} copied`);
});
}}
>
<IconCopy className="h-3 w-3" />
@@ -5,6 +5,7 @@ import { IconCheck, IconCopy, IconTrash, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import type { Annotation } from "@/lib/preview-inspect-bridge";
import { formatAnnotations } from "@/lib/preview-inspect-bridge";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
interface AnnotationsPanelProps {
annotations: Annotation[];
@@ -30,12 +31,11 @@ export function AnnotationsPanel({ annotations, onRemove, onClear }: Annotations
if (annotations.length === 0) return null;
async function handleCopy() {
try {
await navigator.clipboard.writeText(formatAnnotations(annotations));
if (await copyToClipboard(formatAnnotations(annotations))) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error(`AnnotationsPanel: clipboard write failed: ${String(err)}`);
} else {
console.error("AnnotationsPanel: clipboard write failed");
}
}
@@ -19,6 +19,7 @@ import { Badge } from "@kandev/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@kandev/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { listPorts, listTunnels, type ListeningPort } from "@/lib/api/domains/port-api";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { useTunnelActions } from "./use-tunnel-actions";
import { getBackendConfig } from "@/lib/config";
import { toast } from "sonner";
@@ -49,10 +50,11 @@ function InfoTip({ text }: { text: string }) {
function UrlActions({ url }: { url: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
navigator.clipboard?.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
const handleCopy = useCallback(async () => {
if (await copyToClipboard(url)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
}, [url]);
return (
@@ -21,6 +21,7 @@ import {
type SnapshotPreview,
} from "@/lib/api/domains/share-api";
import { useShares } from "@/hooks/domains/session/use-shares";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { ShareList } from "./share-list";
import { ShareSnapshotPreview } from "./share-snapshot-preview";
@@ -188,9 +189,10 @@ function PublishedState({
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(share.url);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
if (await copyToClipboard(share.url)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
};
return (
@@ -35,6 +35,7 @@ import { StatusIcon } from "@/app/office/tasks/[id]/status-icon";
import { StageProgressBar } from "./stage-progress-bar";
import { SubtaskStepper } from "./subtask-stepper";
import { hasBlockerChain } from "./workflow-sort";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { NewTaskDialog } from "@/app/office/components/new-task-dialog";
import { ActiveSessionRefProvider } from "./components/active-session-ref-context";
import { TopbarWorkingIndicator } from "./components/topbar-working-indicator";
@@ -139,7 +140,7 @@ function TaskHeaderRow({ task, activeHold }: { task: Task; activeHold: TreeHold
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => navigator.clipboard.writeText(task.identifier)}
onClick={() => void copyToClipboard(task.identifier)}
>
<IconCopy className="h-4 w-4" />
</Button>
+2 -59
View File
@@ -1,69 +1,12 @@
import { useState, useCallback } from "react";
/**
* Fallback copy method for non-secure contexts (HTTP)
* where navigator.clipboard is not available.
*
* Appends the temp textarea inside the nearest Radix dialog container
* (if the active element lives inside one) to avoid the Radix FocusScope
* stealing focus back to the dialog before execCommand runs.
*/
function fallbackCopy(text: string): boolean {
const previousActive = document.activeElement as HTMLElement | null;
// Mount inside the dialog so Radix FocusScope doesn't steal focus away.
const container =
previousActive?.closest<HTMLElement>('[data-slot="dialog-content"], [role="dialog"]') ??
document.body;
const textArea = document.createElement("textarea");
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
textArea.style.opacity = "0";
container.appendChild(textArea);
textArea.focus();
textArea.select();
let success = false;
try {
success = document.execCommand("copy");
} catch {
success = false;
} finally {
try {
container.removeChild(textArea);
} catch {
// Node may already be gone (e.g. dialog closed mid-copy); ignore.
}
previousActive?.focus();
}
return success;
}
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
export function useCopyToClipboard(duration = 2000) {
const [copied, setCopied] = useState(false);
const copy = useCallback(
async (text: string) => {
let success = false;
// Try modern clipboard API first
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
success = true;
} catch {
// Fall through to fallback
}
}
// Fallback for non-secure contexts
if (!success) {
success = fallbackCopy(text);
}
const success = await copyToClipboard(text);
if (success) {
setCopied(true);
@@ -1,6 +1,8 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import type { UtilityGenerationResult } from "./use-utility-agent-generator";
import { usePromptResultDelivery } from "./use-prompt-result-delivery";
@@ -10,6 +12,12 @@ vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: mockToast }),
}));
vi.mock("@/lib/utils/copy-to-clipboard", () => ({
copyToClipboard: vi.fn(),
}));
const mockCopyToClipboard = vi.mocked(copyToClipboard);
const GENERATED_RESULT = {
content: "enhanced prompt output",
callId: "call-123",
@@ -20,10 +28,7 @@ const INSERT_FAILURE_MESSAGE = "Enhanced prompt was generated but could not be i
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
});
mockCopyToClipboard.mockResolvedValue(true);
});
it.each([
@@ -100,12 +105,7 @@ it("applyPending clears only after apply succeeds", () => {
expect(result.current.pendingResult).toBeNull();
});
it("copyPending writes the pending result and reports success", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
it("copyPending delegates to the clipboard utility and reports success", async () => {
const { result } = renderHook(() =>
usePromptResultDelivery({
scopeKey: "test",
@@ -123,7 +123,7 @@ it("copyPending writes the pending result and reports success", async () => {
await result.current.copyPending();
});
expect(writeText).toHaveBeenCalledWith(GENERATED_RESULT.content);
expect(mockCopyToClipboard).toHaveBeenCalledWith(GENERATED_RESULT.content);
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({
description: "Enhanced prompt copied to clipboard.",
@@ -133,14 +133,8 @@ it("copyPending writes the pending result and reports success", async () => {
expect(result.current.pendingResult).toEqual(GENERATED_RESULT);
});
it("copyPending reports clipboard failure without clearing the result", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("denied"));
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const appendChild = vi.spyOn(document.body, "appendChild");
const createElement = vi.spyOn(document, "createElement");
it("copyPending reports utility failure without clearing the result", async () => {
mockCopyToClipboard.mockResolvedValue(false);
const { result } = renderHook(() =>
usePromptResultDelivery({
scopeKey: "test",
@@ -158,7 +152,7 @@ it("copyPending reports clipboard failure without clearing the result", async ()
await result.current.copyPending();
});
expect(writeText).toHaveBeenCalledWith(GENERATED_RESULT.content);
expect(mockCopyToClipboard).toHaveBeenCalledWith(GENERATED_RESULT.content);
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({
description: "Enhanced prompt could not be copied.",
@@ -166,43 +160,6 @@ it("copyPending reports clipboard failure without clearing the result", async ()
}),
);
expect(result.current.pendingResult).toEqual(GENERATED_RESULT);
expect(appendChild).not.toHaveBeenCalled();
expect(createElement).not.toHaveBeenCalledWith("textarea");
});
it("copyPending reports failure without DOM fallback when clipboard is unavailable", async () => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: undefined,
});
const appendChild = vi.spyOn(document.body, "appendChild");
const createElement = vi.spyOn(document, "createElement");
const { result } = renderHook(() =>
usePromptResultDelivery({
scopeKey: "test",
getCurrent: () => "edited",
apply: vi.fn(() => true),
}),
);
act(() => {
result.current.deliver("original", GENERATED_RESULT, result.current.captureScope());
});
vi.clearAllMocks();
await act(async () => {
await result.current.copyPending();
});
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({
description: "Enhanced prompt could not be copied.",
variant: "error",
}),
);
expect(result.current.pendingResult).toEqual(GENERATED_RESULT);
expect(appendChild).not.toHaveBeenCalled();
expect(createElement).not.toHaveBeenCalledWith("textarea");
});
it("dismissPending clears the retained result", () => {
+2 -14
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useToast } from "@/components/toast-provider";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import type { UtilityGenerationResult } from "./use-utility-agent-generator";
@@ -30,19 +31,6 @@ type PendingResult = {
generation: number;
};
async function copyText(text: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
return false;
}
export function usePromptResultDelivery({
scopeKey,
getCurrent,
@@ -111,7 +99,7 @@ export function usePromptResultDelivery({
return;
}
const copied = await copyText(pendingResult.result.content);
const copied = await copyToClipboard(pendingResult.result.content);
toast({
description: copied ? COPY_SUCCESS_MESSAGE : COPY_FAILURE_MESSAGE,
variant: copied ? "success" : "error",
+16 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SavedLayout } from "@/lib/types/http";
import { panel, type LayoutState } from "@/lib/state/layout-manager";
import {
@@ -29,6 +29,10 @@ const CENTER_COLUMN_ID = "center";
const LEGACY_LAYOUT_VALUE = "old-format";
const SESSION_PANEL_ID = "session:session-one";
afterEach(() => {
vi.unstubAllGlobals();
});
function reusableLayout(panelIds: string[] = ["chat", "files", "changes"]): LayoutState {
return {
columns: [
@@ -296,6 +300,17 @@ describe("layout profile IDs", () => {
expect(first).toMatch(/^layout-[0-9a-f-]{36}$/);
expect(second).not.toBe(first);
});
it("uses the UUID fallback when crypto.randomUUID is unavailable", () => {
vi.stubGlobal("crypto", {});
const first = createLayoutProfileId();
const second = createLayoutProfileId();
expect(first).toMatch(/^layout-[0-9a-f-]{36}$/);
expect(second).toMatch(/^layout-[0-9a-f-]{36}$/);
expect(second).not.toBe(first);
});
});
describe("built-in layout overrides", () => {
+2 -1
View File
@@ -10,6 +10,7 @@ import {
type LayoutState,
} from "@/lib/state/layout-manager";
import type { SavedLayout } from "@/lib/types/http";
import { generateUUID } from "@/lib/utils";
export type BuiltInLayoutProfileId = Exclude<BuiltInPreset, "compact">;
@@ -96,7 +97,7 @@ const REUSABLE_PANEL_ID_SET = new Set<string>(REUSABLE_PANEL_IDS);
const BUILT_IN_OVERRIDE_PREFIX = "layout-override-";
export function createLayoutProfileId(): string {
return `layout-${globalThis.crypto.randomUUID()}`;
return `layout-${generateUUID()}`;
}
export function getBuiltInLayoutProfile(id: BuiltInLayoutProfileId): BuiltInLayoutProfile {
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { copyToClipboard } from "./copy-to-clipboard";
const SAMPLE_TEXT = "test clipboard content";
describe("copyToClipboard", () => {
beforeEach(() => {
document.body.innerHTML = "";
Object.defineProperty(document, "execCommand", {
configurable: true,
value: vi.fn().mockReturnValue(false),
});
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: undefined,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("prefers the modern Clipboard API", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const execCommand = vi.mocked(document.execCommand);
await expect(copyToClipboard(SAMPLE_TEXT)).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith(SAMPLE_TEXT);
expect(execCommand).not.toHaveBeenCalled();
});
it("uses the DOM fallback when the Clipboard API is unavailable", async () => {
const execCommand = vi.mocked(document.execCommand).mockReturnValue(true);
await expect(copyToClipboard(SAMPLE_TEXT)).resolves.toBe(true);
expect(execCommand).toHaveBeenCalledWith("copy");
expect(document.querySelector("textarea")).toBeNull();
});
it("uses the DOM fallback when the Clipboard API rejects and restores dialog focus", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("permission denied"));
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const execCommand = vi.mocked(document.execCommand).mockReturnValue(true);
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
const trigger = document.createElement("button");
dialog.appendChild(trigger);
document.body.appendChild(dialog);
trigger.focus();
await expect(copyToClipboard(SAMPLE_TEXT)).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith(SAMPLE_TEXT);
expect(execCommand).toHaveBeenCalledWith("copy");
expect(dialog.querySelector("textarea")).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it("returns false when both copy mechanisms fail", async () => {
const execCommand = vi.mocked(document.execCommand).mockReturnValue(false);
await expect(copyToClipboard(SAMPLE_TEXT)).resolves.toBe(false);
expect(execCommand).toHaveBeenCalledWith("copy");
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Copy text using the Clipboard API when available, with a DOM fallback for
* non-secure contexts and browsers that reject the modern API.
*
* The fallback keeps a temporary textarea inside the active dialog when there
* is one so Radix FocusScope does not steal focus before execCommand runs.
*/
function fallbackCopy(text: string): boolean {
if (typeof document === "undefined") return false;
const previousActive = document.activeElement as HTMLElement | null;
const container =
previousActive?.closest<HTMLElement>('[data-slot="dialog-content"], [role="dialog"]') ??
document.body;
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
textArea.style.opacity = "0";
container.appendChild(textArea);
textArea.focus();
textArea.select();
let success = false;
try {
success = document.execCommand("copy");
} catch {
success = false;
} finally {
try {
container.removeChild(textArea);
} catch {
// The dialog may have closed while the copy was in progress.
}
previousActive?.focus();
}
return success;
}
export async function copyToClipboard(text: string): Promise<boolean> {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall through to the DOM fallback.
}
}
return fallbackCopy(text);
}
@@ -0,0 +1,142 @@
---
spec: docs/specs/secure-context-browser-fallbacks/spec.md
created: 2026-08-02
status: implemented
---
# Implementation Plan: Secure-context browser fallbacks
## Overview
Route every client-only UUID through the existing guarded generator, then
extract the existing clipboard fallback into a shared utility and migrate
direct Clipboard API call sites to it. The audit confirms that file hashing,
voice input, notifications, and sound already check capability boundaries; the
focused tests will preserve those fallbacks while covering the newly repaired
workflow, layout, and copy paths.
## Root cause
`useWorkflowCreation`, workflow step creation, and layout profile creation call
`crypto.randomUUID()` directly. On a non-localhost HTTP origin the browser
exposes `crypto` but not `randomUUID`, so Add Workflow throws before its draft
state is updated. The shared `generateUUID()` helper already handles that
condition, but those call sites bypass it. Several copy actions similarly call
`navigator.clipboard.writeText()` directly even though the existing copy hook
has a DOM fallback for insecure contexts.
## Frontend — client-only UUIDs
- `apps/web/app/settings/workspace/use-workflow-creation.ts`: use
`generateUUID()` for temporary workflow IDs.
- `apps/web/components/settings/workflow-card-actions.ts`: use
`generateUUID()` for temporary step IDs.
- `apps/web/lib/layout/layout-profiles.ts`: use `generateUUID()` for layout
profile IDs.
- `apps/web/app/settings/workspace/use-workflow-creation.test.ts`,
`apps/web/components/settings/workflow-card-actions.test.ts`, and
`apps/web/lib/layout/layout-profiles.test.ts`: add insecure-context coverage
with `crypto` missing `randomUUID`.
- Keep `apps/web/lib/utils/file-diff.ts` unchanged unless its existing guarded
fallback needs a test adjustment; it already degrades to `djb2Hash` when
`crypto.subtle` is unavailable.
## Frontend — clipboard fallback
- Add `apps/web/lib/utils/copy-to-clipboard.ts` with the existing dialog-aware
DOM fallback and a promise-returning `copyToClipboard(text)` function.
- Refactor `apps/web/hooks/use-copy-to-clipboard.ts` to use the shared utility,
preserving its copied state and timing.
- Migrate direct Clipboard API calls in the settings, onboarding, task,
diff/editor, review, and share surfaces to the shared utility. Preserve each
caller's current success state, toast, and error behavior.
- The audited call sites are `apps/web/app/settings/agents/page.tsx`,
`apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx`,
`apps/web/components/settings/external-mcp-settings.tsx`,
`apps/web/components/automations/trigger-configs/webhook-config.tsx`,
`apps/web/components/task/executor-environment-info.tsx`,
`apps/web/components/task/port-forward-dialog.tsx`,
`apps/web/components/task/simple/OfficeSimplePane.tsx`,
`apps/web/components/task/inspector/annotations-panel.tsx`,
`apps/web/components/task/share/share-dialog.tsx`,
`apps/web/components/task/chat/messages/auth-methods-panel.tsx`,
`apps/web/components/task/chat/messages/tool-edit-message.tsx`,
`apps/web/components/editors/file-actions-dropdown.tsx`,
`apps/web/components/editors/monaco/use-diff-viewer-comments.ts`,
`apps/web/components/editors/monaco/diff-viewer-context-menu.tsx`,
`apps/web/components/editors/monaco/monaco-diff-viewer.tsx`,
`apps/web/components/diff/diff-header-toolbar.tsx`,
`apps/web/components/review/review-diff-toolbar.tsx`,
`apps/web/components/onboarding/step-agents.tsx`,
`apps/web/components/settings/account/api-tokens.tsx`,
`apps/web/components/settings/system/log-viewer.tsx`,
`apps/web/components/settings/system/invite-dialog.tsx`, and
`apps/web/components/settings/ssh-agent-readiness-card.tsx`.
- Add `apps/web/lib/utils/copy-to-clipboard.test.ts` covering modern success,
missing API, rejected API, and dialog focus containment. Update the hook test
only where its implementation seam changes.
## Existing capability audit
- `apps/web/lib/utils/file-diff.ts` already guards `crypto.subtle` and falls
back to `djb2Hash`.
- Voice input gates its UI on secure-context/capability checks and catches
capture failures; notification permission actions check `Notification`
before use; sound creation checks `AudioContext` before construction.
- No behavior change is planned for those already guarded paths; their
existing tests remain part of focused frontend verification.
## Tests
- **UUID regression:** no `randomUUID` must not prevent workflow creation,
step addition, or layout profile creation. Files listed above; Vitest hook and
pure-function tests.
- **Clipboard regression:** missing/rejected Clipboard API uses the DOM
fallback without an uncaught rejection. New utility test plus existing hook
tests.
- **Existing fallbacks:** run the file-diff, voice, notification, and sound
tests selected by the focused test command where applicable.
## E2E and mobile parity
This repair changes shared state/utility behavior, not layout, navigation,
touch targets, scrolling, or mobile composition. The existing workflow settings
desktop/mobile E2E coverage remains the rendered regression path; no new mobile
scenario is required for this data-normalization change. A browser-level
insecure-origin smoke run is optional if an isolated HTTP instance is already
available.
## Risks and out of scope
- The DOM copy fallback depends on `document.execCommand("copy")`, which is
deprecated but is the compatibility path already used by Kandev.
- Fallback UUIDs are not cryptographically secure and must stay limited to
client-only identifiers.
- Backend/API contracts, TLS setup, and security-token generation are out of
scope.
## Implementation waves
Wave 1 (sequential):
- [x] [Task 01: UUID fallback audit](task-01-uuid-fallback.md)
Wave 2 (sequential; depends on Task 01):
- [x] [Task 02: Clipboard fallback migration](task-02-clipboard-fallback.md)
## Verification Results
### Task 01 — UUID fallback audit
- Focused Vitest: 4 files, 78 tests passed.
- Web typecheck passed.
- Static search found only the guarded `generateUUID()` implementation.
### Task 02 — Clipboard fallback migration
- Focused utility/hook Vitest: 3 files, 21 tests passed.
- Broader task-defined Vitest: 360 files, 2,647 tests passed, 4 skipped.
- Web typecheck, targeted ESLint, Prettier, and `i18n:ratchet` passed.
- Static search found no direct application Clipboard API calls outside the
shared guarded utility.
@@ -0,0 +1,53 @@
---
id: "01-uuid-fallback"
title: "UUID fallback audit"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/secure-context-browser-fallbacks/spec.md"
---
# Task 01: UUID fallback audit
Route every browser-side client-only UUID through the guarded generator and
prove the reported insecure-origin workflow path.
## Acceptance
- Add Workflow, Add Step, and layout profile creation succeed when
`crypto.randomUUID` is absent and produce unique UUID-shaped client IDs.
- Existing secure-context UUID behavior remains unchanged.
- No unguarded `crypto.randomUUID()` calls remain in application source under
`apps/web`.
## Files likely touched
- `apps/web/app/settings/workspace/use-workflow-creation.ts`
- `apps/web/app/settings/workspace/use-workflow-creation.test.ts`
- `apps/web/components/settings/workflow-card-actions.ts`
- `apps/web/components/settings/workflow-card-actions.test.ts`
- `apps/web/lib/layout/layout-profiles.ts`
- `apps/web/lib/layout/layout-profiles.test.ts`
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- --run app/settings/workspace/use-workflow-creation.test.ts components/settings/workflow-card-actions.test.ts lib/layout/layout-profiles.test.ts lib/utils.test.ts
cd apps/web && pnpm run typecheck
rtk rg -n --glob '!**/*.test.*' --glob '!**/e2e/**' 'crypto\.randomUUID\s*\(' apps/web
```
## Risks
The existing fallback uses `Math.random` and is suitable only for temporary
client identities. Do not route security-sensitive identifiers through it.
## Results
- RED: the three new insecure-context tests failed with
`TypeError: crypto.randomUUID is not a function` at each original call site.
- GREEN: `cd apps && pnpm --filter @kandev/web test -- --run app/settings/workspace/use-workflow-creation.test.ts components/settings/workflow-card-actions.test.ts lib/layout/layout-profiles.test.ts`
passed (3 files, 49 tests).
- `cd apps/web && pnpm run typecheck` passed.
- Static audit leaves only the guarded call in `apps/web/lib/utils.ts`.
@@ -0,0 +1,62 @@
---
id: "02-clipboard-fallback"
title: "Clipboard fallback migration"
status: done
wave: 2
depends_on: ["01-uuid-fallback"]
plan: "plan.md"
spec: "../../specs/secure-context-browser-fallbacks/spec.md"
---
# Task 02: Clipboard fallback migration
Make every web copy action tolerate an unavailable or rejected Clipboard API
by sharing the existing dialog-aware DOM fallback.
## Acceptance
- Modern `navigator.clipboard.writeText` is preferred when available.
- Missing or rejected Clipboard API requests use the DOM fallback and do not
produce uncaught promise rejections or `TypeError`s.
- Existing copied indicators, toasts, and focus behavior remain intact across
migrated call sites.
- No direct `navigator.clipboard.writeText(...)` calls remain in application
source outside the shared utility and its tests.
## Files likely touched
- `apps/web/lib/utils/copy-to-clipboard.ts`
- `apps/web/lib/utils/copy-to-clipboard.test.ts`
- `apps/web/hooks/use-copy-to-clipboard.ts`
- `apps/web/hooks/use-copy-to-clipboard.test.ts`
- `apps/web/hooks/use-prompt-result-delivery.ts`
- `apps/web/hooks/use-prompt-result-delivery.test.ts`
- Direct-copy settings, onboarding, task, diff/editor, review, and share
components listed in the clipboard section of `plan.md`.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- --run lib/utils/copy-to-clipboard.test.ts hooks/use-copy-to-clipboard.test.ts components/settings components/task components/editors components/review components/diff app/settings
cd apps/web && pnpm run typecheck
rtk rg -n --glob '!**/*.test.*' --glob '!**/e2e/**' 'navigator\.clipboard(?:\?|\.)?\.writeText\s*\(' apps/web
```
## Risks
The fallback is best effort on browsers that block `execCommand`; callers must
retain their current failure/toast semantics and must not report success when
the utility returns false.
## Results
- RED: the prompt-result delivery regression initially reported an error and
skipped the DOM fallback when `navigator.clipboard` was unavailable.
- GREEN: `cd apps && pnpm --filter @kandev/web test -- --run lib/utils/copy-to-clipboard.test.ts hooks/use-copy-to-clipboard.test.ts hooks/use-prompt-result-delivery.test.ts`
passed (3 files, 21 tests).
- The task-defined broader command passed (360 files, 2,647 tests, 4 skipped):
`cd apps && pnpm --filter @kandev/web test -- --run lib/utils/copy-to-clipboard.test.ts hooks/use-copy-to-clipboard.test.ts hooks/use-prompt-result-delivery.test.ts components/settings components/task components/editors components/review components/diff app/settings`.
- `cd apps/web && pnpm run typecheck` passed.
- Targeted ESLint, Prettier, and `i18n:ratchet` checks passed.
- Static audit leaves direct Clipboard API access only in the shared utility and
tests/E2E helpers.
@@ -0,0 +1,68 @@
---
title: Repair secure-context browser fallbacks
status: shipped
created: 2026-08-02
owner: kandev
---
# Repair secure-context browser fallbacks
## Problem
Kandev advertises plain HTTP network URLs for remote browser access. Some Web
APIs used by the web app, including `crypto.randomUUID()` and
`navigator.clipboard`, are only exposed or usable in secure contexts. Direct
calls currently make otherwise valid workflow and copy actions throw when the
app is opened over a non-localhost HTTP origin.
## Desired behavior
- Client-only identifiers used by workflows, workflow steps, and layout
profiles are generated successfully whether or not `crypto.randomUUID()` is
available. The secure Web Crypto implementation remains preferred; the
fallback is only for non-security identifiers.
- Clipboard actions use the modern Clipboard API when it is available and
fall back to the existing DOM copy mechanism when the API is unavailable or
rejects the request. A missing clipboard capability never produces an
uncaught browser API `TypeError`.
- Existing capability-gated APIs remain graceful in insecure contexts:
`crypto.subtle` keeps its content-hash fallback, voice input reports that it
needs HTTPS or localhost, and notification/audio features continue to no-op
or report unsupported instead of throwing.
- These fallbacks preserve the current successful-origin behavior and do not
change backend, WebSocket, or persistence contracts.
## Regression scenarios
- **GIVEN** the page has no `crypto.randomUUID`, **WHEN** the user confirms Add
Workflow, **THEN** a client-only workflow draft and its steps are created
without an exception and receive unique UUID-shaped IDs.
- **GIVEN** the page has no `crypto.randomUUID`, **WHEN** the user adds a
workflow step or creates a layout profile, **THEN** the client-only item is
created without an exception and receives a unique ID.
- **GIVEN** `navigator.clipboard` is missing or `writeText` rejects, **WHEN** a
user activates any copy action, **THEN** the DOM fallback is attempted and no
uncaught promise or `TypeError` escapes the action.
- **GIVEN** `crypto.subtle` is missing, **WHEN** file content hashing is
requested, **THEN** the existing non-cryptographic change-detection hash is
returned.
- **GIVEN** the page is an insecure HTTP origin, **WHEN** voice, notification,
or notification-sound capability checks run, **THEN** the UI reports or
applies the existing unsupported/no-op behavior without throwing.
## Constraints
- `crypto.randomUUID()` remains the preferred source when present.
- Fallback UUIDs are for temporary/client identifiers only and MUST NOT be
used for secrets, authentication tokens, or security decisions.
- The modern Clipboard API remains preferred; fallback copy is best effort and
must preserve focus behavior for dialogs.
- Remote plain HTTP access remains supported; this repair does not require
adding TLS or changing server binding.
## Out of scope
- Adding an HTTPS server or changing the advertised network URLs.
- Replacing secure randomness for security-sensitive values.
- Redesigning voice, notification, or sound settings beyond preventing
capability-related exceptions.