diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index f05dc0515cc..e806fdc9ba5 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -75,7 +75,7 @@ provides a fake optimizer for isolated UI development. | -------------------------------- | ---------------- | ---------------- | ---------------------------------------------------------- | | `OPENAI_API_KEY` | for chat to work | `api/chat.ts` | OpenAI key the function uses to call `streamText`. | | `OPENAI_VOICE_API_KEY` | for voice | voice API | Dedicated OpenAI key used to create Realtime WebRTC calls. | -| `PETRINAUT_OPENAI_VOICE_ENABLED` | no | voice API | Set to `true` to enable voice outside production. | +| `PETRINAUT_OPENAI_VOICE_ENABLED` | no | voice API | Set to `true` to enable voice, including in production. | | `PETRINAUT_AI_MODEL` | no | `api/chat.ts` | Overrides the default OpenAI model id. | | `PETRINAUT_OPT_ORIGIN` | no | `vite.config.ts` | Overrides the local optimizer proxy target. | | `VITE_BRUNCH_CHAT_ENDPOINT` | for Brunch | website | Base URL of the mounted Brunch Flue route. | @@ -84,12 +84,20 @@ provides a fake optimizer for isolated UI development. Local values live in `.env.local`; Vite's `loadEnv` (see [`vite.config.ts`](vite.config.ts)) copies them into `process.env` for both the dev server and the API functions. In production, set these in the Vercel project settings. -### Brunch Voice mode preview +### Brunch Voice mode -Voice mode is disabled by default and always unavailable when `VERCEL_ENV` is -`production`. To exercise the preview locally or in a Vercel preview, set a -real `VITE_BRUNCH_CHAT_ENDPOINT`, `PETRINAUT_OPENAI_VOICE_ENABLED=true`, and a -dedicated `OPENAI_VOICE_API_KEY`. +Voice mode is disabled by default. To enable it, configure a real +`VITE_BRUNCH_CHAT_ENDPOINT`, set `PETRINAUT_OPENAI_VOICE_ENABLED=true`, and +provide a dedicated `OPENAI_VOICE_API_KEY`. + +Production Voice is temporarily unauthenticated. The same-origin check rejects +ordinary cross-site browser requests, but a non-browser caller can spoof its +`Origin` header and create billable Realtime sessions. Use a dedicated OpenAI +project with low usage thresholds and alerts, monitor it while Voice is +enabled, and set `PETRINAUT_OPENAI_VOICE_ENABLED=false` immediately if usage is +unexpected. Revoke or rotate the dedicated `OPENAI_VOICE_API_KEY` in OpenAI, +then update the deployment secret before re-enabling Voice. FE-1622 tracks +adding caller authentication. Text and Voice mode use one assistant transcript and composer. When Voice mode is available, the empty first-run prompt and empty composer show a waveform @@ -174,9 +182,9 @@ correlation. Browser and server diagnostics report only operation, stage, outcome, duration, request ID, and—where applicable—status or a sanitized error code. Voice responses also expose privacy-safe `Server-Timing` metrics. These diagnostics never record audio, SDP, transcript or prompt contents, canonical -speech text, credentials, or provider response bodies. This controlled-preview -evidence does not enable production: production remains unconditionally -disabled by the server policy. +speech text, credentials, or provider response bodies. Production Voice remains +behind the explicit server configuration, which is an operational switch rather +than caller authentication. ## Testing the API against the built output diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/production-voice-availability.integration.test.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/production-voice-availability.integration.test.tsx new file mode 100644 index 00000000000..f4772679e43 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/production-voice-availability.integration.test.tsx @@ -0,0 +1,150 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; + +import { LocalStorageDemoApp } from "./local-storage-demo-app"; + +import type { + AgentConversationObservationSnapshot, + FlueClient, +} from "@flue/sdk"; + +vi.hoisted(() => { + window.matchMedia = (media) => ({ + media, + matches: false, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => true, + }); +}); + +const brunchPreviewConfig = vi.hoisted(() => ({ + chatEndpoint: "/agents/chat", + isBrunchConfigured: true, +})); + +const flueClient = vi.hoisted(() => ({ current: null as unknown })); + +vi.mock("./brunch-preview-config", () => ({ + resolveBrunchPreviewConfig: () => brunchPreviewConfig, +})); + +vi.mock("./brunch-principal", () => ({ + getOrCreateBrunchPrincipal: () => "test-principal", +})); + +vi.mock("@flue/sdk", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + createFlueClient: () => flueClient.current, + }; +}); + +vi.mock("@hashintel/petrinaut/ui", async (importOriginal) => { + const original = + await importOriginal(); + const inertWorker = () => ({ + addEventListener() {}, + postMessage() {}, + removeEventListener() {}, + terminate() {}, + }); + + return { + ...original, + Petrinaut: (props: Parameters[0]) => ( + + ), + }; +}); + +const stubStorage = () => { + const entries = new Map(); + vi.stubGlobal("localStorage", { + get length() { + return entries.size; + }, + clear: () => entries.clear(), + getItem: (key: string) => entries.get(key) ?? null, + key: (index: number) => [...entries.keys()].at(index) ?? null, + removeItem: (key: string) => entries.delete(key), + setItem: (key: string, value: string) => entries.set(key, value), + } satisfies Storage); + localStorage.setItem( + "petrinaut:user-settings", + JSON.stringify({ showWalkthroughOnInit: false }), + ); +}; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +test("renders the microphone action when Brunch and server Voice are available", async () => { + stubStorage(); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + vi.stubGlobal( + "ResizeObserver", + class { + public disconnect() {} + public observe() {} + public unobserve() {} + }, + ); + const snapshot: AgentConversationObservationSnapshot = { + conversation: { + conversationId: "production-voice", + messages: [], + settlements: [], + }, + error: undefined, + offset: "0", + phase: "live", + }; + flueClient.current = { + observe: () => ({ + close: () => {}, + getSnapshot: () => snapshot, + refresh: () => {}, + subscribe: () => () => {}, + }), + } as Pick as FlueClient; + const fetch = vi.fn(async () => + Response.json({ available: true, connectionTimeoutMs: 15_000 }), + ); + vi.stubGlobal("fetch", fetch); + + render( {}} search={{}} />); + + fireEvent.click( + await screen.findByRole("button", { name: "Show AI assistant" }), + ); + + await waitFor(() => + expect(fetch).toHaveBeenCalledWith( + "/api/voice/config", + expect.objectContaining({ cache: "no-store", method: "GET" }), + ), + ); + expect( + await screen.findByRole("button", { name: "Start voice mode" }), + ).not.toBeNull(); +}); diff --git a/apps/petrinaut-website/src/server/voice/openai-voice-config.test.ts b/apps/petrinaut-website/src/server/voice/openai-voice-config.test.ts index c6a795a8def..51537c3345c 100644 --- a/apps/petrinaut-website/src/server/voice/openai-voice-config.test.ts +++ b/apps/petrinaut-website/src/server/voice/openai-voice-config.test.ts @@ -3,11 +3,11 @@ import { describe, expect, test } from "vitest"; import { createOpenAIVoiceConfigHandler } from "./openai-voice-config"; describe("OpenAI voice config handler", () => { - test("returns only server-derived availability and the client timeout", async () => { + test("returns configured production availability without exposing server settings", async () => { const handler = createOpenAIVoiceConfigHandler({ OPENAI_VOICE_API_KEY: "server-secret", PETRINAUT_OPENAI_VOICE_ENABLED: "true", - VERCEL_ENV: "preview", + VERCEL_ENV: "production", }); const response = await handler( diff --git a/apps/petrinaut-website/src/server/voice/openai-voice-policy.test.ts b/apps/petrinaut-website/src/server/voice/openai-voice-policy.test.ts index 523b8c85f17..eef273d2b16 100644 --- a/apps/petrinaut-website/src/server/voice/openai-voice-policy.test.ts +++ b/apps/petrinaut-website/src/server/voice/openai-voice-policy.test.ts @@ -19,14 +19,37 @@ describe("OpenAI voice policy", () => { ).toEqual({ available: false, connectionTimeoutMs: 15_000 }); }); - test("fails closed in production while authentication and quotas are unavailable", () => { + test("keeps the enabled flag closed without a non-whitespace key", () => { + expect( + getOpenAIVoiceAvailability({ + PETRINAUT_OPENAI_VOICE_ENABLED: "true", + }), + ).toEqual({ available: false, connectionTimeoutMs: 15_000 }); + expect( + getOpenAIVoiceAvailability({ + OPENAI_VOICE_API_KEY: " \t\n ", + PETRINAUT_OPENAI_VOICE_ENABLED: "true", + }), + ).toEqual({ available: false, connectionTimeoutMs: 15_000 }); + }); + + test("requires the enabled flag's exact lowercase value", () => { + expect( + getOpenAIVoiceAvailability({ + OPENAI_VOICE_API_KEY: "server-secret", + PETRINAUT_OPENAI_VOICE_ENABLED: "TRUE", + }), + ).toEqual({ available: false, connectionTimeoutMs: 15_000 }); + }); + + test("enables explicitly configured voice in production", () => { expect( getOpenAIVoiceAvailability({ OPENAI_VOICE_API_KEY: "server-secret", PETRINAUT_OPENAI_VOICE_ENABLED: "true", VERCEL_ENV: "production", }), - ).toEqual({ available: false, connectionTimeoutMs: 15_000 }); + ).toEqual({ available: true, connectionTimeoutMs: 15_000 }); expect( getOpenAIVoiceAvailability({ @@ -34,7 +57,7 @@ describe("OpenAI voice policy", () => { OPENAI_VOICE_API_KEY: "server-secret", PETRINAUT_OPENAI_VOICE_ENABLED: "true", }), - ).toEqual({ available: false, connectionTimeoutMs: 15_000 }); + ).toEqual({ available: true, connectionTimeoutMs: 15_000 }); expect( getOpenAIVoiceAvailability({ diff --git a/apps/petrinaut-website/src/server/voice/openai-voice-policy.ts b/apps/petrinaut-website/src/server/voice/openai-voice-policy.ts index 653b622dc3a..25e2a13bca5 100644 --- a/apps/petrinaut-website/src/server/voice/openai-voice-policy.ts +++ b/apps/petrinaut-website/src/server/voice/openai-voice-policy.ts @@ -8,15 +8,11 @@ interface VoiceEnvironment { readonly VERCEL_ENV?: string; } -const isNonProductionRuntime = (environment: VoiceEnvironment): boolean => - environment.VERCEL_ENV === "preview" || - environment.VERCEL_ENV === "development" || - (environment.VERCEL_ENV === undefined && - environment.NODE_ENV !== "production"); - +/** + * This operational provider switch is not caller authentication. + */ export const getOpenAIVoiceAvailability = (environment: VoiceEnvironment) => ({ available: - isNonProductionRuntime(environment) && environment.PETRINAUT_OPENAI_VOICE_ENABLED === "true" && Boolean(environment.OPENAI_VOICE_API_KEY?.trim()), connectionTimeoutMs: OPENAI_REALTIME_CONNECTION_TIMEOUT_MS,