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; }