Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 11 additions & 10 deletions components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
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";
Expand Down Expand Up @@ -368,7 +369,7 @@
if (container && active instanceof HTMLElement && container.contains(active)) {
active.blur();
}
}, [sidebarOpen, mobileSidebarReady]);

Check warning on line 372 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (ubuntu-latest)

React Hook useLayoutEffect has a missing dependency: 'sidebarContainerRef'. Either include it or remove the dependency array

Check warning on line 372 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (windows-latest)

React Hook useLayoutEffect has a missing dependency: 'sidebarContainerRef'. Either include it or remove the dependency array
const refreshAppUpdate = useCallback(async (force = false, autoOpen = false): Promise<AppUpdateInfo | null> => {
const data = await fetchAppUpdateJson<AppUpdateInfo>(
force ? "/api/app-update?force=1" : "/api/app-update",
Expand Down Expand Up @@ -835,7 +836,7 @@
sidebarResizeHandlersRef.current = { onMove, onUp };
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}, [isMobile, sidebarWidth]);

Check warning on line 839 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (ubuntu-latest)

React Hook useCallback has a missing dependency: 'sidebarContainerRef'. Either include it or remove the dependency array

Check warning on line 839 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (windows-latest)

React Hook useCallback has a missing dependency: 'sidebarContainerRef'. Either include it or remove the dependency array

// If the app unmounts mid-drag, remove the window listeners and restore the
// body cursor; otherwise the handlers leak and body stays cursor:col-resize.
Expand All @@ -852,7 +853,7 @@
const resetRightPanelWidth = useCallback(() => {
rightPanelRef.current?.style.removeProperty("--right-panel-width");
setRightPanelWidth(null);
}, []);

Check warning on line 856 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (ubuntu-latest)

React Hook useCallback has a missing dependency: 'rightPanelRef'. Either include it or remove the dependency array

Check warning on line 856 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (windows-latest)

React Hook useCallback has a missing dependency: 'rightPanelRef'. Either include it or remove the dependency array

const changeRightPanelWidth = useCallback((delta: number) => {
setRightPanelWidth((prev) => {
Expand All @@ -863,7 +864,7 @@
rightPanelRef.current?.style.setProperty("--right-panel-width", `${next}px`);
return next;
});
}, []);

Check warning on line 867 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (ubuntu-latest)

React Hook useCallback has a missing dependency: 'rightPanelRef'. Either include it or remove the dependency array

Check warning on line 867 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (windows-latest)

React Hook useCallback has a missing dependency: 'rightPanelRef'. Either include it or remove the dependency array

const handleRightPanelResizeKey = useCallback((e: React.KeyboardEvent) => {
// The handle sits on the panel's left edge: left widens, right narrows.
Expand Down Expand Up @@ -932,7 +933,7 @@
rightResizeHandlersRef.current = { onMove, onUp, cancelFrame };
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}, [isMobile]);

Check warning on line 936 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (ubuntu-latest)

React Hook useCallback has a missing dependency: 'rightPanelRef'. Either include it or remove the dependency array

Check warning on line 936 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (windows-latest)

React Hook useCallback has a missing dependency: 'rightPanelRef'. Either include it or remove the dependency array

// If the app unmounts mid-drag, remove the window listeners and restore the
// body cursor; otherwise the handlers leak and body stays cursor:col-resize.
Expand Down Expand Up @@ -1164,7 +1165,7 @@
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);
Expand Down Expand Up @@ -1204,7 +1205,7 @@
return prev;
});
setNewSessionHostId(remoteHostId(hostId));
setNewSessionCreation({ id: crypto.randomUUID() });
setNewSessionCreation({ id: randomUUID() });
setSessionKey((k) => k + 1);
setBranchTree([]);
setBranchActiveLeafId(null);
Expand Down Expand Up @@ -1251,7 +1252,7 @@
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));
Expand Down Expand Up @@ -1324,7 +1325,7 @@
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;
Expand Down Expand Up @@ -1367,7 +1368,7 @@

// 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,
Expand Down Expand Up @@ -1512,7 +1513,7 @@
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);
Expand Down Expand Up @@ -1982,7 +1983,7 @@
// useAgentSession refuses to start — every send a silent no-op.
// Fall back to the server's default cwd (~/omp-cwd-<date>) instead.
if (activeCwd || activePoolProject) {
handleNewSession(crypto.randomUUID(), activeCwd, activeHostId, activePoolProject ?? undefined);
handleNewSession(randomUUID(), activeCwd, activeHostId, activePoolProject ?? undefined);
return;
}
const targetHost = currentHostId ?? LOCAL_HOST_ID;
Expand All @@ -1996,7 +1997,7 @@
})
.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")));
}}
Expand Down Expand Up @@ -2577,7 +2578,7 @@
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,
Expand Down Expand Up @@ -2609,7 +2610,7 @@
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))
Expand Down
7 changes: 4 additions & 3 deletions components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -724,7 +725,7 @@ export const ChatInput = memo(forwardRef<ChatInputHandle, Props>(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]);

Expand All @@ -735,7 +736,7 @@ export const ChatInput = memo(forwardRef<ChatInputHandle, Props>(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,
Expand Down
2 changes: 1 addition & 1 deletion components/DirectoryPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 3 additions & 11 deletions lib/event-stream.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<never>(() => {});
Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down
24 changes: 24 additions & 0 deletions lib/random-uuid.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
8 changes: 8 additions & 0 deletions lib/random-uuid.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
}
Loading