Skip to content

Commit 1a4d568

Browse files
authored
Merge pull request Stack-Cairn#130 from SinclairLin/fix/webui-random-uuid-fallback
fix(webui): add UUID fallback for legacy WebViews
2 parents b076355 + 168e177 commit 1a4d568

36 files changed

Lines changed: 468 additions & 99 deletions
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { createWebModuleLoader } from "../helpers/load-web-module.mjs";
4+
5+
const loader = createWebModuleLoader();
6+
const { createUuid } = loader.loadModule("@/lib/shared/id.ts");
7+
const { createEmptyRequestDraft } = loader.loadModule("@/pages/settings/httpRequestEditor.tsx");
8+
const { normalizeAgentPromptTemplate, normalizeCustomProvider, normalizeSshSettings } =
9+
loader.loadModule("@/lib/settings/index.ts");
10+
11+
const UUID_V4_PATTERN =
12+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
13+
14+
function withCrypto(value, run) {
15+
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto");
16+
if (value === undefined) {
17+
delete globalThis.crypto;
18+
} else {
19+
Object.defineProperty(globalThis, "crypto", {
20+
configurable: true,
21+
value,
22+
});
23+
}
24+
try {
25+
return run();
26+
} finally {
27+
if (descriptor) {
28+
Object.defineProperty(globalThis, "crypto", descriptor);
29+
} else {
30+
delete globalThis.crypto;
31+
}
32+
}
33+
}
34+
35+
test("createUuid uses crypto.randomUUID when available", () => {
36+
withCrypto({ randomUUID: () => "native-uuid" }, () => {
37+
assert.equal(createUuid(), "native-uuid");
38+
});
39+
});
40+
41+
test("createUuid falls back to an RFC 4122 v4 UUID without randomUUID", () => {
42+
withCrypto({}, () => {
43+
assert.match(createUuid(), UUID_V4_PATTERN);
44+
});
45+
});
46+
47+
test("createUuid works when global crypto is unavailable", () => {
48+
withCrypto(undefined, () => {
49+
assert.match(createUuid(), UUID_V4_PATTERN);
50+
});
51+
});
52+
53+
test("createUuid falls back when browser crypto methods throw", () => {
54+
withCrypto(
55+
{
56+
randomUUID() {
57+
throw new Error("randomUUID unavailable");
58+
},
59+
getRandomValues() {
60+
throw new Error("getRandomValues unavailable");
61+
},
62+
},
63+
() => {
64+
assert.match(createUuid(), UUID_V4_PATTERN);
65+
},
66+
);
67+
});
68+
69+
test("createUuid fallback remains unique when time and randomness repeat", () => {
70+
const originalNow = Date.now;
71+
const originalRandom = Math.random;
72+
Date.now = () => 123;
73+
Math.random = () => 0;
74+
try {
75+
withCrypto({}, () => {
76+
assert.notEqual(createUuid(), createUuid());
77+
});
78+
} finally {
79+
Date.now = originalNow;
80+
Math.random = originalRandom;
81+
}
82+
});
83+
84+
test("settings normalize generated IDs without crypto.randomUUID", () => {
85+
withCrypto({}, () => {
86+
const provider = normalizeCustomProvider({ name: "Provider", type: "codex" });
87+
const agent = normalizeAgentPromptTemplate({ name: "Agent" });
88+
const ssh = normalizeSshSettings({
89+
hosts: [
90+
{ id: "duplicate", host: "first.example" },
91+
{ id: "duplicate", host: "second.example" },
92+
{ host: "third.example" },
93+
],
94+
});
95+
96+
assert.match(provider.id, UUID_V4_PATTERN);
97+
assert.match(agent.id, UUID_V4_PATTERN);
98+
assert.equal(new Set(ssh.hosts.map((host) => host.id)).size, 3);
99+
});
100+
});
101+
102+
test("Hook/Cron HTTP request drafts work without crypto.randomUUID", () => {
103+
withCrypto({}, () => {
104+
assert.match(createEmptyRequestDraft().id, UUID_V4_PATTERN);
105+
});
106+
});

crates/agent-gateway/web/src/app/GatewayApp.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import {
9595
type WorkspaceProject,
9696
workspaceProjectPathKey,
9797
} from "@/lib/settings";
98+
import { createUuid } from "@/lib/shared/id";
9899
import { mergeAlwaysEnabledSkillNames } from "@/lib/skills";
99100
import { terminalSessionBelongsToProject } from "@/lib/terminal/sessionStore";
100101
import type { TerminalSession } from "@/lib/terminal/types";
@@ -110,7 +111,7 @@ import { SkillsHubPage } from "@/pages/skills-hub/SkillsHubPage";
110111

111112
const LOCAL_DRAFT_PREFIX = "__local_draft__:";
112113
function createLocalDraftConversationId() {
113-
return `${LOCAL_DRAFT_PREFIX}${crypto.randomUUID()}`;
114+
return `${LOCAL_DRAFT_PREFIX}${createUuid()}`;
114115
}
115116
function isLocalDraftConversationId(id: string) {
116117
return id.trim().startsWith(LOCAL_DRAFT_PREFIX);
@@ -1867,7 +1868,7 @@ export default function GatewayApp() {
18671868
}
18681869
clearCachedComposerDraft(activeConversationId);
18691870

1870-
const clientRequestId = options?.clientRequestId?.trim() || crypto.randomUUID();
1871+
const clientRequestId = options?.clientRequestId?.trim() || createUuid();
18711872
const startedAt = Date.now();
18721873
const persistedConversationWorkdir = sidebarStore.peek(activeConversationId)?.cwd?.trim() || "";
18731874
const runtimeConversationWorkdir =
@@ -2084,7 +2085,7 @@ export default function GatewayApp() {
20842085
),
20852086
systemSettings: buildGatewaySystemSettings(settings, workdirForTurn),
20862087
uploadedFiles: materialized.uploadedFiles,
2087-
clientRequestId: crypto.randomUUID(),
2088+
clientRequestId: createUuid(),
20882089
runtimeControls: chatRuntimeControlsForCurrentProvider,
20892090
queuePolicy,
20902091
});

crates/agent-gateway/web/src/components/chat/MentionComposer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
formatMarkdownReferenceDestination,
2929
} from "../../lib/chat/mentionReferences";
3030
import { extractClipboardFiles } from "../../lib/clipboardFiles";
31+
import { createUuid } from "../../lib/shared/id";
3132
import { cn } from "../../lib/shared/utils";
3233
import { invokeFs } from "../../lib/tools/fsBackend";
3334
import { Blend, SKILL_ICON_SVG_MARKUP } from "../icons";
@@ -1834,7 +1835,7 @@ export const MentionComposer = memo(
18341835
const index = largePasteCounterRef.current + 1;
18351836
largePasteCounterRef.current = index;
18361837
return {
1837-
id: `large-paste-${Date.now()}-${crypto.randomUUID()}`,
1838+
id: `large-paste-${Date.now()}-${createUuid()}`,
18381839
label: `Pasted text ${index}`,
18391840
text,
18401841
charCount: text.length,

crates/agent-gateway/web/src/lib/chat/uploadedFiles.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Message, UserMessage } from "../agentTypes";
2+
import { createUuid } from "../shared/id";
23

34
export type UploadedReadableFileKind =
45
| "text"
@@ -23,11 +24,7 @@ const DISPLAY_CONTENT_FIELD = "liveAgentDisplayContent";
2324
const ATTACHMENTS_FIELD = "liveAgentAttachments";
2425

2526
function createUserMessageId() {
26-
const id =
27-
typeof globalThis.crypto?.randomUUID === "function"
28-
? globalThis.crypto.randomUUID()
29-
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
30-
return `user-${id}`;
27+
return `user-${createUuid()}`;
3128
}
3229

3330
export type PendingUploadedFile = {

crates/agent-gateway/web/src/lib/chatUi.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getUserMessageDisplayText,
99
type PendingUploadedFile,
1010
} from "@/lib/chat/uploadedFiles";
11+
import { createUuid } from "@/lib/shared/id";
1112

1213
import type { ChatCheckpointPayload, ChatEvent, ConversationSummary } from "./gatewayTypes";
1314

@@ -133,7 +134,7 @@ const LIVE_UPLOADED_FILE_KINDS = new Set<string>([
133134
]);
134135

135136
function randomId(prefix: string) {
136-
return `${prefix}-${crypto.randomUUID()}`;
137+
return `${prefix}-${createUuid()}`;
137138
}
138139

139140
export function hashText(value: string) {

crates/agent-gateway/web/src/lib/gatewaySocket.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
SftpTransferEvent,
2222
SftpTransferResponse,
2323
} from "@/lib/sftp/types";
24+
import { createUuid } from "@/lib/shared/id";
2425
import { BrowserGatewayTerminalStreamClient } from "@/lib/terminal/gatewayTerminalStreamClient";
2526
import type {
2627
SshTerminalTab,
@@ -553,14 +554,10 @@ function buildWebSocketUrl() {
553554
function createChatClientRequestId(input: GatewayChatCommandInput) {
554555
const commandType = input.type.trim() || "chat.command";
555556
const conversationId = input.conversationId?.trim() || "new";
556-
const randomPart =
557-
typeof globalThis.crypto?.randomUUID === "function"
558-
? globalThis.crypto.randomUUID()
559-
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
560557
return `webui-${commandType.replace(/[^a-z0-9._-]/gi, "_")}-${conversationId.replace(
561558
/[^a-z0-9._-]/gi,
562559
"_",
563-
)}-${randomPart}`;
560+
)}-${createUuid()}`;
564561
}
565562

566563
function buildChatCommandPayload(input: GatewayChatCommandInput) {

crates/agent-gateway/web/src/lib/settings/index.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { KnownProvider, ModelThinkingLevel } from "@earendil-works/pi-ai";
22
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
33
import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
44
import { DEFAULT_LOCALE, type Locale, normalizeLocale } from "../../i18n/config";
5+
import { createUuid } from "../shared/id";
56
import { mergeAlwaysEnabledSkillNames } from "../skills/builtin";
67
import { SYSTEM_TOOL_OPTIONS, type SystemToolId } from "../tools/systemToolOptions";
78
import { normalizeApiKey, normalizeBaseUrl, normalizeModels } from "./normalize";
@@ -497,7 +498,7 @@ function normalizeWorkspaceProject(input: unknown): WorkspaceProject | null {
497498
const obj = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
498499
const path = normalizeWorkspaceProjectPath(obj.path);
499500
if (!path) return null;
500-
const id = typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : crypto.randomUUID();
501+
const id = typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : createUuid();
501502
const name =
502503
typeof obj.name === "string" && obj.name.trim()
503504
? obj.name.trim()
@@ -549,7 +550,7 @@ function normalizeWorkspaceProjects(input: unknown): WorkspaceProject[] {
549550
seenPaths.add(pathKey);
550551
let id = project.id;
551552
if (seenIds.has(id)) {
552-
id = crypto.randomUUID();
553+
id = createUuid();
553554
}
554555
seenIds.add(id);
555556
out.push({ ...project, id });
@@ -635,7 +636,7 @@ export function resolveWorkspaceProjects(
635636
seenPaths.add(pathKey);
636637
let id = project.id;
637638
if (!id || id === DEFAULT_WORKSPACE_PROJECT_ID || seenIds.has(id)) {
638-
id = crypto.randomUUID();
639+
id = createUuid();
639640
}
640641
seenIds.add(id);
641642
projects.push({
@@ -1298,7 +1299,7 @@ export function normalizeCustomProvider(input: unknown): CustomProvider {
12981299
const models = normalizeProviderModelConfigs(obj.models, type);
12991300
const validModelIds = new Set(models.map((model) => model.id));
13001301
const apiKey = normalizeApiKey(typeof obj.apiKey === "string" ? obj.apiKey : "");
1301-
const id = typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : crypto.randomUUID();
1302+
const id = typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : createUuid();
13021303

13031304
return {
13041305
id,
@@ -1324,7 +1325,7 @@ export function normalizeAgentPromptTemplate(input: unknown): AgentPromptTemplat
13241325
const obj = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
13251326

13261327
return {
1327-
id: typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : crypto.randomUUID(),
1328+
id: typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : createUuid(),
13281329
name: typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : "未命名模板",
13291330
description: normalizeOptionalText(obj.description),
13301331
prompt: normalizeOptionalText(obj.prompt),
@@ -1396,7 +1397,7 @@ export function normalizeSshHostConfig(input: unknown): SshHostConfig {
13961397
(privateKeyPassphrase.length > 0 || obj.privateKeyPassphraseConfigured === true);
13971398

13981399
return {
1399-
id: typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : crypto.randomUUID(),
1400+
id: typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : createUuid(),
14001401
name,
14011402
description: normalizeOptionalText(obj.description),
14021403
host,
@@ -1424,7 +1425,7 @@ export function normalizeSshSettings(input: unknown): SshSettings {
14241425
seenIds.add(normalized.id);
14251426
return normalized;
14261427
}
1427-
const id = crypto.randomUUID();
1428+
const id = createUuid();
14281429
seenIds.add(id);
14291430
return { ...normalized, id };
14301431
});
@@ -2216,11 +2217,7 @@ const RIGHT_DOCK_WRITER_ID_STORAGE_KEY = "liveagent.client-id";
22162217
let cachedRightDockWriterId = "";
22172218

22182219
function generateRightDockWriterId(): string {
2219-
const uuid =
2220-
typeof globalThis.crypto?.randomUUID === "function"
2221-
? globalThis.crypto.randomUUID()
2222-
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
2223-
return uuid.replace(/-/g, "").slice(0, 12);
2220+
return createUuid().replace(/-/g, "").slice(0, 12);
22242221
}
22252222

22262223
// Stable per-client id used to break stateVersion ties deterministically in
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
let fallbackSequence = 0;
2+
3+
function fillFallbackBytes(bytes: Uint8Array) {
4+
const timestamp = Date.now();
5+
fallbackSequence = (fallbackSequence + 1) >>> 0;
6+
7+
for (let index = 0; index < bytes.length; index += 1) {
8+
const timeByte = Math.floor(timestamp / 2 ** ((index % 6) * 8)) & 0xff;
9+
const sequenceByte = (fallbackSequence >>> ((index % 4) * 8)) & 0xff;
10+
bytes[index] = Math.floor(Math.random() * 256) ^ timeByte ^ sequenceByte;
11+
}
12+
}
13+
14+
/**
15+
* UUID v4 with fallbacks for legacy/embedded WebViews where crypto.randomUUID
16+
* is missing or throws when called. The last-resort path is Math.random-based
17+
* and NOT cryptographically secure — use only for identifiers, never for
18+
* security tokens or secrets.
19+
*/
20+
export function createUuid(): string {
21+
const crypto = globalThis.crypto;
22+
if (typeof crypto?.randomUUID === "function") {
23+
try {
24+
return crypto.randomUUID();
25+
} catch {
26+
// Some embedded WebViews expose the API but fail when it is called.
27+
}
28+
}
29+
30+
const bytes = new Uint8Array(16);
31+
if (typeof crypto?.getRandomValues === "function") {
32+
try {
33+
crypto.getRandomValues(bytes);
34+
} catch {
35+
fillFallbackBytes(bytes);
36+
}
37+
} else {
38+
fillFallbackBytes(bytes);
39+
}
40+
41+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
42+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
43+
44+
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0"));
45+
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
46+
.slice(6, 8)
47+
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
48+
}

crates/agent-gateway/web/src/pages/settings/AgentsSection.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { BookOpen, Eye, Pencil, Plus, Trash2, X } from "../../components/icons";
55
import { Button } from "../../components/ui/button";
66
import { useLocale } from "../../i18n";
77
import { type AgentPromptTemplate, updateAgents } from "../../lib/settings";
8+
import { createUuid } from "../../lib/shared/id";
89
import { useModalMotion } from "../../lib/shared/modalMotion";
910
import { AgentPromptTemplateModal } from "./AgentPromptTemplateModal";
1011
import { AgentActivationSwitch, ConfirmDeletePopover } from "./shared";
@@ -44,7 +45,7 @@ export function AgentsSection(props: SettingsSectionProps) {
4445
}
4546

4647
const newTemplate: AgentPromptTemplate = {
47-
id: crypto.randomUUID(),
48+
id: createUuid(),
4849
...data,
4950
enabled: false,
5051
};

crates/agent-gateway/web/src/pages/settings/ProvidersSection.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
updateCustomProviders,
3939
updateCustomSettings,
4040
} from "../../lib/settings";
41+
import { createUuid } from "../../lib/shared/id";
4142
import { useModalMotion } from "../../lib/shared/modalMotion";
4243
import {
4344
createDraftModelConfig,
@@ -874,7 +875,7 @@ export function ProvidersSection(props: SettingsSectionProps) {
874875
}
875876

876877
const newProvider: CustomProvider = {
877-
id: crypto.randomUUID(),
878+
id: createUuid(),
878879
...data,
879880
};
880881
return updateCustomProviders(prev, [...prev.customProviders, newProvider]);

0 commit comments

Comments
 (0)