diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx index 6c81522833..5a6516ac96 100644 --- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx +++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx @@ -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); } diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 236284dffe..9fc37ff654 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -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(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 }; } diff --git a/apps/web/app/settings/workspace/use-workflow-creation.test.ts b/apps/web/app/settings/workspace/use-workflow-creation.test.ts index 640866800a..a605f67213 100644 --- a/apps/web/app/settings/workspace/use-workflow-creation.test.ts +++ b/apps/web/app/settings/workspace/use-workflow-creation.test.ts @@ -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]); diff --git a/apps/web/app/settings/workspace/use-workflow-creation.ts b/apps/web/app/settings/workspace/use-workflow-creation.ts index 57fa60b04a..d97d7bd21a 100644 --- a/apps/web/app/settings/workspace/use-workflow-creation.ts +++ b/apps/web/app/settings/workspace/use-workflow-creation.ts @@ -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(value: T, mappings: Map): T { diff --git a/apps/web/components/automations/trigger-configs/webhook-config.tsx b/apps/web/components/automations/trigger-configs/webhook-config.tsx index ac29e974df..3cfb876afe 100644 --- a/apps/web/components/automations/trigger-configs/webhook-config.tsx +++ b/apps/web/components/automations/trigger-configs/webhook-config.tsx @@ -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]); diff --git a/apps/web/components/diff/diff-header-toolbar.tsx b/apps/web/components/diff/diff-header-toolbar.tsx index 776ef6b045..845d6b19d7 100644 --- a/apps/web/components/diff/diff-header-toolbar.tsx +++ b/apps/web/components/diff/diff-header-toolbar.tsx @@ -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) { navigator.clipboard.writeText(diff || "")} + onCopyDiff={() => void copyToClipboard(diff || "")} wordWrap={wordWrap} onToggleWordWrap={onToggleWordWrap} viewMode={viewMode} diff --git a/apps/web/components/editors/file-actions-dropdown.tsx b/apps/web/components/editors/file-actions-dropdown.tsx index fc496d9726..9f6cdf7184 100644 --- a/apps/web/components/editors/file-actions-dropdown.tsx +++ b/apps/web/components/editors/file-actions-dropdown.tsx @@ -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( diff --git a/apps/web/components/editors/monaco/diff-viewer-context-menu.tsx b/apps/web/components/editors/monaco/diff-viewer-context-menu.tsx index addfa12751..295fc9e4dd 100644 --- a/apps/web/components/editors/monaco/diff-viewer-context-menu.tsx +++ b/apps/web/components/editors/monaco/diff-viewer-context-menu.tsx @@ -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(); }} > diff --git a/apps/web/components/editors/monaco/monaco-diff-viewer.tsx b/apps/web/components/editors/monaco/monaco-diff-viewer.tsx index 693d68c7c1..27ce72569d 100644 --- a/apps/web/components/editors/monaco/monaco-diff-viewer.tsx +++ b/apps/web/components/editors/monaco/monaco-diff-viewer.tsx @@ -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} diff --git a/apps/web/components/editors/monaco/use-diff-viewer-comments.ts b/apps/web/components/editors/monaco/use-diff-viewer-comments.ts index 9c1bcababa..dd6a8a7c9e 100644 --- a/apps/web/components/editors/monaco/use-diff-viewer-comments.ts +++ b/apps/web/components/editors/monaco/use-diff-viewer-comments.ts @@ -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); }, []); diff --git a/apps/web/components/onboarding/step-agents.tsx b/apps/web/components/onboarding/step-agents.tsx index 08101e14e3..7f6f596d56 100644 --- a/apps/web/components/onboarding/step-agents.tsx +++ b/apps/web/components/onboarding/step-agents.tsx @@ -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 ( diff --git a/apps/web/components/review/review-diff-toolbar.tsx b/apps/web/components/review/review-diff-toolbar.tsx index 9f62349ec8..2456f844cb 100644 --- a/apps/web/components/review/review-diff-toolbar.tsx +++ b/apps/web/components/review/review-diff-toolbar.tsx @@ -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"), diff --git a/apps/web/components/settings/account/api-tokens.tsx b/apps/web/components/settings/account/api-tokens.tsx index 5e0b696fff..ce0d1cd68e 100644 --- a/apps/web/components/settings/account/api-tokens.tsx +++ b/apps/web/components/settings/account/api-tokens.tsx @@ -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([]); @@ -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)} /> diff --git a/apps/web/components/settings/external-mcp-settings.tsx b/apps/web/components/settings/external-mcp-settings.tsx index 20d30d6996..7766e3da22 100644 --- a/apps/web/components/settings/external-mcp-settings.tsx +++ b/apps/web/components/settings/external-mcp-settings.tsx @@ -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(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 ( diff --git a/apps/web/components/settings/ssh-agent-readiness-card.tsx b/apps/web/components/settings/ssh-agent-readiness-card.tsx index 64a79e3605..fea85acdfe 100644 --- a/apps/web/components/settings/ssh-agent-readiness-card.tsx +++ b/apps/web/components/settings/ssh-agent-readiness-card.tsx @@ -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"); + }); }} > diff --git a/apps/web/components/settings/system/invite-dialog.tsx b/apps/web/components/settings/system/invite-dialog.tsx index 959cd14354..d9a0112b96 100644 --- a/apps/web/components/settings/system/invite-dialog.tsx +++ b/apps/web/components/settings/system/invite-dialog.tsx @@ -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 ( <> diff --git a/apps/web/components/settings/system/log-viewer.tsx b/apps/web/components/settings/system/log-viewer.tsx index c0e4053552..e7a6306fc9 100644 --- a/apps/web/components/settings/system/log-viewer.tsx +++ b/apps/web/components/settings/system/log-viewer.tsx @@ -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); diff --git a/apps/web/components/settings/workflow-card-actions.test.ts b/apps/web/components/settings/workflow-card-actions.test.ts index eb1e3591b0..cef482f346 100644 --- a/apps/web/components/settings/workflow-card-actions.test.ts +++ b/apps/web/components/settings/workflow-card-actions.test.ts @@ -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", () => { diff --git a/apps/web/components/settings/workflow-card-actions.ts b/apps/web/components/settings/workflow-card-actions.ts index 5f39bba996..8836ee2c7b 100644 --- a/apps/web/components/settings/workflow-card-actions.ts +++ b/apps/web/components/settings/workflow-card-actions.ts @@ -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, diff --git a/apps/web/components/task/chat/messages/auth-methods-panel.tsx b/apps/web/components/task/chat/messages/auth-methods-panel.tsx index 970cc3964b..5289820462 100644 --- a/apps/web/components/task/chat/messages/auth-methods-panel.tsx +++ b/apps/web/components/task/chat/messages/auth-methods-panel.tsx @@ -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]); diff --git a/apps/web/components/task/chat/messages/tool-edit-message.tsx b/apps/web/components/task/chat/messages/tool-edit-message.tsx index b7400b1904..8c64fe471c 100644 --- a/apps/web/components/task/chat/messages/tool-edit-message.tsx +++ b/apps/web/components/task/chat/messages/tool-edit-message.tsx @@ -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); + } + }); } }; diff --git a/apps/web/components/task/executor-environment-info.tsx b/apps/web/components/task/executor-environment-info.tsx index 22a9708073..657b6fc47f 100644 --- a/apps/web/components/task/executor-environment-info.tsx +++ b/apps/web/components/task/executor-environment-info.tsx @@ -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 = { @@ -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`); + }); }} > diff --git a/apps/web/components/task/inspector/annotations-panel.tsx b/apps/web/components/task/inspector/annotations-panel.tsx index 00363def30..f1d8f68cb8 100644 --- a/apps/web/components/task/inspector/annotations-panel.tsx +++ b/apps/web/components/task/inspector/annotations-panel.tsx @@ -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"); } } diff --git a/apps/web/components/task/port-forward-dialog.tsx b/apps/web/components/task/port-forward-dialog.tsx index a57b90eb4c..5e18d81464 100644 --- a/apps/web/components/task/port-forward-dialog.tsx +++ b/apps/web/components/task/port-forward-dialog.tsx @@ -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 ( diff --git a/apps/web/components/task/share/share-dialog.tsx b/apps/web/components/task/share/share-dialog.tsx index 21493e50f9..96610bc714 100644 --- a/apps/web/components/task/share/share-dialog.tsx +++ b/apps/web/components/task/share/share-dialog.tsx @@ -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 ( diff --git a/apps/web/components/task/simple/OfficeSimplePane.tsx b/apps/web/components/task/simple/OfficeSimplePane.tsx index 999df81e2b..fd2aca97e5 100644 --- a/apps/web/components/task/simple/OfficeSimplePane.tsx +++ b/apps/web/components/task/simple/OfficeSimplePane.tsx @@ -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)} > diff --git a/apps/web/hooks/use-copy-to-clipboard.ts b/apps/web/hooks/use-copy-to-clipboard.ts index 3903d1a72b..165daa1ee3 100644 --- a/apps/web/hooks/use-copy-to-clipboard.ts +++ b/apps/web/hooks/use-copy-to-clipboard.ts @@ -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('[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); diff --git a/apps/web/hooks/use-prompt-result-delivery.test.ts b/apps/web/hooks/use-prompt-result-delivery.test.ts index 43d8f17812..fa56735642 100644 --- a/apps/web/hooks/use-prompt-result-delivery.test.ts +++ b/apps/web/hooks/use-prompt-result-delivery.test.ts @@ -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", () => { diff --git a/apps/web/hooks/use-prompt-result-delivery.ts b/apps/web/hooks/use-prompt-result-delivery.ts index 2fdebbf0a5..94dfcf79f1 100644 --- a/apps/web/hooks/use-prompt-result-delivery.ts +++ b/apps/web/hooks/use-prompt-result-delivery.ts @@ -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 { - 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", diff --git a/apps/web/lib/layout/layout-profiles.test.ts b/apps/web/lib/layout/layout-profiles.test.ts index be98f827a2..0102bb7a6d 100644 --- a/apps/web/lib/layout/layout-profiles.test.ts +++ b/apps/web/lib/layout/layout-profiles.test.ts @@ -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", () => { diff --git a/apps/web/lib/layout/layout-profiles.ts b/apps/web/lib/layout/layout-profiles.ts index b28d866b5d..77c90bfc23 100644 --- a/apps/web/lib/layout/layout-profiles.ts +++ b/apps/web/lib/layout/layout-profiles.ts @@ -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; @@ -96,7 +97,7 @@ const REUSABLE_PANEL_ID_SET = new Set(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 { diff --git a/apps/web/lib/utils/copy-to-clipboard.test.ts b/apps/web/lib/utils/copy-to-clipboard.test.ts new file mode 100644 index 0000000000..f09712d000 --- /dev/null +++ b/apps/web/lib/utils/copy-to-clipboard.test.ts @@ -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"); + }); +}); diff --git a/apps/web/lib/utils/copy-to-clipboard.ts b/apps/web/lib/utils/copy-to-clipboard.ts new file mode 100644 index 0000000000..85f47e99f8 --- /dev/null +++ b/apps/web/lib/utils/copy-to-clipboard.ts @@ -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('[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 { + 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); +} diff --git a/docs/plans/secure-context-browser-fallbacks/plan.md b/docs/plans/secure-context-browser-fallbacks/plan.md new file mode 100644 index 0000000000..7d74c327d2 --- /dev/null +++ b/docs/plans/secure-context-browser-fallbacks/plan.md @@ -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. diff --git a/docs/plans/secure-context-browser-fallbacks/task-01-uuid-fallback.md b/docs/plans/secure-context-browser-fallbacks/task-01-uuid-fallback.md new file mode 100644 index 0000000000..5862f1358f --- /dev/null +++ b/docs/plans/secure-context-browser-fallbacks/task-01-uuid-fallback.md @@ -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`. diff --git a/docs/plans/secure-context-browser-fallbacks/task-02-clipboard-fallback.md b/docs/plans/secure-context-browser-fallbacks/task-02-clipboard-fallback.md new file mode 100644 index 0000000000..26db9471d5 --- /dev/null +++ b/docs/plans/secure-context-browser-fallbacks/task-02-clipboard-fallback.md @@ -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. diff --git a/docs/specs/secure-context-browser-fallbacks/spec.md b/docs/specs/secure-context-browser-fallbacks/spec.md new file mode 100644 index 0000000000..fc2434db5a --- /dev/null +++ b/docs/specs/secure-context-browser-fallbacks/spec.md @@ -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.