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 = {