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:
@@ -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; });
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 { 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
|
||||
</div>
|
||||
<div className="flex justify-end -mb-1 -mr-1 gap-0.5">
|
||||
<CopyButton text={assistantText} />
|
||||
<ReadAloudButton id={message.id ?? crypto.randomUUID()} text={assistantText} />
|
||||
<ReadAloudButton id={message.id ?? randomId()} text={assistantText} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -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 = '';
|
||||
|
||||
+5
-4
@@ -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();
|
||||
|
||||
@@ -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=<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();
|
||||
if (!text || submitting) return;
|
||||
setSubmitting(true);
|
||||
const sentId = crypto.randomUUID();
|
||||
const sentId = randomId();
|
||||
try {
|
||||
const created = await client.post<Partial<SlskdSearchSummary>>('/slskd/api/v0/searches', {
|
||||
id: sentId,
|
||||
|
||||
@@ -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<string, 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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user