diff --git a/site/package.json b/site/package.json index f974baaa18d47..a3974dd6494bb 100644 --- a/site/package.json +++ b/site/package.json @@ -110,7 +110,6 @@ "ua-parser-js": "1.0.41", "ufuzzy": "npm:@leeoniya/ufuzzy@1.0.10", "unique-names-generator": "4.7.1", - "uuid": "14.0.0", "websocket-ts": "2.3.0", "yup": "1.7.1" }, @@ -150,7 +149,6 @@ "@types/semver": "7.7.1", "@types/ssh2": "1.15.5", "@types/ua-parser-js": "0.7.36", - "@types/uuid": "9.0.2", "@vitejs/plugin-react": "6.0.5", "@vitest/browser-playwright": "4.1.10", "autoprefixer": "10.5.4", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 82a297dd774dc..ae47926bf0d75 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -251,9 +251,6 @@ importers: unique-names-generator: specifier: 4.7.1 version: 4.7.1 - uuid: - specifier: 11.1.1 - version: 11.1.1 websocket-ts: specifier: 2.3.0 version: 2.3.0 @@ -366,9 +363,6 @@ importers: '@types/ua-parser-js': specifier: 0.7.36 version: 0.7.36 - '@types/uuid': - specifier: 9.0.2 - version: 9.0.2 '@vitejs/plugin-react': specifier: 6.0.5 version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.26.10)(rolldown@1.2.4)(vite@8.2.1(@types/node@20.19.41)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.22.4)(yaml@2.8.3)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.1(@types/node@20.19.41)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.22.4)(yaml@2.8.3)) @@ -2690,9 +2684,6 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==, tarball: https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz} - '@types/uuid@9.0.2': - resolution: {integrity: sha512-kNnC1GFBLuhImSnV7w4njQkUiJi0ZXUycu1rUaouPqiKlXkh77JKgdRnTAp1x5eBwcIwbtI+3otwzuIDEuDoxQ==, tarball: https://registry.npmjs.org/@types/uuid/-/uuid-9.0.2.tgz} - '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==, tarball: https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz} @@ -8497,8 +8488,6 @@ snapshots: '@types/unist@3.0.3': {} - '@types/uuid@9.0.2': {} - '@types/wrap-ansi@3.0.0': {} '@ungap/structured-clone@1.3.0': {} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 288f0b0c03774..24df0aa8fc46c 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -9,7 +9,6 @@ import { } from "react"; import { useQueryClient } from "react-query"; import type { UrlTransform } from "streamdown"; -import { v4 as uuidv4 } from "uuid"; import { invalidateChatDiffContents } from "#/api/queries/chats"; import type * as TypesGen from "#/api/typesGenerated"; import type { @@ -28,6 +27,7 @@ import { findWorkspaceAppWithAgent } from "#/modules/apps/workspaceApps"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { cn } from "#/utils/cn"; import { pageTitle } from "#/utils/page"; +import { generateUUID } from "#/utils/random"; import { findWorkspaceAgent } from "#/utils/workspace"; import { AgentChatInput, @@ -592,7 +592,7 @@ export const AgentChatPageView: FC = ({ const createUserRightPanelTabId = ( kind: UserRightPanelTab["kind"], ): string => { - return `${kind}-${uuidv4()}`; + return `${kind}-${generateUUID()}`; }; const handleAddTerminalTab = () => { @@ -611,7 +611,7 @@ export const AgentChatPageView: FC = ({ { id: tabId, kind: "terminal", - reconnectionToken: uuidv4(), + reconnectionToken: generateUUID(), }, ]); startPendingTab(tabId); @@ -657,7 +657,7 @@ export const AgentChatPageView: FC = ({ id: createUserRightPanelTabId("terminal"), kind: "terminal", label: app.display_name ?? app.slug, - reconnectionToken: uuidv4(), + reconnectionToken: generateUUID(), initialCommand: app.command, sourceAppId: app.id, }; diff --git a/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.ts b/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.ts index 9c2e3a7e1ecf0..fa3a6dfab02be 100644 --- a/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.ts @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { API } from "#/api/api"; import { MaxChatFileSizeBytes } from "#/api/typesGenerated"; +import { generateUUID } from "#/utils/random"; import type { UploadState } from "../components/AgentChatInput"; import { getChatFileURL, @@ -82,8 +83,6 @@ type UploadRegistryEntry = { // write storage or notify UI again. const activeDraftUploads = new Map(); -let fallbackClientIdCounter = 0; - const isTerminalRegistryStatus = (entry: UploadRegistryEntry) => entry.status === "uploaded" || entry.status === "error"; @@ -93,20 +92,7 @@ const pruneTerminalRegistryEntry = (entry: UploadRegistryEntry) => { } }; -const createClientId = () => { - const cryptoObject = - typeof globalThis.crypto !== "undefined" ? globalThis.crypto : undefined; - if (cryptoObject?.randomUUID) { - return cryptoObject.randomUUID(); - } - if (cryptoObject?.getRandomValues) { - const values = new Uint32Array(2); - cryptoObject.getRandomValues(values); - return `draft-${Date.now()}-${Array.from(values, (value) => value.toString(36)).join("-")}`; - } - fallbackClientIdCounter += 1; - return `draft-${Date.now()}-${fallbackClientIdCounter}`; -}; +const createClientId = () => generateUUID(); const createBlobPreview = (file: File): string | undefined => { if (file.type === "text/plain" || typeof URL.createObjectURL !== "function") { @@ -884,5 +870,4 @@ export const resetChatDraftAttachmentRegistryForTest = () => { notifySubscribers(entry); } activeDraftUploads.clear(); - fallbackClientIdCounter = 0; }; diff --git a/site/src/pages/TaskPage/TaskApps.stories.tsx b/site/src/pages/TaskPage/TaskApps.stories.tsx index e80cd1cbe2a76..9cb91333ca79a 100644 --- a/site/src/pages/TaskPage/TaskApps.stories.tsx +++ b/site/src/pages/TaskPage/TaskApps.stories.tsx @@ -12,6 +12,7 @@ import { MockWorkspaceProxies, } from "#/testHelpers/entities"; import { withAuthProvider, withProxyProvider } from "#/testHelpers/storybook"; +import { generateUUID } from "#/utils/random"; import { TaskApps } from "./TaskApps"; const mockExternalApp: WorkspaceApp = { @@ -123,7 +124,7 @@ export const WithManyEmbeddedApps: Story = { function mockEmbeddedApp(name = MockWorkspaceApp.display_name): WorkspaceApp { return { ...MockWorkspaceApp, - id: crypto.randomUUID(), + id: generateUUID(), slug: kebabCase(name), display_name: name, external: false, diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx index e1a443b6580c9..c8a6b60601492 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx @@ -1,7 +1,6 @@ import { type FC, useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useQuery } from "react-query"; import { Navigate, useNavigate, useSearchParams } from "react-router"; -import { v4 as uuidv4 } from "uuid"; import { deploymentConfig } from "#/api/queries/deployment"; import { createTemplateFromBuilder, @@ -12,6 +11,7 @@ import { Loader } from "#/components/Loader/Loader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { linkToTemplate, useLinks } from "#/modules/navigation"; import { pageTitle } from "#/utils/page"; +import { generateUUID } from "#/utils/random"; import { TemplateBuilderPageView } from "./TemplateBuilderPageView"; import type { SelectedBaseMeta, @@ -30,7 +30,7 @@ const TemplateBuilderPage: FC = () => { // Stable session ID for the lifetime of this page mount, shared // across wizard_entry and compose_completion telemetry events. - const sessionId = useMemo(() => uuidv4(), []); + const sessionId = useMemo(() => generateUUID(), []); const builderDisabled = data?.config?.template_builder?.disabled ?? false; const wizardReady = diff --git a/site/src/pages/TerminalPage/TerminalPage.test.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx index 4c1269c54f48f..40649a194efcb 100644 --- a/site/src/pages/TerminalPage/TerminalPage.test.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -16,8 +16,8 @@ import TerminalPage from "./TerminalPage"; const reconnectToken = "terminal-page-test-reconnect-token"; -vi.mock("uuid", () => ({ - v4: () => "terminal-page-test-reconnect-token", +vi.mock("#/utils/random", () => ({ + generateUUID: () => "terminal-page-test-reconnect-token", })); vi.stubGlobal("jest", vi); await import("jest-canvas-mock"); diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index 44c8f1040c870..4bbb7371b1210 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -8,7 +8,6 @@ import { } from "react"; import { useQuery } from "react-query"; import { useNavigate, useParams, useSearchParams } from "react-router"; -import { v4 as uuidv4 } from "uuid"; import { deploymentConfig } from "#/api/queries/deployment"; import { appearanceSettings } from "#/api/queries/users"; import { @@ -28,6 +27,7 @@ import { WorkspaceTerminalAlerts } from "#/modules/terminal/WorkspaceTerminalAle import themes from "#/theme"; import { pageTitle } from "#/utils/page"; import { openMaybePortForwardedURL } from "#/utils/portForward"; +import { generateUUID } from "#/utils/random"; import { getMatchingAgentOrFirst } from "#/utils/workspace"; import { TerminalCommandConsentDialog } from "./TerminalCommandConsentDialog"; @@ -49,7 +49,7 @@ const TerminalPage: FC = () => { // The reconnection token is a unique token that identifies // a terminal session. It's generated by the client to reduce // a round-trip, and must be a UUIDv4. - const reconnectionToken = searchParams.get("reconnect") ?? uuidv4(); + const reconnectionToken = searchParams.get("reconnect") ?? generateUUID(); const command = searchParams.get("command") || undefined; const appSlug = searchParams.get("app") || undefined; const containerName = searchParams.get("container") || undefined; diff --git a/site/src/pages/WorkspacesPage/WorkspaceSharingIndicator.stories.tsx b/site/src/pages/WorkspacesPage/WorkspaceSharingIndicator.stories.tsx index 0306ab445d555..0be88dcf2ec26 100644 --- a/site/src/pages/WorkspacesPage/WorkspaceSharingIndicator.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspaceSharingIndicator.stories.tsx @@ -1,13 +1,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, screen, userEvent, waitFor } from "storybook/test"; import type { SharedWorkspaceActor } from "#/api/typesGenerated"; +import { generateUUID } from "#/utils/random"; import { WorkspaceSharingIndicator } from "./WorkspaceSharingIndicator"; const mockUser = ( name: string, roles: SharedWorkspaceActor["roles"] = ["use"], ): SharedWorkspaceActor => ({ - id: crypto.randomUUID(), + id: generateUUID(), actor_type: "user", name, roles, @@ -17,7 +18,7 @@ const mockGroup = ( name: string, roles: SharedWorkspaceActor["roles"] = ["use"], ): SharedWorkspaceActor => ({ - id: crypto.randomUUID(), + id: generateUUID(), actor_type: "group", name, roles, diff --git a/site/src/utils/random.test.ts b/site/src/utils/random.test.ts index 99efb5ef7461a..2ce73e281fa33 100644 --- a/site/src/utils/random.test.ts +++ b/site/src/utils/random.test.ts @@ -1,5 +1,31 @@ -import { describe, expect, it } from "vitest"; -import { generateConnectionSessionId } from "#/utils/random"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { generateConnectionSessionId, generateUUID } from "#/utils/random"; +import { isUUID } from "#/utils/uuid"; + +describe("generateUUID", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("outputs a valid UUID", () => { + expect(isUUID(generateUUID())).toBe(true); + }); + + it("generates unique values across calls", () => { + const numValues = 1000; + const ids = new Set( + Array.from({ length: numValues }, () => generateUUID()), + ); + expect(ids.size).toBe(numValues); + }); + + it("produces a valid UUID without crypto.randomUUID (insecure context)", () => { + vi.stubGlobal("crypto", { + getRandomValues: crypto.getRandomValues.bind(crypto), + }); + expect(isUUID(generateUUID())).toBe(true); + }); +}); describe("generateConnectionSessionId", () => { it("is 32 characters long", () => { diff --git a/site/src/utils/random.ts b/site/src/utils/random.ts index 39cfed9041623..6f593f2948579 100644 --- a/site/src/utils/random.ts +++ b/site/src/utils/random.ts @@ -1,3 +1,25 @@ +/** + * Generate a random UUID (version 4). `crypto.randomUUID` only exists in + * secure contexts, so fall back to `crypto.getRandomValues` (which works over + * plain HTTP) when it is missing. + */ +export const generateUUID = (): string => { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return [ + hex.slice(0, 4).join(""), + hex.slice(4, 6).join(""), + hex.slice(6, 8).join(""), + hex.slice(8, 10).join(""), + hex.slice(10, 16).join(""), + ].join("-"); +}; + /** * Generate a random hexadecimal string from the specified number of bytes. */