From ac70d1debe5da4e8813b2e5420641cdbd1c212c4 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Sat, 14 Nov 2026 14:40:43 +0000 Subject: [PATCH] fix(browser): generate UUIDs on HTTP LAN origins --- CHANGELOG.md | 4 ++++ components/AppShell.tsx | 21 +++++++++++---------- components/ChatInput.tsx | 7 ++++--- components/DirectoryPicker.tsx | 2 +- lib/event-stream.ts | 14 +++----------- lib/random-uuid.test.mjs | 24 ++++++++++++++++++++++++ lib/random-uuid.ts | 8 ++++++++ 7 files changed, 55 insertions(+), 25 deletions(-) create mode 100644 lib/random-uuid.test.mjs create mode 100644 lib/random-uuid.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd592282..37f9b5177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ## [Unreleased] +### Fixed + +- **Restore HTTP LAN and VPN access.** Browser UUID generation now works without secure-context-only `crypto.randomUUID()`, preventing the "Something went wrong" crash when opening the app or creating a session. + ## [v0.7.1] - 2026-11-14 ### Fixed diff --git a/components/AppShell.tsx b/components/AppShell.tsx index c711e75b4..f2847c856 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -81,6 +81,7 @@ import type { CrossSessionHostToolCall, ManagedProject, SessionInfo, SessionTree import { runHostTool } from "@/hooks/useAgentSession-stream"; import { sendAgentCommand } from "@/lib/agent-client"; import { openEventStream } from "@/lib/event-stream"; +import { randomUUID } from "@/lib/random-uuid"; import type { ChatInputHandle } from "./ChatInput"; import type { SessionStatsInfo, GenerationSpeedInfo } from "@/lib/pi-types"; import type { SettingsTab } from "./SettingsTabs"; @@ -1164,7 +1165,7 @@ export function AppShell({ appName }: { appName: string }) { if (selectedSession?.poolId === project.poolId && selectedSession.projectId === project.projectId) return; if (newSessionPoolProject?.poolId === project.poolId && newSessionPoolProject.projectId === project.projectId) return; setNewSessionPoolProject(project); - setNewSessionCreation({ id: crypto.randomUUID() }); + setNewSessionCreation({ id: randomUUID() }); setNewSessionCwd(null); setNewSessionHostId(undefined); setSelectedSession(null); @@ -1204,7 +1205,7 @@ export function AppShell({ appName }: { appName: string }) { return prev; }); setNewSessionHostId(remoteHostId(hostId)); - setNewSessionCreation({ id: crypto.randomUUID() }); + setNewSessionCreation({ id: randomUUID() }); setSessionKey((k) => k + 1); setBranchTree([]); setBranchActiveLeafId(null); @@ -1251,7 +1252,7 @@ export function AppShell({ appName }: { appName: string }) { setSettingsTab(null); setSelectedSession(null); const target = project ?? (cwd === activeCwd ? activePoolProject : null); - setNewSessionCreation({ id: creationId || crypto.randomUUID(), initialization }); + setNewSessionCreation({ id: creationId || randomUUID(), initialization }); setNewSessionPoolProject(target?.poolId ? target : null); setNewSessionCwd(target?.poolId ? null : cwd); setNewSessionHostId(target?.poolId ? undefined : remoteHostId(hostId)); @@ -1324,7 +1325,7 @@ export function AppShell({ appName }: { appName: string }) { else navigationHistory.commitForward(); const project = entry.poolId ? workspaceOptions.projects.find((candidate) => candidate.poolId === entry.poolId && candidate.projectId === entry.projectId) : undefined; if (entry.poolId && !project) { toast.error(translate("errors.generic")); return; } - handleNewSession(entry.creationId ?? crypto.randomUUID(), entry.cwd, entry.hostId, project, entry.initialization); + handleNewSession(entry.creationId ?? randomUUID(), entry.cwd, entry.hostId, project, entry.initialization); return; } const versionAtPress = navViewVersionRef.current; @@ -1367,7 +1368,7 @@ export function AppShell({ appName }: { appName: string }) { // Global keyboard shortcuts (handles Esc, Ctrl+Alt+N etc.) useGlobalKeyboardShortcuts({ - onNewSession: (cwd: string) => handleNewSession(crypto.randomUUID(), activePoolProject ? null : cwd, activeHostId, activePoolProject ?? undefined), + onNewSession: (cwd: string) => handleNewSession(randomUUID(), activePoolProject ? null : cwd, activeHostId, activePoolProject ?? undefined), onNavigateBack: handleNavigateBack, onNavigateForward: handleNavigateForward, activeCwd: activePoolProject ? projectIdentity(activePoolProject) : activeCwd, @@ -1512,7 +1513,7 @@ export function AppShell({ appName }: { appName: string }) { const cwd = selectedSession.cwd; setSelectedSession(null); setNewSessionCwd(selectedSession.poolId ? null : cwd ?? null); - setNewSessionCreation({ id: crypto.randomUUID() }); + setNewSessionCreation({ id: randomUUID() }); setNewSessionPoolProject(selectedSession.poolId ? workspaceOptions.projects.find((project) => project.poolId === selectedSession.poolId && project.projectId === selectedSession.projectId) ?? null : null); setNewSessionHostId(remoteHostId(selectedSession.hostId)); setSessionKey((k) => k + 1); @@ -1982,7 +1983,7 @@ export function AppShell({ appName }: { appName: string }) { // useAgentSession refuses to start — every send a silent no-op. // Fall back to the server's default cwd (~/omp-cwd-) instead. if (activeCwd || activePoolProject) { - handleNewSession(crypto.randomUUID(), activeCwd, activeHostId, activePoolProject ?? undefined); + handleNewSession(randomUUID(), activeCwd, activeHostId, activePoolProject ?? undefined); return; } const targetHost = currentHostId ?? LOCAL_HOST_ID; @@ -1996,7 +1997,7 @@ export function AppShell({ appName }: { appName: string }) { }) .then((data) => { if (!data.cwd) throw new Error("Empty cwd returned"); - if (navViewVersionRef.current === sourceVersion) handleNewSession(crypto.randomUUID(), data.cwd, targetHost); + if (navViewVersionRef.current === sourceVersion) handleNewSession(randomUUID(), data.cwd, targetHost); }) .catch(() => toast.error(translate("errors.generic"))); }} @@ -2577,7 +2578,7 @@ export function AppShell({ appName }: { appName: string }) { setActivePoolProject(first.poolId ? first : null); setActiveCwd(first.poolId ? null : first.path); setActiveHostId(first.poolId ? undefined : remoteHostId(first.hostId)); - handleNewSession(crypto.randomUUID(), first.poolId ? null : first.path, first.hostId, first); + handleNewSession(randomUUID(), first.poolId ? null : first.path, first.hostId, first); }} options={creationTargets.map((target) => ({ value: target.id, @@ -2609,7 +2610,7 @@ export function AppShell({ appName }: { appName: string }) { setActivePoolProject(project.poolId ? project : null); setActiveCwd(project.poolId ? null : project.path); setActiveHostId(project.poolId ? undefined : remoteHostId(project.hostId)); - handleNewSession(crypto.randomUUID(), project.poolId ? null : project.path, project.hostId, project); + handleNewSession(randomUUID(), project.poolId ? null : project.path, project.hostId, project); }} options={[ ...(!effectivePoolProject && effectiveNewSessionCwd && !hostProjects.some((project) => foldedHostProjectKey(project.hostId, project.path) === foldedHostProjectKey(newSessionHost, newSessionProject)) diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 94bc66063..d145411a4 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -36,7 +36,8 @@ import { uploadRecovery, type AttachedUpload, } from "./ChatInput-uploads"; -import { discardUpload, newUploadId, uploadFile, UploadFailedError, type UploadHandle } from "@/lib/event-stream"; +import { discardUpload, uploadFile, UploadFailedError, type UploadHandle } from "@/lib/event-stream"; +import { randomUUID } from "@/lib/random-uuid"; import { MAX_UPLOAD_BYTES, MAX_UPLOADS_PER_MESSAGE } from "@/lib/upload-limits"; import { hostProjectKey, LOCAL_HOST_ID } from "@/lib/project-ordering"; import { @@ -724,7 +725,7 @@ export const ChatInput = memo(forwardRef(function ChatIn ({ proof }) => settle({ status: "done", received: size, error: null, proof }), // 410: the worker already cancelled it on its host (the page went into the back/forward cache). A Retry under a // new id starts over, so the cancel still pending for this one can never reach it. - (error: unknown) => settle({ status: "error", error: uploadErrorMessage(error), ...(error instanceof UploadFailedError && error.status === 410 ? { id: newUploadId(), proof: undefined } : {}) }), + (error: unknown) => settle({ status: "error", error: uploadErrorMessage(error), ...(error instanceof UploadFailedError && error.status === 410 ? { id: randomUUID(), proof: undefined } : {}) }), ); }, [patchUpload, t]); @@ -735,7 +736,7 @@ export const ChatInput = memo(forwardRef(function ChatIn const room = Math.max(0, MAX_UPLOADS_PER_MESSAGE - attachedUploadsRef.current.length); const fitting = files.flatMap((file, i) => (file.size <= MAX_UPLOAD_BYTES ? [{ file, previewUrl: previewUrls[i] }] : [])); const added = fitting.slice(0, room).map(({ file, previewUrl }): AttachedUpload => ({ - id: newUploadId(), + id: randomUUID(), name: file.name, size: file.size, hostId: resolveUploadHostRef.current ? "" : uploadHostIdRef.current, diff --git a/components/DirectoryPicker.tsx b/components/DirectoryPicker.tsx index 6fbc03aff..f15f3ad22 100644 --- a/components/DirectoryPicker.tsx +++ b/components/DirectoryPicker.tsx @@ -7,7 +7,7 @@ import { isValidOmpProfile } from "@/lib/omp-profile"; import { useI18n } from "@/lib/i18n"; import { formatApiError } from "@/lib/i18n/api-error"; import { appendProgress, cloneDirectoryName } from "@/lib/git-clone"; -import { newUploadId as newCloneId } from "@/lib/event-stream"; +import { randomUUID as newCloneId } from "@/lib/random-uuid"; interface DirectoryEntry { name: string; diff --git a/lib/event-stream.ts b/lib/event-stream.ts index 9895548c1..b19478971 100644 --- a/lib/event-stream.ts +++ b/lib/event-stream.ts @@ -1,5 +1,6 @@ "use client"; +import { randomUUID } from "./random-uuid"; import type { BrowserTransport, ConnectionDiagnostics, EventStreamChannel } from "./event-stream-protocol"; import { registerNotificationWorker } from "./notification-client"; import type { OutboxEntry, OutboxMode } from "./types"; @@ -309,7 +310,7 @@ class Transport { private holdLifetimeLock(): void { const locks = typeof navigator === "undefined" ? undefined : navigator.locks; if (!locks) return; - const name = `omp-web-event-stream:${crypto.randomUUID()}`; + const name = `omp-web-event-stream:${randomUUID()}`; void locks.request(name, () => { this.port.postMessage({ op: "lock", name }); return new Promise(() => {}); @@ -562,15 +563,6 @@ export interface UploadHandle { /** HTTP PUT bodies in SSE mode (spec U4). */ const HTTP_UPLOAD_BYTES = 1024 * 1024; -/** A UUID v4, lowercase (UPLOAD_ID_RE); no crypto.randomUUID (insecure LAN origins lack it). */ -export function newUploadId(): string { - const bytes = crypto.getRandomValues(new Uint8Array(16)); - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = Array.from(bytes, (byte) => byte.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)}`; -} - /** Back-off before each automatic resume; the last value repeats. */ const RESUME_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 15_000]; /** An outage (transient failures with no new bytes on the host in between) this long stops resuming: the chip then @@ -587,7 +579,7 @@ export function uploadFile( file: File, hostId: string, onProgress: (received: number) => void, - id = newUploadId(), + id = randomUUID(), onReconnecting: (error: unknown) => void = () => {}, ): UploadHandle { const controller = new AbortController(); diff --git a/lib/random-uuid.test.mjs b/lib/random-uuid.test.mjs new file mode 100644 index 000000000..3433da1bc --- /dev/null +++ b/lib/random-uuid.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { webcrypto } from "node:crypto"; +import { randomUUID } from "./random-uuid.ts"; + +const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +test("UUIDs remain valid and distinct on HTTP origins without randomUUID", () => { + const original = Object.getOwnPropertyDescriptor(globalThis, "crypto"); + try { + Object.defineProperty(globalThis, "crypto", { configurable: true, value: { + getRandomValues: webcrypto.getRandomValues.bind(webcrypto), + } }); + const ids = Array.from({ length: 100 }, () => randomUUID()); + for (const id of ids) assert.match(id, uuidV4); + assert.equal(new Set(ids).size, ids.length); + } finally { + Object.defineProperty(globalThis, "crypto", original); + } +}); + +test("UUIDs remain valid in secure contexts", () => { + assert.match(randomUUID(), uuidV4); +}); diff --git a/lib/random-uuid.ts b/lib/random-uuid.ts new file mode 100644 index 000000000..1d0685bd8 --- /dev/null +++ b/lib/random-uuid.ts @@ -0,0 +1,8 @@ +/** UUID v4, including on HTTP LAN origins where crypto.randomUUID is unavailable. */ +export function randomUUID(): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.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)}`; +}