From 74b4c7908a4661343833c111ff968fa4521378b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 13 Aug 2026 03:23:31 +0000 Subject: [PATCH] fix the crash over http: crypto.randomUUID is secure-context only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat took the whole page down at the end of every turn with `TypeError: crypto.randomUUID is not a function`. `crypto.randomUUID()` is SECURE-CONTEXT ONLY — over plain http on anything that is not localhost it is not defined at all. Officer is reached at http://officer-dev:9000, which is neither, so all eighteen call sites in the frontend were throwing. The stack shows why it was fatal rather than merely broken: it was called inside a `useState` initialiser, so the throw happened during render and unmounted the tree. The assistant message that has no id yet is created at the end of a turn, which is exactly when it fired. No TLS needed. `crypto.getRandomValues()` carries no such restriction — it is on `Crypto`, not `SubtleCrypto`, and works in an insecure context. helpers/random-id uses randomUUID when it exists and otherwise assembles a v4 from the same CSPRNG: same 122 bits, same version and variant bits. Verified both paths produce a UUID matching the v4 pattern, including with randomUUID deleted. `crypto.subtle` is not used anywhere in the frontend, so randomUUID was the whole of the problem. Audio recording is a different matter — getUserMedia genuinely requires a secure context and cannot be polyfilled. Nine files, eighteen call sites. The vendored hls.mjs is left alone. Two of my own mistakes on the way, both caught by parsing rather than by reading: the rewrite added an import of the helper TO the helper, and inserted another one inside a multi-line import block — the same trap as the officerdb move earlier tonight. Co-Authored-By: Claude Opus 5 (1M context) --- .../Screens/Dashboard/Jobs/JobDetail.tsx | 7 +-- src/apps/officer-web/state/usePageTitle.ts | 3 +- src/workspaces/helpers/passkeys.ts | 3 +- src/workspaces/helpers/random-id.ts | 48 +++++++++++++++++++ .../apps/Chat/components/MessageBubble.tsx | 3 +- .../components/TaskRunnerModal.tsx | 3 +- .../components/usePipelineRunner.ts | 9 ++-- .../src/apps/Soulseek/SearchView.tsx | 3 +- .../src/apps/Terminal/use-terminal-session.ts | 3 +- .../officerdev/src/hooks/useChat.ts | 11 +++-- 10 files changed, 75 insertions(+), 18 deletions(-) create mode 100644 src/workspaces/helpers/random-id.ts diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx index 399157b5..b6fc1ee6 100644 --- a/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx @@ -9,6 +9,7 @@ import { Card } from '@/components/Card'; import { Button } from '@/components/ui/button'; import { WorkspaceLayout } from 'officerdev'; import type { LayoutNode, PanelComponents } from 'officerdev'; +import { randomId } from 'helpers/random-id'; type Cost = { inputTokens: number; outputTokens: number; totalUSD: number }; @@ -571,7 +572,7 @@ export const PipelineJobDetail = () => { const key = outputKey(msg.stepIndex, msg.iterationLabel); const text = msg.text || streamBuffers.current.get(key) || ''; if (text) { - appendOutput(key, { id: crypto.randomUUID(), type: 'text', text }); + appendOutput(key, { id: randomId(), type: 'text', text }); } streamBuffers.current.delete(key); setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; }); @@ -583,7 +584,7 @@ export const PipelineJobDetail = () => { // Flush any streaming text before the tool call flushStreamBuffer(key); appendOutput(key, { - id: crypto.randomUUID(), + id: randomId(), type: 'tool', toolCallId: msg.toolCallId, toolName: msg.toolName, @@ -630,7 +631,7 @@ export const PipelineJobDetail = () => { const flushStreamBuffer = useCallback((key: string) => { const text = streamBuffers.current.get(key); if (text) { - appendOutput(key, { id: crypto.randomUUID(), type: 'text', text }); + appendOutput(key, { id: randomId(), type: 'text', text }); streamBuffers.current.delete(key); setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; }); } diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 4faac0f3..3660b0e0 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -3,6 +3,7 @@ import { useLocation } from 'react-router'; import type { PageTitleOverride } from 'officerdev'; import { usePageTitleOverride } from 'officerdev'; import { useSessionState, writeSessionValue } from 'hooks/useSessionState'; +import { randomId } from 'helpers/random-id'; type TitleRule = { match: (p: string) => boolean; title: string }; @@ -138,7 +139,7 @@ function claimTabIdentity(): void { /** `randomUUID` needs a secure context; the id only has to be unique among open tabs. */ function newTabId(): string { - return crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`; + return randomId(); } // Once per document, before React reads the stored name. diff --git a/src/workspaces/helpers/passkeys.ts b/src/workspaces/helpers/passkeys.ts index 14f3fa6c..e16882f4 100644 --- a/src/workspaces/helpers/passkeys.ts +++ b/src/workspaces/helpers/passkeys.ts @@ -1,3 +1,4 @@ +import { randomId } from 'helpers/random-id'; // Frontend type PasskeyUser = { @@ -49,7 +50,7 @@ export function getSigninPayload(challenge: number[], credentialIds: string[]) { // Backend export function createChallenge() { - const challenge = crypto.randomUUID(); + const challenge = randomId(); const hex = challenge.replace(/-/g, ''); const array: number[] = new Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { diff --git a/src/workspaces/helpers/random-id.ts b/src/workspaces/helpers/random-id.ts new file mode 100644 index 00000000..8d4f06b7 --- /dev/null +++ b/src/workspaces/helpers/random-id.ts @@ -0,0 +1,48 @@ +/** + * A UUID v4, in a browser that may not be in a secure context. + * + * ── Why this exists ── + * + * `crypto.randomUUID()` is SECURE-CONTEXT ONLY. Over plain http on anything that + * is not localhost it is not defined at all, and calling it throws + * `TypeError: crypto.randomUUID is not a function`. + * + * Officer is reached over the tailnet — `http://officer-dev:9000` — which is + * neither localhost nor https, so every one of these threw. Worst inside a + * `useState` initialiser, where the throw happens during render and takes the + * whole tree down: chat crashed at the end of every turn, on the assistant + * message that did not have an id yet. + * + * `crypto.getRandomValues()` carries NO such restriction — it is on `Crypto`, + * not on `SubtleCrypto`, and works in an insecure context. So the randomness + * below is exactly what `randomUUID` would have given; only the convenience + * wrapper was missing. + * + * ── This is not a weakening ── + * + * Same CSPRNG, same 122 bits of entropy, same version and variant bits. When + * `randomUUID` exists it is used unchanged; otherwise the value is assembled by + * hand from the source `randomUUID` itself draws on. + * + * The final fallback is `Math.random`, and it is there for completeness rather + * than for use: a browser with no `crypto` object at all cannot run this app. + * Never reached in practice, and marked so nobody mistakes it for a supported + * path or copies it somewhere it would matter. + */ +export function randomId(): string { + const c = globalThis.crypto; + + if (typeof c?.randomUUID === 'function') return c.randomUUID(); + + if (typeof c?.getRandomValues === 'function') { + const bytes = c.getRandomValues(new Uint8Array(16)); + // Version 4, and the RFC 4122 variant. Exactly what randomUUID sets. + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + + // Unreachable in any browser that can run this app. Not a supported path. + return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`; +} diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx index e3e6ed1e..7792b624 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx @@ -27,6 +27,7 @@ import { getRawUrl } from '../../FileViewer/file-types'; import { useFilesAPI } from '../../../hooks/useFilesAPI'; import { CopyButton } from './CopyButton'; import { CodeBlock } from './CodeBlock'; +import { randomId } from 'helpers/random-id'; /** Shared by the settled bubble and the streaming one, so a block gains nothing when the turn ends. */ const MD_COMPONENTS = { pre: CodeBlock }; @@ -202,7 +203,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, defaultOpen }: Messa
- +
diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx index 1a24aee3..505b1e17 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx @@ -20,6 +20,7 @@ import { type FolderProbe, type FolderTrackGroup, } from '../../../../hooks/useFilesAPI'; +import { randomId } from 'helpers/random-id'; const playDing = () => { const ctx = new AudioContext(); @@ -129,7 +130,7 @@ const AgenticTaskRunner = ({ const text = lastStreamRef.current; const isDuplicate = accRef.current.some((a) => a.role === 'assistant' && 'text' in a && a.text === text); if (!isDuplicate) { - accRef.current.push({ role: 'assistant', id: crypto.randomUUID(), text }); + accRef.current.push({ role: 'assistant', id: randomId(), text }); bump((n) => n + 1); } lastStreamRef.current = ''; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts index cbf37899..63e4244d 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/usePipelineRunner.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import type { ChatMessage } from '../../../Chat'; +import { randomId } from 'helpers/random-id'; type Phase = 'ready' | 'running' | 'done'; @@ -76,7 +77,7 @@ export function usePipelineRunner() { const flushStream = useCallback(() => { const text = streamBufferRef.current; if (text) { - setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]); + setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]); streamBufferRef.current = ''; setStreamingText(''); } @@ -208,7 +209,7 @@ export function usePipelineRunner() { if (inParallelRef.current && msg.iterationLabel) break; const text = msg.text || streamBufferRef.current; if (text) { - setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]); + setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]); } streamBufferRef.current = ''; setStreamingText(''); @@ -223,7 +224,7 @@ export function usePipelineRunner() { ...prev, { role: 'tool' as const, - id: crypto.randomUUID(), + id: randomId(), toolCallId: msg.toolCallId, toolName: msg.toolName, toolInput: msg.toolInput, @@ -253,7 +254,7 @@ export function usePipelineRunner() { case 'error': flushStream(); - setMessages((prev) => [...prev, { role: 'error' as const, id: crypto.randomUUID(), text: msg.message }]); + setMessages((prev) => [...prev, { role: 'error' as const, id: randomId(), text: msg.message }]); setHasError(true); setPhase('done'); stopTimer(); diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx index 4cb50438..b715ae31 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SearchView.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner'; import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight, Trash2 } from 'lucide-react'; import { SearchResults, dropCachedResults } from './SearchResults'; import { formatWhen, SEARCH_PARAM, type SlskdSearchSummary } from './shared'; +import { randomId } from 'helpers/random-id'; // The 'search' section — a search input plus the history of past searches (GET /searches). Which search // is open lives in `?search=`, so rows are real links and the back button returns to the history. @@ -95,7 +96,7 @@ export const SearchView = () => { const text = query.trim(); if (!text || submitting) return; setSubmitting(true); - const sentId = crypto.randomUUID(); + const sentId = randomId(); try { const created = await client.post>('/slskd/api/v0/searches', { id: sentId, diff --git a/src/workspaces/officerdev/src/apps/Terminal/use-terminal-session.ts b/src/workspaces/officerdev/src/apps/Terminal/use-terminal-session.ts index 82c65a19..f1306e1b 100644 --- a/src/workspaces/officerdev/src/apps/Terminal/use-terminal-session.ts +++ b/src/workspaces/officerdev/src/apps/Terminal/use-terminal-session.ts @@ -4,11 +4,12 @@ import { useClient } from 'hooks/useClient'; import { useDashboardState } from 'state/useDashboardState'; import { usePanelClose } from '../../components/Workspace'; +import { randomId } from 'helpers/random-id'; const EMPTY_TERMINALS: Record = {}; const newSessionId = (): string => - crypto.randomUUID?.() ?? Math.random().toString(36).slice(2) + Date.now().toString(36); + randomId() ?? Math.random().toString(36).slice(2) + Date.now().toString(36); /** * The shell a terminal panel is attached to: minted on first mount, remembered until the panel is closed. diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 57c98b2b..cb93c1a6 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -4,6 +4,7 @@ import { useClient } from 'hooks/useClient'; import { useSettings } from 'state/useSettings'; import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types'; import { spliceRunningTasks } from '../apps/Chat/running-tasks'; +import { randomId } from 'helpers/random-id'; const SAVE_DEBOUNCE_MS = 1000; const OLDER_PAGE_SIZE = 20; // messages fetched per scroll-up (matches the initial tail window) @@ -182,7 +183,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, const text = streamingRef.current; streamingRef.current = ''; setStreamingText(''); - setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]); + setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]); } /** @@ -226,7 +227,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, if (call.toolCallId) byToolCallId.set(call.toolCallId, call); converted = call; } else { - converted = { role: 'assistant', id: m.id ?? crypto.randomUUID(), text: m.text || '' }; + converted = { role: 'assistant', id: m.id ?? randomId(), text: m.text || '' }; } // A subagent's output belongs under the Task row that spawned it. If that row is missing (pruned @@ -294,15 +295,15 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, (prev) => withChildren(prev, parent, (kids) => [ ...kids, - { role: 'assistant', id: crypto.randomUUID(), text: msg.text }, - ]) ?? [...prev, { role: 'assistant', id: crypto.randomUUID(), text: msg.text }], + { role: 'assistant', id: randomId(), text: msg.text }, + ]) ?? [...prev, { role: 'assistant', id: randomId(), text: msg.text }], ); break; } if (streamingRef.current) { commitStreaming(); } else { - setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text: msg.text }]); + setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text: msg.text }]); } break; }