From 7d2c399329ffc60dd67c30ca2021ec4fdc8c307f Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:01:30 +0200 Subject: [PATCH] fix(web): keep Remote Web loading over plain HTTP without crypto.randomUUID (#22516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): keep Remote Web loading over plain HTTP without crypto.randomUUID Browsers hide crypto.randomUUID outside secure contexts, so Remote Web over http:// 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 --- .oxlintrc.json | 21 +++++++++- .../ipc/terminal-render-desync-evidence.ts | 10 ++--- .../automation-orca-save-operations.ts | 3 +- .../use-emulator-video-stream.ts | 3 +- ...structured-agent-session-outbox-storage.ts | 3 +- ...e-structured-agent-session-outbox.test.tsx | 4 ++ .../use-structured-agent-session-outbox.ts | 3 +- .../ai-vault-session-refresh.test.ts | 26 +++++++++++++ .../right-sidebar/ai-vault-session-refresh.ts | 3 +- ...GlobalWorktreeVisibilitySourcesSetting.tsx | 3 +- .../components/settings/OpenInMenuSetting.tsx | 5 +-- .../sidebar/WorktreeVisibilityDialog.tsx | 3 +- .../sidebar/worktree-snapshot-prune-batch.ts | 4 +- .../skills/SkillBundleInstallFlow.tsx | 3 +- .../components/skills/SkillInstallDialog.tsx | 3 +- .../skills/SkillInstallManagementDialog.tsx | 3 +- .../skills/use-skill-delete-flow.ts | 3 +- ...render-desync-evidence-persistence.test.ts | 27 +++++++++++++ ...inal-render-desync-evidence-persistence.ts | 10 ++++- .../use-workspace-cleanup-git-evidence.ts | 3 +- .../workspace-cleanup-snapshot-prune-batch.ts | 3 +- src/renderer/src/lib/browser-uuid.ts | 31 ++------------- .../lib/launch-structured-agent-session.ts | 5 ++- .../src/lib/non-secure-context-crypto-stub.ts | 29 ++++++++++++++ .../non-secure-context-crypto.repro.test.ts | 35 +++++++++++++---- .../lib/pane-manager/mint-stable-pane-id.ts | 24 ++---------- ...structured-agent-session-launch-options.ts | 3 +- .../structured-agent-session-launch-prompt.ts | 3 +- .../slices/agent-status-authority-actions.ts | 3 +- src/renderer/src/store/slices/settings.ts | 10 +---- .../workspace-cleanup-removal-targets.ts | 3 +- .../workspace-cleanup-scan-lifecycle.ts | 3 +- .../src/store/slices/workspace-cleanup.ts | 3 +- .../removed-worktree-snapshot-prune.ts | 3 +- src/shared/agent-status-observation.ts | 4 +- src/shared/nested-repo-telemetry.ts | 22 ++--------- src/shared/non-secure-context-uuid.ts | 38 +++++++++++++++++++ src/shared/project-groups.ts | 11 +----- src/shared/setup-agent-sequencing.ts | 7 +--- src/shared/terminal-render-desync-evidence.ts | 4 ++ 40 files changed, 255 insertions(+), 132 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.test.ts create mode 100644 src/renderer/src/lib/non-secure-context-crypto-stub.ts create mode 100644 src/shared/non-secure-context-uuid.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 7de99d4461e..7a0753c06e2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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." + } + ] } }, { diff --git a/src/main/ipc/terminal-render-desync-evidence.ts b/src/main/ipc/terminal-render-desync-evidence.ts index 9ea82246539..dc536faf1f8 100644 --- a/src/main/ipc/terminal-render-desync-evidence.ts +++ b/src/main/ipc/terminal-render-desync-evidence.ts @@ -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 { - 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') { diff --git a/src/renderer/src/components/automations/automation-orca-save-operations.ts b/src/renderer/src/components/automations/automation-orca-save-operations.ts index b12874d0036..914a6b47843 100644 --- a/src/renderer/src/components/automations/automation-orca-save-operations.ts +++ b/src/renderer/src/components/automations/automation-orca-save-operations.ts @@ -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( diff --git a/src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts b/src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts index 35857aa2b7d..1dc9f18593d 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts +++ b/src/renderer/src/components/emulator-pane/use-emulator-video-stream.ts @@ -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 . 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( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index b96fb55ea60..8e9bb342638 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -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: [], diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx index 613a599c2a6..662ed752582 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx @@ -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') diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index e38709b9fed..17a9e2769f3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -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 diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index 19f0d47ce82..567e080360a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index b8787af49f9..9ace8fbd399 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -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(null) const requestTokenRef = useRef(undefined!) - requestTokenRef.current ??= crypto.randomUUID() + requestTokenRef.current ??= createBrowserUuid() const refreshIdRef = useRef(0) const refreshInFlightRef = useRef(false) const pendingRefreshRef = useRef(false) diff --git a/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx b/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx index 1cf9a026cd9..b660b25a3f3 100644 --- a/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx +++ b/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx @@ -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' diff --git a/src/renderer/src/components/settings/OpenInMenuSetting.tsx b/src/renderer/src/components/settings/OpenInMenuSetting.tsx index aa18ec951f1..33db6a1b4bc 100644 --- a/src/renderer/src/components/settings/OpenInMenuSetting.tsx +++ b/src/renderer/src/components/settings/OpenInMenuSetting.tsx @@ -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: '' } diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx index 3ce9a85d275..3eadb6ca0d4 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx @@ -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' diff --git a/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts b/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts index 91e970291fd..f8cbd049cf8 100644 --- a/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts +++ b/src/renderer/src/components/sidebar/worktree-snapshot-prune-batch.ts @@ -1,3 +1,5 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' + export type WorktreeSnapshotPruneBatch = { batchId: string finish: () => Promise @@ -14,7 +16,7 @@ export function beginWorktreeSnapshotPruneBatch(): Promise ({ batchId, finish: () => finish({ batchId }) })) .catch((error: unknown) => { diff --git a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx index 3663ac4e81a..e4115f5ba13 100644 --- a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx +++ b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx @@ -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 @@ -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, diff --git a/src/renderer/src/components/skills/SkillInstallDialog.tsx b/src/renderer/src/components/skills/SkillInstallDialog.tsx index 6ad6edcbedf..8f150708ee9 100644 --- a/src/renderer/src/components/skills/SkillInstallDialog.tsx +++ b/src/renderer/src/components/skills/SkillInstallDialog.tsx @@ -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, diff --git a/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx b/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx index d1a6016655f..7dabac57dfe 100644 --- a/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx +++ b/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx @@ -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) diff --git a/src/renderer/src/components/skills/use-skill-delete-flow.ts b/src/renderer/src/components/skills/use-skill-delete-flow.ts index 654cefedc6d..1ca6f6035de 100644 --- a/src/renderer/src/components/skills/use-skill-delete-flow.ts +++ b/src/renderer/src/components/skills/use-skill-delete-flow.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.test.ts b/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.test.ts new file mode 100644 index 00000000000..2bfe39c2971 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.test.ts @@ -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)) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.ts b/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.ts index 036f677a02b..ee3be4f4eb6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.ts +++ b/src/renderer/src/components/terminal-pane/terminal-render-desync-evidence-persistence.ts @@ -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}` } diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts index ffd41665523..5e14cd962e2 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-git-evidence.ts @@ -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) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts index b633f610d93..9081604e714 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-snapshot-prune-batch.ts @@ -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 }), diff --git a/src/renderer/src/lib/browser-uuid.ts b/src/renderer/src/lib/browser-uuid.ts index 177d05f7ddb..017bfd13f74 100644 --- a/src/renderer/src/lib/browser-uuid.ts +++ b/src/renderer/src/lib/browser-uuid.ts @@ -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' diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 73d274a7bdc..759f5c7d614 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -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) } diff --git a/src/renderer/src/lib/non-secure-context-crypto-stub.ts b/src/renderer/src/lib/non-secure-context-crypto-stub.ts new file mode 100644 index 00000000000..0413be294b8 --- /dev/null +++ b/src/renderer/src/lib/non-secure-context-crypto-stub.ts @@ -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(body: () => Promise | T): Promise { + 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 }) + } +} diff --git a/src/renderer/src/lib/non-secure-context-crypto.repro.test.ts b/src/renderer/src/lib/non-secure-context-crypto.repro.test.ts index f2f1a24c558..ce16d669f16 100644 --- a/src/renderer/src/lib/non-secure-context-crypto.repro.test.ts +++ b/src/renderer/src/lib/non-secure-context-crypto.repro.test.ts @@ -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( diff --git a/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts b/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts index 10f332f49ac..a6ca7cc2d64 100644 --- a/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts +++ b/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts @@ -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 } diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index ea1f1cb18cb..c7d98e848fc 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -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', diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index de7f4733644..18cd3f01cf2 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -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 ) ) diff --git a/src/renderer/src/store/slices/agent-status-authority-actions.ts b/src/renderer/src/store/slices/agent-status-authority-actions.ts index 7382cc020f2..2001f658752 100644 --- a/src/renderer/src/store/slices/agent-status-authority-actions.ts +++ b/src/renderer/src/store/slices/agent-status-authority-actions.ts @@ -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 = [ diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 8189ec5e5f9..55821c10667 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -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, 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) { diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts index 49f8814ca62..efdd165910d 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-targets.ts @@ -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()) diff --git a/src/renderer/src/store/slices/workspace-cleanup-scan-lifecycle.ts b/src/renderer/src/store/slices/workspace-cleanup-scan-lifecycle.ts index 72438524614..d8e21f94cf5 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-scan-lifecycle.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-scan-lifecycle.ts @@ -22,6 +22,7 @@ import { finalizeWorkspaceCleanupScan, isLatestWorkspaceCleanupScan } from './workspace-cleanup-scan-progress' +import { createBrowserUuid } from '@/lib/browser-uuid' type SetState = ( partial: Partial | ((state: AppState) => Partial), @@ -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) diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index 00c58e81b4b..23bff42e763 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -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 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: { diff --git a/src/shared/non-secure-context-uuid.ts b/src/shared/non-secure-context-uuid.ts new file mode 100644 index 00000000000..d9fe33e0459 --- /dev/null +++ b/src/shared/non-secure-context-uuid.ts @@ -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('')}` +} diff --git a/src/shared/project-groups.ts b/src/shared/project-groups.ts index 5b37aadce8c..4cfa87080e2 100644 --- a/src/shared/project-groups.ts +++ b/src/shared/project-groups.ts @@ -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, diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts index 367be99c212..43f22c9bdcd 100644 --- a/src/shared/setup-agent-sequencing.ts +++ b/src/shared/setup-agent-sequencing.ts @@ -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: { diff --git a/src/shared/terminal-render-desync-evidence.ts b/src/shared/terminal-render-desync-evidence.ts index 1621626e187..cc0e292983a 100644 --- a/src/shared/terminal-render-desync-evidence.ts +++ b/src/shared/terminal-render-desync-evidence.ts @@ -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 = {