fix(web): keep Remote Web loading over plain HTTP without crypto.randomUUID (#22516)

* fix(web): keep Remote Web loading over plain HTTP without crypto.randomUUID

Browsers hide crypto.randomUUID outside secure contexts, so Remote Web over
http://<lan-or-tailnet-ip> threw while importing the store and never painted.
createAgentStatusAuthorityId now takes its UUID source (renderer passes
createBrowserUuid, main passes node:crypto randomUUID), and the other
unguarded renderer calls go through createBrowserUuid.

* refactor(renderer): route remaining randomUUID fallbacks through createBrowserUuid

Replaces five hand-rolled crypto?.randomUUID?.() fallbacks (including a copy
of the browser-uuid fallback in mint-stable-pane-id) with createBrowserUuid,
and adds an oxlint no-restricted-properties rule so renderer code cannot call
randomUUID directly again.

* refactor(shared): move the non-secure-context UUID generator into src/shared

The white screen came from src/shared, so the fix belongs there. src/shared had
three hand-rolled copies of the same randomUUID-then-getRandomValues-then-Math.random
ladder (nested-repo-telemetry, project-groups, setup-agent-sequencing) because there
was nothing in that layer to import; createBrowserUuid lived one directory over in
the renderer.

createNonSecureContextUuid() now holds the single implementation, @/lib/browser-uuid
re-exports it under the renderer's existing name so no renderer import site changes,
and the three duplicates call it.

That also lets createAgentStatusAuthorityId go back to one argument. The injected
randomUuid source was justified as keeping browser APIs out of shared code, but this
generator is runtime-agnostic — it works unchanged in Node. Injecting it bought no
layering and made the safe choice a parameter every future caller had to get right,
unguarded: a caller could pass () => globalThis.crypto.randomUUID() and restore the
white screen with lint and tests green.

* fix(lint): ban crypto.randomUUID in src/shared and scope the escape hatch

vite.web.config.ts compiles src/shared straight into the web bundle, but the new
randomUUID ban only covered src/renderer/src — so the exact module that white-screened
the app sat outside the guard it shipped with, and the regression could come back with
a green lint. The override now covers src/shared/**/*.ts too; it costs zero diagnostics
because the duplicates it would have flagged are gone. `import { randomUUID } from
'node:crypto'` is untouched, so main-only shared modules keep working.

Both blanket "off" overrides are gone. no-restricted-properties is keyed by property
name, so the moment a second property joins the renderer block those overrides would
have silently exempted it — in the one file that is the escape hatch, and in every test
in the repo. Tests are where people copy patterns from, so they stay covered; the four
real uses carry line-scoped disables with a reason.

* fix(terminal): keep render-desync capture ids inside main's 120-char cap

createCaptureId builds `${Date.now()}-${panePart}-${nonce}`. A real paneKey is
`${tabId}:${leafId}` — two UUIDs, 73 chars after sanitizing — so with a 36-char UUID
nonce the id is 124 chars and main rejects it with 'Invalid render-desync capture id'.
persistHealedReference swallows that into console.error, so it shows up as diagnostics
that silently never appear.

This was already broken on the desktop app, where randomUUID is available; routing the
non-secure path through the same generator would have made it unconditional, including
on the plain-HTTP web client this branch exists to repair.

Bound the pane part rather than the nonce: keep the trailing 40 chars, which is the
whole leaf id (the identifying half, unique on its own) and drop the tab-id prefix, so
ids stay unique and traceable at 91 chars. The 120-char contract now lives in
src/shared next to the IPC args, imported by both sides, so the renderer cannot mint an
id main will reject without the test noticing.

* test(web): cover the whole store graph and the Vault token without randomUUID

The reported stack was the store chunk, not two named modules, so the repro test now
evaluates the store root under the stubbed non-secure crypto. Any new import-time
secure-context call anywhere in that graph fails here, not just the one this branch
removed.

Also ports the request-token regression from #20465, the one piece of coverage the
competing branches for this bug contributed that this one lacked. Both cases fail with
"randomUUID is not a function" when their production change is reverted.

* test(web): restore the real crypto.randomUUID after the non-secure Vault case

randomUUID lives on Crypto.prototype, so stubbing it as an own property of
globalThis.crypto left the restore branch with an undefined descriptor and a
leaked own `randomUUID: undefined`. Swap the whole crypto own property instead,
through one shared stub the repro suite already needed.

---------

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
mmarabel
2026-09-25 15:01:30 -07:00
committed by GitHub
co-authored by Neil
parent 2796a3ac15
commit 7d2c399329
40 changed files with 255 additions and 132 deletions
+20 -1
View File
@@ -143,7 +143,26 @@
{
"files": ["src/renderer/src/**/*.{ts,tsx}"],
"rules": {
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "error"
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "error",
"no-restricted-properties": [
"error",
{
"property": "randomUUID",
"message": "crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP), which white-screens the app. Use createBrowserUuid() from '@/lib/browser-uuid'."
}
]
}
},
{
"files": ["src/shared/**/*.ts"],
"rules": {
"no-restricted-properties": [
"error",
{
"property": "randomUUID",
"message": "src/shared is compiled into the web bundle, where crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP). Use createNonSecureContextUuid() from './non-secure-context-uuid', or import randomUUID from 'node:crypto' in main-only code."
}
]
}
},
{
@@ -1,9 +1,10 @@
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { app, ipcMain } from 'electron'
import type {
WriteTerminalRenderDesyncEvidenceArgs,
WriteTerminalRenderDesyncEvidenceResult
import {
TERMINAL_RENDER_DESYNC_CAPTURE_ID_PATTERN,
type WriteTerminalRenderDesyncEvidenceArgs,
type WriteTerminalRenderDesyncEvidenceResult
} from '../../shared/terminal-render-desync-evidence'
import { isTrustedUIRenderer } from './ui'
@@ -12,7 +13,6 @@ const MAX_PNG_DATA_URL_BYTES = 40 * 1024 * 1024
const MAX_METADATA_BYTES = 1024 * 1024
const MAX_CAPTURE_DIRECTORIES = 4
const MAX_EVIDENCE_BYTES = 96 * 1024 * 1024
const CAPTURE_ID_PATTERN = /^[a-zA-Z0-9_-]{1,120}$/
const PNG_DATA_URL_PREFIX = 'data:image/png;base64,'
let evidenceWriteQueue = Promise.resolve()
@@ -41,7 +41,7 @@ export async function writeTerminalRenderDesyncEvidence(
userDataPath: string,
args: WriteTerminalRenderDesyncEvidenceArgs
): Promise<WriteTerminalRenderDesyncEvidenceResult> {
if (!CAPTURE_ID_PATTERN.test(args.captureId)) {
if (!TERMINAL_RENDER_DESYNC_CAPTURE_ID_PATTERN.test(args.captureId)) {
throw new Error('Invalid render-desync capture id')
}
if (args.phase !== 'corrupt' && args.phase !== 'healed') {
@@ -33,6 +33,7 @@ import type { AutomationHostTarget } from './automation-host-client'
import type { AutomationAuthorityChangeReason } from './automation-host-invalidation'
import type { AutomationSaveContext } from './automation-save-context'
import { automationAuthorityCatalogKey } from './automation-host-catalog-types'
import { createBrowserUuid } from '@/lib/browser-uuid'
export type AutomationMoveOperationContext = {
automationDispatchContext: AutomationDispatchContext
@@ -165,7 +166,7 @@ export async function moveAutomationToDestination(
}
const operationKey = `${source.id}:${target.entry.stableKey}`
const creationKey = context.moveCreationKeysRef.current.get(operationKey) ?? crypto.randomUUID()
const creationKey = context.moveCreationKeysRef.current.get(operationKey) ?? createBrowserUuid()
context.moveCreationKeysRef.current.set(operationKey, creationKey)
const created = toDispatchResult(
await createAutomationAtDestination(
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { createBrowserUuid } from '@/lib/browser-uuid'
// Decodes the Android H.264 stream (scrcpy access units forwarded over the
// emulator:videoStream* IPC) with WebCodecs and paints it to a <canvas>. The
@@ -32,7 +33,7 @@ const H264_CODEC = 'avc1.640028'
type StreamSize = { width: number; height: number }
function newVideoStreamId(): string {
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`
return createBrowserUuid()
}
export function useEmulatorVideoStream(
@@ -4,6 +4,7 @@ import {
type StructuredAgentSessionOutboxEntry
} from '../../../../shared/structured-agent-session-outbox'
import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation'
import { createBrowserUuid } from '@/lib/browser-uuid'
const OUTBOX_PREFIX = 'orca:desktopStructuredAgentSessionOutbox:v1:'
@@ -103,7 +104,7 @@ export function enqueueStructuredAgentSessionLaunchPrompt(
): StructuredAgentSessionOutboxEntry | null {
const entry = {
...createStructuredAgentSessionOutboxEntry({
clientMessageId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()),
clientMessageId: createStructuredAgentSessionOperationId(createBrowserUuid),
sessionId,
text,
attachments: [],
@@ -233,6 +233,7 @@ describe('useStructuredAgentSessionOutbox', () => {
it.each(['agent_session_operation_conflict', 'agent_session_operation_expired'] as const)(
'rotates a send operation after %s',
async (code) => {
// oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test
vi.mocked(globalThis.crypto.randomUUID)
.mockReturnValueOnce('11111111-1111-4111-8111-111111111111')
.mockReturnValueOnce('22222222-2222-4222-8222-222222222222')
@@ -591,6 +592,7 @@ describe('useStructuredAgentSessionOutbox', () => {
})
it('retries an unknown head and advances a queued tail', async () => {
// oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test
vi.mocked(globalThis.crypto.randomUUID)
.mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
.mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')
@@ -653,6 +655,7 @@ describe('useStructuredAgentSessionOutbox', () => {
})
it('rotates a history-rejected unknown head so the queued tail can advance', async () => {
// oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test
vi.mocked(globalThis.crypto.randomUUID)
.mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
.mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')
@@ -713,6 +716,7 @@ describe('useStructuredAgentSessionOutbox', () => {
})
it('rotates the id after a refused write and delivers the message exactly once', async () => {
// oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test
vi.mocked(globalThis.crypto.randomUUID)
.mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
.mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')
@@ -16,9 +16,10 @@ import {
readMountedStructuredAgentSessionOutbox
} from './structured-agent-session-outbox-dispatch'
import { getStructuredAgentLaunchPromptDispatch } from '@/lib/structured-agent-session-launch-prompt'
import { createBrowserUuid } from '@/lib/browser-uuid'
export function structuredSessionOperationId(): string {
return createStructuredAgentSessionOperationId(() => crypto.randomUUID())
return createStructuredAgentSessionOperationId(createBrowserUuid)
}
const UNCONFIRMED_PROBE_BASE_DELAY_MS = 1_000
@@ -13,6 +13,7 @@ import {
useAiVaultSessionRefresh
} from './ai-vault-session-refresh'
import { DEFAULT_AI_VAULT_SESSION_LIMIT, type AiVaultSessionLimit } from './ai-vault-session-limit'
import { withNonSecureContextCrypto } from '@/lib/non-secure-context-crypto-stub'
const EMPTY_RESULT: AiVaultListResult = {
sessions: [],
@@ -801,3 +802,28 @@ describe('useAiVaultSessionRefresh in-app agent session behavior', () => {
expect(listSessionsMock.mock.calls.length).toBe(callsWhileHealthy)
})
})
// Regression for #18096: over plain HTTP the browser hides crypto.randomUUID, so minting
// the request token with a raw call threw during render and the panel showed "The right
// sidebar hit an error". The fallback must still be a well-formed v4 UUID.
describe('useAiVaultSessionRefresh in a non-secure context', () => {
it('mints a request token when crypto.randomUUID is unavailable', async () => {
await withNonSecureContextCrypto(async () => {
await renderHook()
await flushMicrotasks()
expect(lastCallArgs()).toMatchObject({
requestToken: expect.stringMatching(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
)
})
})
})
// Guards the stub itself: an own-property stub of randomUUID is unrestorable, so a
// leaky teardown would silently strip the real method from every later test in the file.
it('leaves the real crypto.randomUUID in place afterwards', () => {
// oxlint-disable-next-line no-restricted-properties -- asserting the restore this case exists for
expect(typeof globalThis.crypto.randomUUID).toBe('function')
})
})
@@ -21,6 +21,7 @@ import {
readCachedAiVaultSessionResult,
resetAiVaultSessionResultCacheForTest
} from './ai-vault-session-result-cache'
import { createBrowserUuid } from '@/lib/browser-uuid'
// In-app session creation bypasses the cache so the new session appears promptly.
// Keep the budget at module scope so tab remounts cannot amplify full scans.
@@ -94,7 +95,7 @@ export function useAiVaultSessionRefresh(
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const requestTokenRef = useRef<string>(undefined!)
requestTokenRef.current ??= crypto.randomUUID()
requestTokenRef.current ??= createBrowserUuid()
const refreshIdRef = useRef(0)
const refreshInFlightRef = useRef(false)
const pendingRefreshRef = useRef(false)
@@ -17,6 +17,7 @@ import WorktreeVisibilitySourceList, {
type WorktreeVisibilitySourceRow
} from '../sidebar/WorktreeVisibilitySourceList'
import { translate } from '@/i18n/i18n'
import { createBrowserUuid } from '@/lib/browser-uuid'
type Props = {
settings: GlobalSettings
@@ -105,7 +106,7 @@ export function GlobalWorktreeVisibilitySourcesSetting({
if (customSources.length >= MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES) {
return 'limit'
}
const id = crypto.randomUUID().replaceAll('-', '')
const id = createBrowserUuid().replaceAll('-', '')
const candidate = normalizeCustomWorktreeVisibilitySources([{ id, rootPath }])?.[0]
if (!candidate) {
return 'invalid-path'
@@ -23,6 +23,7 @@ import {
type OpenInAppPreset
} from '@/lib/open-in-app-catalog'
import { translate } from '@/i18n/i18n'
import { createBrowserUuid } from '@/lib/browser-uuid'
type OpenInMenuSettingProps = {
applications: OpenInApplication[] | undefined
@@ -36,9 +37,7 @@ type OpenInApplicationsDraftState = {
function createOpenInApplication(): OpenInApplication {
return {
id:
globalThis.crypto?.randomUUID?.() ??
`open-in-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
id: createBrowserUuid(),
label: '',
command: ''
}
@@ -56,6 +56,7 @@ import {
shouldUseGlobalWorktreeVisibility
} from './worktree-visibility-use-global'
import { createWorktreeVisibilitySourceMutation } from './worktree-visibility-source-mutation'
import { createBrowserUuid } from '@/lib/browser-uuid'
export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
const activeModal = useAppStore((s) => s.activeModal)
@@ -277,7 +278,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
if ((existing?.length ?? 0) >= MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES) {
return 'limit'
}
const id = crypto.randomUUID().replaceAll('-', '')
const id = createBrowserUuid().replaceAll('-', '')
const candidate = normalizeCustomWorktreeVisibilitySources([{ id, rootPath }])?.[0]
if (!candidate) {
return 'invalid-path'
@@ -1,3 +1,5 @@
import { createBrowserUuid } from '@/lib/browser-uuid'
export type WorktreeSnapshotPruneBatch = {
batchId: string
finish: () => Promise<void>
@@ -14,7 +16,7 @@ export function beginWorktreeSnapshotPruneBatch(): Promise<WorktreeSnapshotPrune
if (typeof begin !== 'function' || typeof record !== 'function' || typeof finish !== 'function') {
return null
}
const batchId = crypto.randomUUID()
const batchId = createBrowserUuid()
return begin({ batchId })
.then(() => ({ batchId, finish: () => finish({ batchId }) }))
.catch((error: unknown) => {
@@ -24,6 +24,7 @@ import { translate } from '@/i18n/i18n'
import { checklistItemsFromVersion } from './skill-package-checklist-items'
import { summarizeSkillInstallRisk } from './skill-package-install-risk'
import { retryableSkillIds } from './skill-bundle-retry-selection'
import { createBrowserUuid } from '@/lib/browser-uuid'
type BundleVersion = SkillCloudVersion & {
manifest: Extract<SkillCloudVersion['manifest'], { skills: unknown }>
@@ -169,7 +170,7 @@ export function SkillBundleInstallFlow(props: {
return
}
}
const operationId = crypto.randomUUID()
const operationId = createBrowserUuid()
installProgress.begin(operationId)
const operation = await window.api.skills.installBundleShare({
shareId: props.shareId,
@@ -26,6 +26,7 @@ import { translate } from '@/i18n/i18n'
import { resolveSkillShareForInstall } from './skill-warning-preview-gate'
import { useSkillInstallRisk } from './use-skill-install-risk'
import { SkillInstallDialogFooter } from './SkillInstallDialogFooter'
import { createBrowserUuid } from '@/lib/browser-uuid'
export function SkillInstallDialog({
open,
@@ -193,7 +194,7 @@ export function SkillInstallDialog({
return
}
}
const operationId = crypto.randomUUID()
const operationId = createBrowserUuid()
installProgress.begin(operationId)
const operation = await window.api.skills.installShare({
shareId: preview.shareId,
@@ -17,6 +17,7 @@ import {
} from './skill-managed-install-groups'
import { translate } from '@/i18n/i18n'
import { SkillInstallManagementDialogContent } from './SkillInstallManagementDialogContent'
import { createBrowserUuid } from '@/lib/browser-uuid'
export function SkillInstallManagementDialog({
open,
@@ -158,7 +159,7 @@ export function SkillInstallManagementDialog({
setBusy(true)
setError(null)
setNotice(null)
const operationId = crypto.randomUUID()
const operationId = createBrowserUuid()
installProgress.begin(operationId)
try {
const version = details?.versions.find((candidate) => candidate.versionId === versionId)
@@ -22,6 +22,7 @@ import {
skillDeletePlacementSummary,
skillDeleteRetainedSourceLines
} from './skill-delete-copy'
import { createBrowserUuid } from '@/lib/browser-uuid'
export type SkillDeleteFlow = {
/** False while the target is unresolved or the host predates the capability. */
@@ -38,7 +39,7 @@ export type SkillDeleteFlow = {
function toRequest(skills: readonly DiscoveredSkill[]): SkillDeleteRequest {
return {
operationId: crypto.randomUUID(),
operationId: createBrowserUuid(),
skills: skills.map((skill) => ({
id: skill.id,
directoryPath: skill.directoryPath,
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { TERMINAL_RENDER_DESYNC_CAPTURE_ID_PATTERN } from '../../../../shared/terminal-render-desync-evidence'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { createCaptureId } from './terminal-render-desync-evidence-persistence'
describe('createCaptureId', () => {
// A real paneKey is two UUIDs joined by ':'. Unbounded, the id ran 124 chars and main
// rejected every capture with 'Invalid render-desync capture id'.
it('stays inside the id main will accept for a realistic paneKey', () => {
const paneKey = makePaneKey(createBrowserUuid(), createBrowserUuid())
expect(createCaptureId(paneKey)).toMatch(TERMINAL_RENDER_DESYNC_CAPTURE_ID_PATTERN)
})
it('keeps the leaf id so a capture is still traceable to its pane', () => {
const leafId = createBrowserUuid()
expect(createCaptureId(makePaneKey(createBrowserUuid(), leafId))).toContain(leafId)
})
it('does not collide for repeated captures of the same pane', () => {
const paneKey = makePaneKey(createBrowserUuid(), createBrowserUuid())
expect(createCaptureId(paneKey)).not.toBe(createCaptureId(paneKey))
})
})
@@ -1,4 +1,5 @@
import type { SentinelEvidence } from './terminal-render-desync-sentinel'
import { createBrowserUuid } from '@/lib/browser-uuid'
/**
* Durable persistence for render-desync captures, split from the sentinel so
@@ -59,8 +60,13 @@ export async function persistHealedReference(
}
}
/** Why: a real paneKey is `${tabId}:${leafId}` — two UUIDs, 73 chars — which pushes the full
* id past main's 120-char cap, so every capture was rejected. The trailing leaf id is the
* identifying half, and the UUID nonce already guarantees uniqueness. */
const MAX_CAPTURE_ID_PANE_PART_LENGTH = 40
export function createCaptureId(paneKey: string): string {
const panePart = paneKey.replace(/[^a-zA-Z0-9_-]/g, '-')
const nonce = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)
const panePart = paneKey.replace(/[^a-zA-Z0-9_-]/g, '-').slice(-MAX_CAPTURE_ID_PANE_PART_LENGTH)
const nonce = createBrowserUuid()
return `${Date.now()}-${panePart}-${nonce}`
}
@@ -6,6 +6,7 @@ import {
selectWorkspaceCleanupGitEvidenceTargets,
WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS
} from './workspace-cleanup-git-evidence'
import { createBrowserUuid } from '@/lib/browser-uuid'
export type WorkspaceCleanupGitEvidenceState = {
/** Focused re-scan results, keyed by host-qualified identity. */
@@ -79,7 +80,7 @@ export function useWorkspaceCleanupGitEvidence({
// would silently drop the overflow ids while marking them attempted.
const worktreeIds = queueRef.current.slice(0, WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS)
queueRef.current = queueRef.current.slice(worktreeIds.length)
const scanId = crypto.randomUUID()
const scanId = createBrowserUuid()
activeScanIdRef.current = scanId
for (const worktreeId of worktreeIds) {
queuedRef.current.delete(worktreeId)
@@ -1,4 +1,5 @@
import type { WorkspaceCleanupBackgroundRemovalArgs } from './workspace-cleanup-background-removal'
import { createBrowserUuid } from '@/lib/browser-uuid'
export function createWorkspaceCleanupSnapshotPruneBatch():
| WorkspaceCleanupBackgroundRemovalArgs['snapshotPruneBatch']
@@ -9,7 +10,7 @@ export function createWorkspaceCleanupSnapshotPruneBatch():
if (typeof begin !== 'function' || typeof record !== 'function' || typeof finish !== 'function') {
return undefined
}
const batchId = crypto.randomUUID()
const batchId = createBrowserUuid()
return {
batchId,
begin: () => begin({ batchId }),
+3 -28
View File
@@ -1,28 +1,3 @@
export function createBrowserUuid(): string {
const cryptoApi = globalThis.crypto
if (typeof cryptoApi?.randomUUID === 'function') {
return cryptoApi.randomUUID()
}
const bytes = new Uint8Array(16)
if (typeof cryptoApi?.getRandomValues === 'function') {
cryptoApi.getRandomValues(bytes)
} else {
// Why: LAN web clients can run in non-secure browser contexts where
// randomUUID is hidden. These are local UI IDs, not auth credentials.
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256)
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
return bytesToUuid(bytes)
}
function bytesToUuid(bytes: Uint8Array): string {
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('')}`
}
// Renderer-facing name for the shared generator. A re-export, so renderer code keeps one
// obvious import and src/shared keeps the single implementation.
export { createNonSecureContextUuid as createBrowserUuid } from '../../../shared/non-secure-context-uuid'
@@ -21,6 +21,7 @@ import {
resolveWebSessionVisibleTabId
} from '@/runtime/web-session-focus-intent'
import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/local-structured-session-owner'
import { createBrowserUuid } from '@/lib/browser-uuid'
export type StructuredAgentSessionLaunchIntent = {
sessionId: string
@@ -105,7 +106,7 @@ export function createStructuredAgentSessionLaunchIntent(
agent: AgentSessionHandleProvider,
resumeFrom?: StructuredAgentSessionResumeSource
): StructuredAgentSessionLaunchIntent {
const sessionId = createStructuredAgentSessionId(agent, () => crypto.randomUUID())
const sessionId = createStructuredAgentSessionId(agent, createBrowserUuid)
return buildStructuredAgentSessionLaunchIntent(worktreeId, agent, sessionId, resumeFrom)
}
@@ -132,7 +133,7 @@ function buildStructuredAgentSessionLaunchIntent(
worktree: toRuntimeWorktreeSelector(worktreeId),
agent,
...(resumeFrom ? { resumeFrom } : {}),
randomUuid: () => crypto.randomUUID()
randomUuid: createBrowserUuid
}),
...launchSeedOptions(state, agent)
}
@@ -0,0 +1,29 @@
/**
* Test-only shape of `globalThis.crypto` on a plain-HTTP origin: getRandomValues
* survives, the secure-context-only members do not.
*
* Swapping the whole `crypto` own property is the only reversible way to do this.
* `randomUUID` lives on `Crypto.prototype`, so stubbing it as an own property of
* `globalThis.crypto` leaves nothing to restore and leaks into the rest of the file.
*/
/** The crypto object a browser exposes on a non-secure origin. */
export function createNonSecureContextCrypto(secureCrypto: Crypto = globalThis.crypto): {
getRandomValues: Crypto['getRandomValues']
} {
return { getRandomValues: secureCrypto.getRandomValues.bind(secureCrypto) }
}
/** Runs `body` with the non-secure crypto shape installed, restoring the real one after. */
export async function withNonSecureContextCrypto<T>(body: () => Promise<T> | T): Promise<T> {
const secureCrypto = globalThis.crypto
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: createNonSecureContextCrypto(secureCrypto)
})
try {
return await body()
} finally {
Object.defineProperty(globalThis, 'crypto', { configurable: true, value: secureCrypto })
}
}
@@ -3,18 +3,15 @@
* hides crypto.randomUUID and crypto.subtle (secure-context-only). This test
* recreates that exact global shape and drives the real call sites.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createNonSecureContextCrypto } from './non-secure-context-crypto-stub'
const realCrypto = globalThis.crypto
beforeEach(() => {
// Match a non-secure browser context: getRandomValues stays, the
// secure-context-only members are undefined.
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: {
getRandomValues: realCrypto.getRandomValues.bind(realCrypto)
}
value: createNonSecureContextCrypto(realCrypto)
})
})
@@ -24,7 +21,9 @@ afterEach(() => {
describe('non-secure context (plain HTTP LAN web client)', () => {
it('crypto.randomUUID is undefined, like the browser reports', () => {
// oxlint-disable-next-line no-restricted-properties -- asserting the absence this suite exists for
expect((globalThis.crypto as Crypto).randomUUID).toBeUndefined()
// oxlint-disable-next-line no-restricted-properties -- asserting the absence this suite exists for
expect(() => (globalThis.crypto as Crypto).randomUUID()).toThrow()
})
@@ -45,11 +44,33 @@ describe('non-secure context (plain HTTP LAN web client)', () => {
})()
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: { getRandomValues: realCrypto.getRandomValues.bind(realCrypto) }
value: createNonSecureContextCrypto(realCrypto)
})
expect(await hashOrcaHookScript('echo hi')).toBe(secureHash)
})
// Regression for #19667: the store builds this sequencer at module load, so a throw here
// white-screened the whole Remote Web client before anything painted.
it('loads the renderer agent-status authority and its store slice', async () => {
vi.resetModules()
const { rendererAgentStatusObservations } = await import('./renderer-agent-status-observations')
const { createAgentStatusAuthorityActions } =
await import('../store/slices/agent-status-authority-actions')
expect(typeof createAgentStatusAuthorityActions).toBe('function')
expect(rendererAgentStatusObservations.getAuthorityId()).toMatch(
/^renderer:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
)
})
// Naming two modules only pins today's crash. The reported stack was the whole store
// chunk, so evaluate the store root: any new import-time secure-context call anywhere in
// that graph fails here.
it('evaluates the whole store graph', async () => {
vi.resetModules()
const { useAppStore } = await import('@/store')
expect(typeof useAppStore.getState).toBe('function')
})
it('createBrowserUuid does not throw when randomUUID is missing', async () => {
const { createBrowserUuid } = await import('./browser-uuid')
expect(createBrowserUuid()).toMatch(
@@ -1,25 +1,7 @@
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
import { createBrowserUuid } from '../browser-uuid'
// Why: Electron/test runtimes can lack crypto.randomUUID. The fallback still
// produces a UUID-shaped v4 id so pane-key validation remains deterministic.
export function mintStablePaneId(): TerminalLeafId {
const cryptoApi = globalThis.crypto as Crypto | undefined
if (cryptoApi?.randomUUID) {
return cryptoApi.randomUUID() as TerminalLeafId
}
const bytes = new Uint8Array(16)
if (cryptoApi?.getRandomValues) {
cryptoApi.getRandomValues(bytes)
} else {
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256)
}
}
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)}` as TerminalLeafId
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: createBrowserUuid always returns a lowercase v4 UUID, the TerminalLeafId shape.
return createBrowserUuid() as TerminalLeafId
}
@@ -9,6 +9,7 @@ import {
structuredAgentSessionPayloadFingerprint
} from '../../../shared/structured-agent-session-mutation'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { createBrowserUuid } from '@/lib/browser-uuid'
import {
StructuredAgentSessionLaunchCancelledError,
type StructuredAgentLaunchReceipt
@@ -87,7 +88,7 @@ async function setLaunchOption(
>({ kind: 'local' }, 'agentSession.setOption', {
envelope: {
sessionId,
clientOperationId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()),
clientOperationId: createStructuredAgentSessionOperationId(createBrowserUuid),
expectedRuntimeFence: fence,
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
method: 'agentSession.setOption',
@@ -13,6 +13,7 @@ import {
type StructuredAgentSessionLaunchPromptMutation
} from '@/components/native-chat/structured-agent-session-outbox-storage'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { createBrowserUuid } from '@/lib/browser-uuid'
export type StructuredPromptDeliveryResult = {
delivered: boolean
@@ -112,7 +113,7 @@ async function dispatchStructuredLaunchPrompt(
requeueStructuredAgentSessionSendRefusal(
current,
result.refusal.code,
() => createStructuredAgentSessionOperationId(() => crypto.randomUUID()),
() => createStructuredAgentSessionOperationId(createBrowserUuid),
entry.lastAttemptAt !== null
)
)
@@ -16,6 +16,7 @@ import {
getTabIdFromPaneKey,
isRecentlyClosedAgentStatusTab
} from './agent-status-pane-key-tab-binding'
import { createBrowserUuid } from '@/lib/browser-uuid'
export function createAgentStatusAuthorityActions(
runtime: AgentStatusRuntime
@@ -31,7 +32,7 @@ export function createAgentStatusAuthorityActions(
scheduleAgentStatusFreshness: () => freshness.schedule(),
retireAgentPaneAuthority: (paneKey, options) => {
const retirementId = crypto.randomUUID()
const retirementId = createBrowserUuid()
const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey)
const previousRetirement = get().recentlyRetiredAgentStatusPaneKeys[ownerPaneKey]
const retiredPaneKeys = [
+2 -8
View File
@@ -35,6 +35,7 @@ import {
import * as ownerHydration from './settings-owner-hydration-publication'
import { persistVisibilityAwareSettings } from './worktree-visibility-settings-write'
import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host'
import { createBrowserUuid } from '@/lib/browser-uuid'
export type SettingsSlice = SettingsSearchState & {
settings: GlobalSettings | null
@@ -57,13 +58,6 @@ function normalizeRuntimeEnvironmentId(value: string | null | undefined): string
return trimmed ? trimmed : null
}
function createOpenInApplicationId(): string {
return (
globalThis.crypto?.randomUUID?.() ??
`open-in-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
)
}
function normalizeSettingsUpdates(
updates: Partial<GlobalSettings>,
currentSettings: GlobalSettings | null
@@ -97,7 +91,7 @@ function normalizeSettingsUpdates(
}
if ('openInApplications' in updates) {
sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications, {
createId: createOpenInApplicationId
createId: createBrowserUuid
})
}
if ('disabledTuiAgents' in updates) {
@@ -34,6 +34,7 @@ import {
hasValidWorkspaceCleanupUnverifiedConsent,
hasWorkspaceCleanupRiskEscalated
} from './workspace-cleanup-preflight-failures'
import { createBrowserUuid } from '@/lib/browser-uuid'
/** Distinct from every ExecutionHostId, so a hostless row cannot alias one. */
const UNQUALIFIED_HOST_BUCKET = Symbol('unqualified-cleanup-host')
@@ -190,7 +191,7 @@ export async function preflightWorkspaceCleanupCandidates(
const chunk = worktreeIds.slice(start, start + WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT)
const scan = await window.api.workspaceCleanup.scan({
worktreeIds: [...chunk],
scanId: crypto.randomUUID(),
scanId: createBrowserUuid(),
refreshActivity: true
})
const enriched = await enrich(scan.candidates, getState())
@@ -22,6 +22,7 @@ import {
finalizeWorkspaceCleanupScan,
isLatestWorkspaceCleanupScan
} from './workspace-cleanup-scan-progress'
import { createBrowserUuid } from '@/lib/browser-uuid'
type SetState = (
partial: Partial<AppState> | ((state: AppState) => Partial<AppState>),
@@ -54,7 +55,7 @@ export async function scanWorkspaceCleanup(
],
// Broad scan identity belongs to this store request; caller-provided IDs
// are reserved for focused scans and can collide across refresh variants.
scanId: crypto.randomUUID()
scanId: createBrowserUuid()
}
const scanKey = getWorkspaceCleanupScanKey(scanArgs)
@@ -27,6 +27,7 @@ import {
type WorkspaceCleanupRemoveOptions,
type WorkspaceCleanupRemoveResult
} from './workspace-cleanup-removal'
import { createBrowserUuid } from '@/lib/browser-uuid'
export type { WorkspaceCleanupFailure, WorkspaceCleanupRemoveOptions, WorkspaceCleanupRemoveResult }
export { enrichWorkspaceCleanupCandidates, WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY }
@@ -150,7 +151,7 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
if (existing.has(identity)) {
return null
}
const attemptId = crypto.randomUUID()
const attemptId = createBrowserUuid()
existing.set(identity, attemptId)
return attemptId
},
@@ -1,4 +1,5 @@
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { createBrowserUuid } from '@/lib/browser-uuid'
/**
* Drop a removed row from the local persisted cleanup snapshots.
@@ -16,7 +17,7 @@ export async function recordRemovedWorktreeSnapshotPrune(args: {
// Why: an unknown batch id degrades to an immediate one-off prune. The id
// must stay bounded — main rejects batch ids over 128 chars, so it cannot
// embed the unbounded worktreeId.
batchId: args.snapshotPruneBatchId ?? `single-removal:${crypto.randomUUID()}`,
batchId: args.snapshotPruneBatchId ?? `single-removal:${createBrowserUuid()}`,
worktreeId: args.worktreeId,
...(args.hostId ? { executionHostId: args.hostId } : {})
})
+3 -1
View File
@@ -7,6 +7,8 @@
//
// NOTHING READS IT YET. It is stamped so consumers can be migrated one at a time.
import { createNonSecureContextUuid } from './non-secure-context-uuid'
/** Where the evidence for a status row came from — the ingress, not the transport.
* A hook event relayed over SSH is still `hook`; the relay is a carrier. */
export const AGENT_STATUS_OBSERVATION_ORIGINS = [
@@ -195,5 +197,5 @@ export class AgentStatusObservationSequencer {
* authority's revision counter starts over, so its observations must not be comparable
* with the ones it emitted before (including any rehydrated from disk). */
export function createAgentStatusAuthorityId(role: string): string {
return `${role}:${globalThis.crypto.randomUUID()}`
return `${role}:${createNonSecureContextUuid()}`
}
+3 -19
View File
@@ -3,6 +3,7 @@ import type {
ProjectGroupImportMode,
ProjectGroupImportResult
} from './project-group-types'
import { createNonSecureContextUuid } from './non-secure-context-uuid'
export const NESTED_REPO_TELEMETRY_MAX_REPO_COUNT = 500
@@ -117,25 +118,8 @@ export function shouldEmitNestedRepoImportSubmitTelemetry(args: {
}
export function createNestedRepoTelemetryAttemptId(): string {
const cryptoApi = globalThis.crypto
if (typeof cryptoApi?.randomUUID === 'function') {
return cryptoApi.randomUUID()
}
const bytes = new Uint8Array(16)
if (typeof cryptoApi?.getRandomValues === 'function') {
cryptoApi.getRandomValues(bytes)
} else {
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Math.floor(Math.random() * 256)
}
}
// Why: keep the fallback schema-compatible without deriving from any stable repo input.
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
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('')}`
// Why a UUID: the attempt id must not derive from any stable repo input.
return createNonSecureContextUuid()
}
export function buildNestedRepoScanTelemetry(args: {
+38
View File
@@ -0,0 +1,38 @@
/**
* The one v4 UUID generator that is safe everywhere Orca's code runs.
*
* Why: browsers hide `crypto.randomUUID` outside a secure context, so a renderer served
* over plain HTTP (Remote Web on a LAN/Tailscale address) throws on any direct call — at
* module scope that white-screens the app before it paints. `getRandomValues` stays
* available there, and Node/Electron main satisfy the first branch, so this is
* runtime-agnostic rather than browser-specific.
*/
export function createNonSecureContextUuid(): string {
const cryptoApi = globalThis.crypto
// oxlint-disable-next-line no-restricted-properties -- the sanctioned escape hatch: the one guarded call every other site routes through.
if (typeof cryptoApi?.randomUUID === 'function') {
// oxlint-disable-next-line no-restricted-properties -- the sanctioned escape hatch (see above).
return cryptoApi.randomUUID()
}
const bytes = new Uint8Array(16)
if (typeof cryptoApi?.getRandomValues === 'function') {
cryptoApi.getRandomValues(bytes)
} else {
// Why: these are local UI and correlation ids, not auth credentials.
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256)
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
return bytesToUuid(bytes)
}
function bytesToUuid(bytes: Uint8Array): string {
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('')}`
}
+2 -9
View File
@@ -1,17 +1,10 @@
import { normalizeExecutionHostId } from './execution-host'
import type { ProjectGroup, ProjectGroupCreatedFrom } from './project-group-types'
import type { Repo } from './repo-types'
import { createNonSecureContextUuid } from './non-secure-context-uuid'
export const UNGROUPED_PROJECT_GROUP_KEY = 'project-group:ungrouped'
function createProjectGroupId(): string {
const randomUUID = globalThis.crypto?.randomUUID
if (randomUUID) {
return randomUUID.call(globalThis.crypto)
}
return `project-group-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
export function normalizeProjectGroupName(name: string, fallback = 'Untitled group'): string {
const trimmed = name.trim()
return trimmed.length > 0 ? trimmed : fallback
@@ -28,7 +21,7 @@ export function createProjectGroup(input: {
}): ProjectGroup {
const now = input.now ?? Date.now()
return {
id: createProjectGroupId(),
id: createNonSecureContextUuid(),
name: normalizeProjectGroupName(input.name),
parentPath: input.parentPath ?? null,
connectionId: input.connectionId ?? null,
+2 -5
View File
@@ -6,6 +6,7 @@ import {
type SetupRunnerCommandShell,
type SetupRunnerShell
} from './setup-runner-command'
import { createNonSecureContextUuid } from './non-secure-context-uuid'
const DEFAULT_WAIT_TIMEOUT_SECONDS = 2 * 60 * 60
// Exported so the gate and its tests share one definition.
@@ -28,11 +29,7 @@ export function resolveSetupAgentSequenceLaunchCommand(
}
export function createSetupAgentSequenceNonce(): string {
const cryptoApi = globalThis.crypto
if (typeof cryptoApi?.randomUUID === 'function') {
return cryptoApi.randomUUID()
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
return createNonSecureContextUuid()
}
export function createSequencedSetupAgentCommands(args: {
@@ -1,3 +1,7 @@
/** The capture id becomes a directory name under userData, so main validates it before
* use. Both sides import this so the renderer cannot mint an id main will reject. */
export const TERMINAL_RENDER_DESYNC_CAPTURE_ID_PATTERN = /^[a-zA-Z0-9_-]{1,120}$/
export type TerminalRenderDesyncEvidencePhase = 'corrupt' | 'healed'
export type WriteTerminalRenderDesyncEvidenceArgs = {