feat: add local realtime voice agent stack - #144
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
|
|
||
| export function parseBearer(header) { | ||
| if (typeof header !== "string") return null; | ||
| const match = /^Bearer\s+(.+)$/i.exec(header.trim()); |
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eb6429a594
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const tenantId = String(request.headers["x-tenant-id"] || "").trim(); | ||
| const subjectId = String( | ||
| request.headers["x-subject-id"] || request.headers["cf-access-authenticated-user-email"] || "", | ||
| ).trim(); |
There was a problem hiding this comment.
Derive ticket claims from verified identity
When ZVoice is exposed behind the documented identity proxy, a same-origin caller can supply X-Tenant-Id and X-Subject-Id directly; these browser-settable values are accepted even with anonymous access disabled, and X-Subject-Id takes precedence over the Cloudflare-authenticated identity. The server then uses its service token to mint a valid ticket for the attacker-selected tenant and subject, so these claims must instead come from verified middleware or proxy headers that clients cannot override.
Useful? React with 👍 / 👎.
| socket.setTimeout(0); | ||
| upstreamSocket.setTimeout(0); |
There was a problem hiding this comment.
Enforce a finite WebSocket session timeout
When a client opens a valid WebSocket and then remains idle or an upstream connection stalls, both sockets explicitly have timeouts disabled and the admission slot is released only on close/error. Such clients can therefore occupy every VOICE_MAX_SESSIONS slot indefinitely, causing all later sessions to receive 429 responses; enforce an idle or maximum-session deadline that closes both sockets and add timeout-path coverage.
AGENTS.md reference: AGENTS.md:L35-L38
Useful? React with 👍 / 👎.
| const session = await requestVoiceSession(); | ||
| if (modelField) modelField.value = session.model || modelField.value; | ||
| await startCapture(); | ||
|
|
||
| socket = new WebSocket(session.websocket_url, [`zticket.${session.ticket}`]); |
There was a problem hiding this comment.
Request the ticket after microphone setup
On first use, the browser requests the short-lived ticket before awaiting the microphone permission prompt and AudioWorklet setup. If the user takes longer than the configured ticket TTL—60 seconds by default—the subsequent WebSocket handshake is rejected as expired, even though the user just approved access; complete capture setup before requesting the ticket, or refresh it immediately before opening the socket.
Useful? React with 👍 / 👎.
| "scripts": { | ||
| "build": "pnpm -r --if-present build", | ||
| "test": "pnpm -r --workspace-concurrency=1 --if-present test && node --test scripts/test/*.test.mjs", | ||
| "test": "pnpm -r --workspace-concurrency=1 --if-present test && node --test scripts/test/*.test.mjs apps/zvoice/test/*.test.mjs services/voice-gateway/test/*.test.mjs", |
There was a problem hiding this comment.
The added root test command is not invoked by either checked workflow: .github/workflows/ci.yml and .github/workflows/validate.yml run npm test only for fixed workspace lists, while neither apps/zvoice nor services/voice-gateway has a package.json or appears in those lists. Consequently the ticket-authentication and browser-boundary tests added by this commit never run in GitHub Actions, allowing regressions in these security controls to pass the release gate; add explicit workflow coverage or package manifests that the workflow executes.
AGENTS.md reference: AGENTS.md:L41-L43
Useful? React with 👍 / 👎.
| function serializeUpgradeRequest(request, target, claims, forwardedProtocols) { | ||
| const headers = { ...request.headers }; | ||
| delete headers["proxy-connection"]; | ||
| delete headers["content-length"]; | ||
| delete headers["sec-websocket-protocol"]; |
There was a problem hiding this comment.
Allowlist headers forwarded to the voice agent
When the gateway is deployed behind Cloudflare Access or another cookie-based identity proxy, cloning the entire upgrade request forwards browser cookies, Access assertions, authorization headers, and proxy identity metadata into the less-trusted speech runtime. None of these credentials is needed after the gateway verifies the signed ticket, so construct an allowlist containing only the WebSocket handshake headers and the three synthesized Z Platform identity fields.
Useful? React with 👍 / 👎.
| const url = new URL(request.url || "/", "http://voice-gateway.local"); | ||
| return { | ||
| ticket: url.searchParams.get("ticket"), | ||
| forwardedProtocols: protocols, |
There was a problem hiding this comment.
Remove the query-string ticket fallback
When a client uses the fallback ?ticket=... transport, reverse proxies and access-log middleware commonly record the full WebSocket URL, placing a still-valid tenant-bearing credential in logs where another reader can race the intended client. The browser already uses the documented Sec-WebSocket-Protocol transport, so reject query-string tickets rather than silently accepting this leak-prone alternative.
Useful? React with 👍 / 👎.
| RUN python -m pip install --no-cache-dir --upgrade pip setuptools wheel \ | ||
| && python -m pip install --no-cache-dir \ | ||
| "qwentts-cpp-python==${QWENTTS_CPP_VERSION}" \ | ||
| --find-links "${QWENTTS_WHEEL_INDEX}" \ | ||
| && python -m pip install --no-cache-dir \ | ||
| "speech-to-speech[faster-whisper]==${SPEECH_TO_SPEECH_VERSION}" |
There was a problem hiding this comment.
Include voice-agent dependencies in audit and SBOM gates
The new Python runtime installs its production dependencies only through Dockerfile pip install commands, with no requirements or lock manifest. The checked dependency workflow inventories only package.json files, while the Python audit job covers only apps/zaicoder/backend, so speech-to-speech, qwentts-cpp-python, and their dynamically resolved transitive dependencies are absent from dependency-policy and repository SBOM verification; record a reproducible dependency manifest and include it in the audit/SBOM gates.
AGENTS.md reference: AGENTS.md:L41-L43
Useful? React with 👍 / 👎.
| ## Status | ||
|
|
||
| Initial production-oriented vertical slice. External traffic remains disabled by default; published ports bind to loopback. |
There was a problem hiding this comment.
Add voice services to canonical production controls
Although this document declares a new production-oriented application and two deployable services, the checked docs/operations/production-master.md, docs/operations/staging-readiness.md, and docs/requirements/master-requirements.md contain no voice service, requirement, readiness, rollback, or release-gate entries. The migration manifest and architecture index likewise do not register the new boundary, so the repository's canonical production review cannot track the conditions listed only in this standalone document; update those control documents before treating the slice as releaseable.
AGENTS.md reference: AGENTS.md:L45-L53
Useful? React with 👍 / 👎.
| const ticketProtocol = protocols.find((value) => value.startsWith("zticket.")); | ||
| if (ticketProtocol) { | ||
| return { | ||
| ticket: ticketProtocol.slice("zticket.".length), | ||
| forwardedProtocols: protocols.filter((value) => !value.startsWith("zticket.")), | ||
| }; |
There was a problem hiding this comment.
Complete the ticket subprotocol handshake
The browser opens the socket with only zticket.<ticket> as its requested WebSocket subprotocol, but the gateway removes that value before forwarding the upgrade, so a compliant upstream returns no Sec-WebSocket-Protocol selection. Browsers then reject the otherwise successful 101 response because none of their requested protocols was negotiated, making the documented ZVoice connection fail before any realtime events can flow; terminate or rewrite the handshake so the gateway acknowledges an accepted non-secret protocol without exposing the ticket upstream.
Useful? React with 👍 / 👎.
Summary
Adds a production-oriented local realtime voice vertical slice to Z Platform:
apps/zvoice: browser voice UI with AudioWorklet microphone capture, 16 kHz PCM16 streaming, live transcripts, audio playback, cancellation, and barge-in.services/voice-gateway: service-authenticated ticket issuance, short-lived HMAC one-time WebSocket tickets, replay rejection, concurrency admission, raw WebSocket tunneling, health, metrics, and redacted structured logs.services/voice-agent: pinned Hugging Facespeech-to-speechruntime using Faster Whisper STT, the OpenAI-compatible chat-completions backend, and Qwen3-TTS.compose.voice.yml: optional Ollama, llama.cpp, and vLLM profiles while keepingservices/ai-gatewayas the sole LLM policy boundary.Architecture
Security properties
Z_PLATFORM_SERVICE_TOKENor provider keys.Sec-WebSocket-Protocol.Validation completed
node --checkfor gateway, ZVoice server, browser client, and AudioWorklet.main.Local run — Ollama
Open
http://127.0.0.1:3022.Remaining release checks
This PR is intentionally draft because the current execution environment could not perform Docker image builds, model downloads, GPU compatibility validation, or an end-to-end browser microphone/speaker test. Those checks are documented in
docs/operations/voice-agent.mdand must pass on the target host before merge or external exposure.The first implementation stores consumed ticket nonces in memory and is therefore single-replica. Move replay/admission state to Redis before horizontal scaling.