From af52f6d21c9f23e52d3795fa447a0c378803d885 Mon Sep 17 00:00:00 2001 From: why-tomato Date: Wed, 26 Aug 2026 15:54:01 +0800 Subject: [PATCH 1/2] docs: clarify Generic control ownership --- README.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/README.md b/README.md index 54ebe2f90..f3cbe5466 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,102 @@ broker, template, warm pool, and `SANDBOX_ENV_*` values in the LexVoice reposito This frontend repository only runs the Next.js UI. It does not create, release, or warm sandbox sessions. +### Generic control and Orin deployment boundary + +The current Generic session path is: + +```text +Browser + -> agent-starter-react + -> LiveKit Room + -> Agent Dispatch + -> LexVoice Generic Agent + -> lex-reflex /start + -> LiveKit room_audio and room_video_raw + -> LexVoice Video Processor + -> LiveKit room_video + -> Browser +``` + +The Browser displays the UI, starts and stops the session through the existing +Next.js session flow, connects to LiveKit, and uses the existing Agent Dispatch. +Neither the Browser nor Next.js knows the Jetson IP, connects to Jetson port +`8013`, receives a Jetson heartbeat, or maintains an Endpoint Lease. Do not add +a public Jetson address variable such as `NEXT_PUBLIC_JETSON_IP`, and do not put +the Jetson IP in Next.js runtime configuration or page responses. + +The Jetson address remains in the existing LexVoice Generic environment as +`EDGE_MEDIA_URL`. The LexVoice Generic Agent owns calls to lex-reflex `/start` +and `/stop`. lex-reflex publishes the raw `room_audio` and `room_video_raw` +tracks; the LexVoice Video Processor consumes the raw video and publishes +`room_video` for the Browser. Device registration, endpoint discovery, and +Endpoint Lease design are deferred until the cloud platform is integrated. The +archived `codex/endpoint-connectivity-probe` PR remains a reference for that +future governance work, not part of the current runtime architecture. + +#### Open the cloud frontend from Orin + +An Orin Firefox or Chromium browser can open the frontend through a private +cloud address such as `http://10.2.77.108:4003`. This only establishes Browser +access to the cloud UI; it does not require lex-reflex to know the UI address, +Jetson-to-Next.js heartbeat or IP reporting, or Browser access to lex-reflex. + +Bind Next.js to all cloud-side interfaces rather than only localhost: + +```bash +# Development +pnpm dev --hostname 0.0.0.0 --port 4003 + +# Production +pnpm build +pnpm start --hostname 0.0.0.0 --port 4003 +``` + +Allow the Orin private network to reach cloud TCP port `4003`. The Orin must +also be able to reach the configured LiveKit address and its required WSS, TCP, +and UDP ports. + +Verify the cloud listener locally: + +```bash +curl --noproxy '*' --connect-timeout 5 -I http://127.0.0.1:4003/ +``` + +Verify the route, port, and home page from Orin: + +```bash +ip route get 10.2.77.108 +nc -vz 10.2.77.108 4003 +curl --noproxy '*' --connect-timeout 5 -I http://10.2.77.108:4003/ +``` + +The local and Orin HTTP checks should return `200`. Also request a JavaScript +or CSS asset that actually appears in the returned HTML; this confirms that the +page is not the only reachable resource: + +```bash +FRONTEND_ORIGIN=http://10.2.77.108:4003 +FRONTEND_HTML="$(curl --noproxy '*' --connect-timeout 5 --fail --silent --show-error "$FRONTEND_ORIGIN/")" +FRONTEND_ASSET="$(printf '%s' "$FRONTEND_HTML" | grep -Eo '/_next/static/[^" ]+\.(js|css)' | head -n 1)" +test -n "$FRONTEND_ASSET" +curl --noproxy '*' --connect-timeout 5 --fail --silent --show-error \ + --dump-header - --output /dev/null "$FRONTEND_ORIGIN$FRONTEND_ASSET" +``` + +The asset request should return `200` with a Content-Type matching the selected +JavaScript or CSS resource. + +For manual acceptance, open `http://10.2.77.108:4003` in Orin Firefox or +Chromium and confirm the complete page, JavaScript, CSS, Start and Stop controls +load without a blank screen or indefinite loading state. Start must join the +LiveKit Room and dispatch the LexVoice Generic Agent; LexVoice then starts +lex-reflex, which publishes `room_audio` and `room_video_raw`, and the Video +Processor publishes `room_video`. Stop must clean up the Agent session, cause +LexVoice to stop lex-reflex, release the media devices, and leave the Browser +ready to start again. In browser developer tools, confirm there is no Jetson IP +input and no request to `10.2.2.199:8013`. Next.js must not store the Jetson IP, +and Jetson must not send a heartbeat to Next.js. + For standalone frontend development, install dependencies and run the dev server directly: From 841b0fb261f00db80da8317d3701c5be2cd700d9 Mon Sep 17 00:00:00 2001 From: why-tomato Date: Wed, 26 Aug 2026 19:45:48 +0800 Subject: [PATCH 2/2] fix: support browser UUIDs over LAN HTTP --- components/app/session-provider.tsx | 17 +++++++ lib/browser-room-session.ts | 23 ++++++++- lib/browser-runtime-compat.ts | 45 ++++++++++++++++++ tests/browser-room-session.test.mjs | 68 +++++++++++++++++++++++++++ tests/browser-runtime-compat.test.mjs | 67 ++++++++++++++++++++++++++ 5 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 lib/browser-runtime-compat.ts create mode 100644 tests/browser-runtime-compat.test.mjs diff --git a/components/app/session-provider.tsx b/components/app/session-provider.tsx index 51a42b4bb..30f1af851 100644 --- a/components/app/session-provider.tsx +++ b/components/app/session-provider.tsx @@ -6,6 +6,7 @@ import { APP_CONFIG_DEFAULTS, type AppConfig } from '@/app-config'; import type { BrowserSourceClient } from '@/hooks/useBrowserSourceClient'; import { useRoom } from '@/hooks/useRoom'; import { SelectedVideoTrackProvider } from '@/hooks/useSelectedVideoTrack'; +import { ensureBrowserRandomUuid } from '@/lib/browser-runtime-compat'; const DEFAULT_BROWSER_SOURCE_CLIENT: BrowserSourceClient = { enabled: false, @@ -43,6 +44,22 @@ interface SessionProviderProps { } export const SessionProvider = ({ appConfig, children }: SessionProviderProps) => { + const compatibility = ensureBrowserRandomUuid(); + if (!compatibility.ok) { + return ( +
+
+

Browser compatibility error

+

{compatibility.message}

+
+
+ ); + } + + return {children}; +}; + +const CompatibleSessionProvider = ({ appConfig, children }: SessionProviderProps) => { const { room, isSessionActive, diff --git a/lib/browser-room-session.ts b/lib/browser-room-session.ts index face980a4..7df53bb11 100644 --- a/lib/browser-room-session.ts +++ b/lib/browser-room-session.ts @@ -6,7 +6,7 @@ let fallbackSessionId: string | null = null; export function getVoiceSessionId( storage: Pick | null | undefined = getSessionStorage(), - createSessionId: () => string = () => crypto.randomUUID() + createSessionId: () => string = createBrowserRandomUuid ) { if (storage) { try { @@ -50,6 +50,27 @@ export function resetVoiceSessionId( export const getBrowserRoomSessionId = getVoiceSessionId; export const resetBrowserRoomSessionId = resetVoiceSessionId; +export function createBrowserRandomUuid( + cryptoProvider: Pick & Partial> = crypto +) { + if (typeof cryptoProvider.randomUUID === 'function') { + return cryptoProvider.randomUUID(); + } + + return createBrowserRandomUuidFromRandomValues(cryptoProvider); +} + +export function createBrowserRandomUuidFromRandomValues( + cryptoProvider: Pick +) { + const bytes = cryptoProvider.getRandomValues(new Uint8Array(16)); + 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).join('')}`; +} + function getSessionStorage() { if (typeof window === 'undefined') { return null; diff --git a/lib/browser-runtime-compat.ts b/lib/browser-runtime-compat.ts new file mode 100644 index 000000000..7a01924f1 --- /dev/null +++ b/lib/browser-runtime-compat.ts @@ -0,0 +1,45 @@ +import { createBrowserRandomUuidFromRandomValues } from './browser-room-session'; + +type BrowserCryptoProvider = Pick & Partial>; + +export type BrowserRandomUuidStatus = + | { ok: true; installed: boolean } + | { ok: false; message: string }; + +export function ensureBrowserRandomUuid( + cryptoProvider: BrowserCryptoProvider | undefined = globalThis.crypto +): BrowserRandomUuidStatus { + if (!cryptoProvider || typeof cryptoProvider.getRandomValues !== 'function') { + return { + ok: false, + message: 'This browser does not provide the secure random values required by randomUUID.', + }; + } + + if (typeof cryptoProvider.randomUUID === 'function') { + return { ok: true, installed: false }; + } + + try { + Object.defineProperty(cryptoProvider, 'randomUUID', { + configurable: true, + enumerable: false, + writable: false, + value: () => createBrowserRandomUuidFromRandomValues(cryptoProvider), + }); + } catch { + return { + ok: false, + message: 'This browser could not install the required randomUUID compatibility support.', + }; + } + + if (typeof cryptoProvider.randomUUID !== 'function') { + return { + ok: false, + message: 'This browser could not install the required randomUUID compatibility support.', + }; + } + + return { ok: true, installed: true }; +} diff --git a/tests/browser-room-session.test.mjs b/tests/browser-room-session.test.mjs index 153264045..f94dba1b5 100644 --- a/tests/browser-room-session.test.mjs +++ b/tests/browser-room-session.test.mjs @@ -44,6 +44,74 @@ function createMemoryStorage() { }; } +async function withCryptoProvider(provider, callback) { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: provider, + }); + try { + return await callback(); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, 'crypto', descriptor); + } else { + delete globalThis.crypto; + } + } +} + +test('creates an RFC 4122 v4 browser room id when native randomUUID is unavailable', async () => { + const { createBrowserRandomUuid, isValidConnectionRoomId } = await loadBrowserRoomSessionModule(); + + const sessionId = await withCryptoProvider( + { + getRandomValues(bytes) { + bytes.set(Array.from({ length: 16 }, (_, index) => index)); + return bytes; + }, + }, + () => createBrowserRandomUuid() + ); + + assert.equal(sessionId, '00010203-0405-4607-8809-0a0b0c0d0e0f'); + assert.equal(sessionId[14], '4'); + assert.match(sessionId[19], /[89ab]/); + assert.equal(isValidConnectionRoomId(sessionId), true); +}); + +test('prefers native randomUUID for a new browser room id', async () => { + const { createBrowserRandomUuid } = await loadBrowserRoomSessionModule(); + const nativeSessionId = '33333333-4444-4555-8666-777777777777'; + + const sessionId = await withCryptoProvider( + { + randomUUID() { + return nativeSessionId; + }, + getRandomValues() { + assert.fail('getRandomValues must not run when native randomUUID is callable'); + }, + }, + () => createBrowserRandomUuid() + ); + + assert.equal(sessionId, nativeSessionId); +}); + +test('reuses a valid stored browser room id without creating another', async () => { + const { getBrowserRoomSessionId } = await loadBrowserRoomSessionModule(); + const storage = createMemoryStorage(); + const storedSessionId = '44444444-5555-4666-8777-888888888888'; + storage.setItem('lexvoice.session_id.v1', storedSessionId); + + const sessionId = getBrowserRoomSessionId(storage, () => { + assert.fail('stored session id should be reused'); + }); + + assert.equal(sessionId, storedSessionId); +}); + test('resetting browser room session rotates the next room id', async () => { const { getBrowserRoomSessionId, resetBrowserRoomSessionId } = await loadBrowserRoomSessionModule(); diff --git a/tests/browser-runtime-compat.test.mjs b/tests/browser-runtime-compat.test.mjs new file mode 100644 index 000000000..b86f250ef --- /dev/null +++ b/tests/browser-runtime-compat.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import * as browserCompat from '../lib/browser-runtime-compat.ts'; + +test('installs an RFC 4122 v4 randomUUID when the browser crypto API omits it', () => { + const cryptoProvider = { + getRandomValues(bytes) { + bytes.set(Array.from({ length: 16 }, (_, index) => index)); + return bytes; + }, + }; + + const result = browserCompat.ensureBrowserRandomUuid(cryptoProvider); + + assert.deepEqual(result, { ok: true, installed: true }); + assert.equal(cryptoProvider.randomUUID(), '00010203-0405-4607-8809-0a0b0c0d0e0f'); + assert.equal(cryptoProvider.randomUUID()[14], '4'); + assert.match(cryptoProvider.randomUUID()[19], /[89ab]/); +}); + +test('preserves the native randomUUID implementation', () => { + const nativeRandomUuid = () => '33333333-4444-4555-8666-777777777777'; + const cryptoProvider = { + randomUUID: nativeRandomUuid, + getRandomValues() { + assert.fail('getRandomValues must not run when native randomUUID exists'); + }, + }; + + const result = browserCompat.ensureBrowserRandomUuid(cryptoProvider); + + assert.deepEqual(result, { ok: true, installed: false }); + assert.equal(cryptoProvider.randomUUID, nativeRandomUuid); +}); + +test('fails safely when the browser crypto object rejects the polyfill', () => { + const cryptoProvider = Object.preventExtensions({ + getRandomValues(bytes) { + return bytes; + }, + }); + + const result = browserCompat.ensureBrowserRandomUuid(cryptoProvider); + + assert.equal(result.ok, false); + assert.match(result.message, /randomUUID/i); + assert.equal('randomUUID' in cryptoProvider, false); +}); + +test('SessionProvider exposes an explicit compatibility error before creating a Room', async () => { + const source = await readFile('components/app/session-provider.tsx', 'utf8'); + + assert.match(source, /ensureBrowserRandomUuid\(/); + assert.match(source, /role="alert"/); + assert.match(source, /Browser compatibility error/); +}); + +test('AgentControlBar retains the high-level useChat send path', async () => { + const source = await readFile( + 'components/livekit/agent-control-bar/agent-control-bar.tsx', + 'utf8' + ); + + assert.match(source, /await send\(message\)/); + assert.doesNotMatch(source, /streamText|sendBrowserChatMessage/); +});