fix the crash over http: crypto.randomUUID is secure-context only

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 03:23:31 +00:00
co-authored by Claude Opus 5
parent f1cfc0042f
commit 74b4c7908a
10 changed files with 75 additions and 18 deletions
@@ -9,6 +9,7 @@ import { Card } from '@/components/Card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { WorkspaceLayout } from 'officerdev'; import { WorkspaceLayout } from 'officerdev';
import type { LayoutNode, PanelComponents } from 'officerdev'; import type { LayoutNode, PanelComponents } from 'officerdev';
import { randomId } from 'helpers/random-id';
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number }; type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
@@ -571,7 +572,7 @@ export const PipelineJobDetail = () => {
const key = outputKey(msg.stepIndex, msg.iterationLabel); const key = outputKey(msg.stepIndex, msg.iterationLabel);
const text = msg.text || streamBuffers.current.get(key) || ''; const text = msg.text || streamBuffers.current.get(key) || '';
if (text) { if (text) {
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text }); appendOutput(key, { id: randomId(), type: 'text', text });
} }
streamBuffers.current.delete(key); streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; }); 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 // Flush any streaming text before the tool call
flushStreamBuffer(key); flushStreamBuffer(key);
appendOutput(key, { appendOutput(key, {
id: crypto.randomUUID(), id: randomId(),
type: 'tool', type: 'tool',
toolCallId: msg.toolCallId, toolCallId: msg.toolCallId,
toolName: msg.toolName, toolName: msg.toolName,
@@ -630,7 +631,7 @@ export const PipelineJobDetail = () => {
const flushStreamBuffer = useCallback((key: string) => { const flushStreamBuffer = useCallback((key: string) => {
const text = streamBuffers.current.get(key); const text = streamBuffers.current.get(key);
if (text) { if (text) {
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text }); appendOutput(key, { id: randomId(), type: 'text', text });
streamBuffers.current.delete(key); streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; }); setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
} }
+2 -1
View File
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router';
import type { PageTitleOverride } from 'officerdev'; import type { PageTitleOverride } from 'officerdev';
import { usePageTitleOverride } from 'officerdev'; import { usePageTitleOverride } from 'officerdev';
import { useSessionState, writeSessionValue } from 'hooks/useSessionState'; import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
import { randomId } from 'helpers/random-id';
type TitleRule = { match: (p: string) => boolean; title: string }; 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. */ /** `randomUUID` needs a secure context; the id only has to be unique among open tabs. */
function newTabId(): string { 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. // Once per document, before React reads the stored name.
+2 -1
View File
@@ -1,3 +1,4 @@
import { randomId } from 'helpers/random-id';
// Frontend // Frontend
type PasskeyUser = { type PasskeyUser = {
@@ -49,7 +50,7 @@ export function getSigninPayload(challenge: number[], credentialIds: string[]) {
// Backend // Backend
export function createChallenge() { export function createChallenge() {
const challenge = crypto.randomUUID(); const challenge = randomId();
const hex = challenge.replace(/-/g, ''); const hex = challenge.replace(/-/g, '');
const array: number[] = new Array(hex.length / 2); const array: number[] = new Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) { for (let i = 0; i < hex.length; i += 2) {
+48
View File
@@ -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)}`;
}
@@ -27,6 +27,7 @@ import { getRawUrl } from '../../FileViewer/file-types';
import { useFilesAPI } from '../../../hooks/useFilesAPI'; import { useFilesAPI } from '../../../hooks/useFilesAPI';
import { CopyButton } from './CopyButton'; import { CopyButton } from './CopyButton';
import { CodeBlock } from './CodeBlock'; 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. */ /** Shared by the settled bubble and the streaming one, so a block gains nothing when the turn ends. */
const MD_COMPONENTS = { pre: CodeBlock }; const MD_COMPONENTS = { pre: CodeBlock };
@@ -202,7 +203,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, defaultOpen }: Messa
</div> </div>
<div className="flex justify-end -mb-1 -mr-1 gap-0.5"> <div className="flex justify-end -mb-1 -mr-1 gap-0.5">
<CopyButton text={assistantText} /> <CopyButton text={assistantText} />
<ReadAloudButton id={message.id ?? crypto.randomUUID()} text={assistantText} /> <ReadAloudButton id={message.id ?? randomId()} text={assistantText} />
</div> </div>
</div> </div>
</div> </div>
@@ -20,6 +20,7 @@ import {
type FolderProbe, type FolderProbe,
type FolderTrackGroup, type FolderTrackGroup,
} from '../../../../hooks/useFilesAPI'; } from '../../../../hooks/useFilesAPI';
import { randomId } from 'helpers/random-id';
const playDing = () => { const playDing = () => {
const ctx = new AudioContext(); const ctx = new AudioContext();
@@ -129,7 +130,7 @@ const AgenticTaskRunner = ({
const text = lastStreamRef.current; const text = lastStreamRef.current;
const isDuplicate = accRef.current.some((a) => a.role === 'assistant' && 'text' in a && a.text === text); const isDuplicate = accRef.current.some((a) => a.role === 'assistant' && 'text' in a && a.text === text);
if (!isDuplicate) { if (!isDuplicate) {
accRef.current.push({ role: 'assistant', id: crypto.randomUUID(), text }); accRef.current.push({ role: 'assistant', id: randomId(), text });
bump((n) => n + 1); bump((n) => n + 1);
} }
lastStreamRef.current = ''; lastStreamRef.current = '';
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import type { ChatMessage } from '../../../Chat'; import type { ChatMessage } from '../../../Chat';
import { randomId } from 'helpers/random-id';
type Phase = 'ready' | 'running' | 'done'; type Phase = 'ready' | 'running' | 'done';
@@ -76,7 +77,7 @@ export function usePipelineRunner() {
const flushStream = useCallback(() => { const flushStream = useCallback(() => {
const text = streamBufferRef.current; const text = streamBufferRef.current;
if (text) { if (text) {
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]); setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]);
streamBufferRef.current = ''; streamBufferRef.current = '';
setStreamingText(''); setStreamingText('');
} }
@@ -208,7 +209,7 @@ export function usePipelineRunner() {
if (inParallelRef.current && msg.iterationLabel) break; if (inParallelRef.current && msg.iterationLabel) break;
const text = msg.text || streamBufferRef.current; const text = msg.text || streamBufferRef.current;
if (text) { if (text) {
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]); setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]);
} }
streamBufferRef.current = ''; streamBufferRef.current = '';
setStreamingText(''); setStreamingText('');
@@ -223,7 +224,7 @@ export function usePipelineRunner() {
...prev, ...prev,
{ {
role: 'tool' as const, role: 'tool' as const,
id: crypto.randomUUID(), id: randomId(),
toolCallId: msg.toolCallId, toolCallId: msg.toolCallId,
toolName: msg.toolName, toolName: msg.toolName,
toolInput: msg.toolInput, toolInput: msg.toolInput,
@@ -253,7 +254,7 @@ export function usePipelineRunner() {
case 'error': case 'error':
flushStream(); 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); setHasError(true);
setPhase('done'); setPhase('done');
stopTimer(); stopTimer();
@@ -5,6 +5,7 @@ import { toast } from 'sonner';
import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight, Trash2 } from 'lucide-react'; import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight, Trash2 } from 'lucide-react';
import { SearchResults, dropCachedResults } from './SearchResults'; import { SearchResults, dropCachedResults } from './SearchResults';
import { formatWhen, SEARCH_PARAM, type SlskdSearchSummary } from './shared'; 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 // The 'search' section — a search input plus the history of past searches (GET /searches). Which search
// is open lives in `?search=<id>`, so rows are real links and the back button returns to the history. // is open lives in `?search=<id>`, so rows are real links and the back button returns to the history.
@@ -95,7 +96,7 @@ export const SearchView = () => {
const text = query.trim(); const text = query.trim();
if (!text || submitting) return; if (!text || submitting) return;
setSubmitting(true); setSubmitting(true);
const sentId = crypto.randomUUID(); const sentId = randomId();
try { try {
const created = await client.post<Partial<SlskdSearchSummary>>('/slskd/api/v0/searches', { const created = await client.post<Partial<SlskdSearchSummary>>('/slskd/api/v0/searches', {
id: sentId, id: sentId,
@@ -4,11 +4,12 @@ import { useClient } from 'hooks/useClient';
import { useDashboardState } from 'state/useDashboardState'; import { useDashboardState } from 'state/useDashboardState';
import { usePanelClose } from '../../components/Workspace'; import { usePanelClose } from '../../components/Workspace';
import { randomId } from 'helpers/random-id';
const EMPTY_TERMINALS: Record<string, string> = {}; const EMPTY_TERMINALS: Record<string, string> = {};
const newSessionId = (): string => 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. * The shell a terminal panel is attached to: minted on first mount, remembered until the panel is closed.
@@ -4,6 +4,7 @@ import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types'; import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types';
import { spliceRunningTasks } from '../apps/Chat/running-tasks'; import { spliceRunningTasks } from '../apps/Chat/running-tasks';
import { randomId } from 'helpers/random-id';
const SAVE_DEBOUNCE_MS = 1000; const SAVE_DEBOUNCE_MS = 1000;
const OLDER_PAGE_SIZE = 20; // messages fetched per scroll-up (matches the initial tail window) 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; const text = streamingRef.current;
streamingRef.current = ''; streamingRef.current = '';
setStreamingText(''); 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); if (call.toolCallId) byToolCallId.set(call.toolCallId, call);
converted = call; converted = call;
} else { } 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 // 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) => (prev) =>
withChildren(prev, parent, (kids) => [ withChildren(prev, parent, (kids) => [
...kids, ...kids,
{ role: 'assistant', id: crypto.randomUUID(), text: msg.text }, { role: 'assistant', id: randomId(), text: msg.text },
]) ?? [...prev, { role: 'assistant', id: crypto.randomUUID(), text: msg.text }], ]) ?? [...prev, { role: 'assistant', id: randomId(), text: msg.text }],
); );
break; break;
} }
if (streamingRef.current) { if (streamingRef.current) {
commitStreaming(); commitStreaming();
} else { } else {
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text: msg.text }]); setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text: msg.text }]);
} }
break; break;
} }