mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
fix(web): keep Remote Web loading over plain HTTP without crypto.randomUUID (#22516)
* fix(web): keep Remote Web loading over plain HTTP without crypto.randomUUID
Browsers hide crypto.randomUUID outside secure contexts, so Remote Web over
http://<lan-or-tailnet-ip> threw while importing the store and never painted.
createAgentStatusAuthorityId now takes its UUID source (renderer passes
createBrowserUuid, main passes node:crypto randomUUID), and the other
unguarded renderer calls go through createBrowserUuid.
* refactor(renderer): route remaining randomUUID fallbacks through createBrowserUuid
Replaces five hand-rolled crypto?.randomUUID?.() fallbacks (including a copy
of the browser-uuid fallback in mint-stable-pane-id) with createBrowserUuid,
and adds an oxlint no-restricted-properties rule so renderer code cannot call
randomUUID directly again.
* refactor(shared): move the non-secure-context UUID generator into src/shared
The white screen came from src/shared, so the fix belongs there. src/shared had
three hand-rolled copies of the same randomUUID-then-getRandomValues-then-Math.random
ladder (nested-repo-telemetry, project-groups, setup-agent-sequencing) because there
was nothing in that layer to import; createBrowserUuid lived one directory over in
the renderer.
createNonSecureContextUuid() now holds the single implementation, @/lib/browser-uuid
re-exports it under the renderer's existing name so no renderer import site changes,
and the three duplicates call it.
That also lets createAgentStatusAuthorityId go back to one argument. The injected
randomUuid source was justified as keeping browser APIs out of shared code, but this
generator is runtime-agnostic — it works unchanged in Node. Injecting it bought no
layering and made the safe choice a parameter every future caller had to get right,
unguarded: a caller could pass () => globalThis.crypto.randomUUID() and restore the
white screen with lint and tests green.
* fix(lint): ban crypto.randomUUID in src/shared and scope the escape hatch
vite.web.config.ts compiles src/shared straight into the web bundle, but the new
randomUUID ban only covered src/renderer/src — so the exact module that white-screened
the app sat outside the guard it shipped with, and the regression could come back with
a green lint. The override now covers src/shared/**/*.ts too; it costs zero diagnostics
because the duplicates it would have flagged are gone. `import { randomUUID } from
'node:crypto'` is untouched, so main-only shared modules keep working.
Both blanket "off" overrides are gone. no-restricted-properties is keyed by property
name, so the moment a second property joins the renderer block those overrides would
have silently exempted it — in the one file that is the escape hatch, and in every test
in the repo. Tests are where people copy patterns from, so they stay covered; the four
real uses carry line-scoped disables with a reason.
* fix(terminal): keep render-desync capture ids inside main's 120-char cap
createCaptureId builds `${Date.now()}-${panePart}-${nonce}`. A real paneKey is
`${tabId}:${leafId}` — two UUIDs, 73 chars after sanitizing — so with a 36-char UUID
nonce the id is 124 chars and main rejects it with 'Invalid render-desync capture id'.
persistHealedReference swallows that into console.error, so it shows up as diagnostics
that silently never appear.
This was already broken on the desktop app, where randomUUID is available; routing the
non-secure path through the same generator would have made it unconditional, including
on the plain-HTTP web client this branch exists to repair.
Bound the pane part rather than the nonce: keep the trailing 40 chars, which is the
whole leaf id (the identifying half, unique on its own) and drop the tab-id prefix, so
ids stay unique and traceable at 91 chars. The 120-char contract now lives in
src/shared next to the IPC args, imported by both sides, so the renderer cannot mint an
id main will reject without the test noticing.
* test(web): cover the whole store graph and the Vault token without randomUUID
The reported stack was the store chunk, not two named modules, so the repro test now
evaluates the store root under the stubbed non-secure crypto. Any new import-time
secure-context call anywhere in that graph fails here, not just the one this branch
removed.
Also ports the request-token regression from #20465, the one piece of coverage the
competing branches for this bug contributed that this one lacked. Both cases fail with
"randomUUID is not a function" when their production change is reverted.
* test(web): restore the real crypto.randomUUID after the non-secure Vault case
randomUUID lives on Crypto.prototype, so stubbing it as an own property of
globalThis.crypto left the restore branch with an undefined descriptor and a
leaked own `randomUUID: undefined`. Swap the whole crypto own property instead,
through one shared stub the repro suite already needed.
---------
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
//
|
||||
// NOTHING READS IT YET. It is stamped so consumers can be migrated one at a time.
|
||||
|
||||
import { createNonSecureContextUuid } from './non-secure-context-uuid'
|
||||
|
||||
/** Where the evidence for a status row came from — the ingress, not the transport.
|
||||
* A hook event relayed over SSH is still `hook`; the relay is a carrier. */
|
||||
export const AGENT_STATUS_OBSERVATION_ORIGINS = [
|
||||
@@ -195,5 +197,5 @@ export class AgentStatusObservationSequencer {
|
||||
* authority's revision counter starts over, so its observations must not be comparable
|
||||
* with the ones it emitted before (including any rehydrated from disk). */
|
||||
export function createAgentStatusAuthorityId(role: string): string {
|
||||
return `${role}:${globalThis.crypto.randomUUID()}`
|
||||
return `${role}:${createNonSecureContextUuid()}`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ProjectGroupImportMode,
|
||||
ProjectGroupImportResult
|
||||
} from './project-group-types'
|
||||
import { createNonSecureContextUuid } from './non-secure-context-uuid'
|
||||
|
||||
export const NESTED_REPO_TELEMETRY_MAX_REPO_COUNT = 500
|
||||
|
||||
@@ -117,25 +118,8 @@ export function shouldEmitNestedRepoImportSubmitTelemetry(args: {
|
||||
}
|
||||
|
||||
export function createNestedRepoTelemetryAttemptId(): string {
|
||||
const cryptoApi = globalThis.crypto
|
||||
if (typeof cryptoApi?.randomUUID === 'function') {
|
||||
return cryptoApi.randomUUID()
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16)
|
||||
if (typeof cryptoApi?.getRandomValues === 'function') {
|
||||
cryptoApi.getRandomValues(bytes)
|
||||
} else {
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = Math.floor(Math.random() * 256)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: keep the fallback schema-compatible without deriving from any stable repo input.
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'))
|
||||
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
|
||||
// Why a UUID: the attempt id must not derive from any stable repo input.
|
||||
return createNonSecureContextUuid()
|
||||
}
|
||||
|
||||
export function buildNestedRepoScanTelemetry(args: {
|
||||
|
||||
@@ -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('')}`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type SetupRunnerCommandShell,
|
||||
type SetupRunnerShell
|
||||
} from './setup-runner-command'
|
||||
import { createNonSecureContextUuid } from './non-secure-context-uuid'
|
||||
|
||||
const DEFAULT_WAIT_TIMEOUT_SECONDS = 2 * 60 * 60
|
||||
// Exported so the gate and its tests share one definition.
|
||||
@@ -28,11 +29,7 @@ export function resolveSetupAgentSequenceLaunchCommand(
|
||||
}
|
||||
|
||||
export function createSetupAgentSequenceNonce(): string {
|
||||
const cryptoApi = globalThis.crypto
|
||||
if (typeof cryptoApi?.randomUUID === 'function') {
|
||||
return cryptoApi.randomUUID()
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
||||
return createNonSecureContextUuid()
|
||||
}
|
||||
|
||||
export function createSequencedSetupAgentCommands(args: {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/** The capture id becomes a directory name under userData, so main validates it before
|
||||
* use. Both sides import this so the renderer cannot mint an id main will reject. */
|
||||
export const TERMINAL_RENDER_DESYNC_CAPTURE_ID_PATTERN = /^[a-zA-Z0-9_-]{1,120}$/
|
||||
|
||||
export type TerminalRenderDesyncEvidencePhase = 'corrupt' | 'healed'
|
||||
|
||||
export type WriteTerminalRenderDesyncEvidenceArgs = {
|
||||
|
||||
Reference in New Issue
Block a user