Skip to content

bug(voice): the TTS stream WebSocket sends no credential, so the authenticated /api/voice/stream refuses every connection #17004

Description

@mrveiss

What

The frontend's text-to-speech stream opens its WebSocket with no credential at all:

autobot-frontend/src/composables/useVoiceOutput.ts:665-666

const url = `${getBackendWsUrl()}/api/voice/stream`
const ws = new WebSocket(url)

It sends no ?token= and no Sec-WebSocket-Protocol. The backend route (autobot-backend/api/voice_stream.py, voice_stream_ws, mounted at /voice) calls authenticate_websocket and, when that fails, accepts then closes with 4001 (#15745). authenticate_websocket has no cookie fallback. So by static reading, every real TTS stream connection is refused.

Not determined

This is from reading the code, not observed at runtime. The deployment's access logs show no requests to /api/voice/stream at all (checked read-only on 2026-09-18), so there is no runtime evidence either way. The feature may simply be unused. Confirm on a running instance: enable voice output and watch for a 4001 close.

Acceptance criteria

Found by the independent review of #16939 (WebSocket subprotocol auth). Related: #16457.

Activity

  1. added this to the v0.9.0 milestone on Sep 18, 2026
  2. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Chunking triage — a proposal, not an assignment

    Nothing was relabelled, moved or closed by this pass.

  3. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    B6 spec — two voice surfaces whose failure looks like nothing to do

    Batch B6 = #17004 → #17734's client half, one risk class, one review pass. Full spec at ~/b6-spec-2026-09-28.md.

    #17004 — and the fix is a design choice, not a one-liner

    useVoiceOutput.ts:665-666 opens new WebSocket(url) with no credential. The mechanical constraint: the browser WebSocket constructor cannot set headers, and this app's credential is a Bearer token in an Authorization header (utils/fetchWithAuth.ts:25, :35-36). So the token cannot be attached the way every REST call attaches it, and the fix has to pick a mechanism: a short-lived ticket as a query parameter, the Sec-WebSocket-Protocol subprotocol, or a required first-message handshake.

    Whichever is chosen, match open_authenticated_ws — the mechanism the workflow socket already uses (services/workflow_automation/ws_endpoint.py). A second WebSocket auth scheme in this repo is the #17693 shape in a new place.

    Tests: client half in useVoiceOutput's own __tests__; server half beside api/ws_auth_17009_test.py, the established home for "this socket refuses an unauthenticated connection". Mutation: revert to the bare new WebSocket(url) → the server test shows a refusal and the client test goes red. Negative control: an authenticated connection still succeeds, or the fix has just broken voice output.

    #17734's client half — and the test that exists today is the half-assertion

    AC1 is met (route exists, mounted, authenticated). _fetchTools (useRealtimeVoice.ts:110-125) returns [] on both !res.ok and an exception, so the endpoint failed and there are no tools are the same value.

    Two assertions, and the second is pinned by nothing today:

    1. a non-2xx and a thrown exception each produce a failure, not [];
    2. a successful fetch of a non-empty toolset produces that toolset.

    useRealtimeVoice.test.ts:208 treats an empty list as a state — it passes for a real empty toolset and for a failed fetch, so it must be split, not extended. Mutations: return 500 → assertion 1 red; return 200 [] → assertion 2 red. A single test that accepts [] passes both, which is exactly why this AC is 1/4 and not 2/4.

    Do not close #17734 on this batch alone — the route half is already done, and :208's half-assertion is part of the remainder.

  4. mrveiss commented on Oct 4, 2026

    @mrveiss
    OwnerAuthor

    Code and test criteria delivered by #17954 (merge 7f8e7385db). Verified against origin/main. The issue stays open for AC3.

    • useVoiceOutput.ts authenticates its WebSocket the same way the other migrated clients do after security(websocket): auth token travels via Sec-WebSocket-Protocol, not the URL (#16457) #16891/security(websocket): every authenticated WebSocket echoes the bearer subprotocol through one helper (#16457) #16939: new WebSocket(url, buildAuthenticatedWsSubprotocols()). No URL token.
      • composables/useVoiceOutput.ts:760: new WebSocket(url, buildAuthenticatedWsSubprotocols() ?? undefined). With no token yet, ?? undefined avoids sending the literal protocol "null".
      • The URL is still ${getBackendWsUrl()}/api/voice/stream with no query string.
    • A frontend test asserts the socket is opened with the bearer subprotocol.
      • composables/__tests__/useVoiceOutput.auth.test.ts, case opens /api/voice/stream with the bearer subprotocol and no URL token (expects ['bearer', 'tok-123']).
      • Same file, case passes no protocol list, never the string "null", when no token exists yet.
    • Runtime evidence: a TTS stream connects and stays open on a running instance, or the feature is confirmed unused and the decision recorded.
      • Not met yet. This needs host evidence after the builtin updater deploys 7f8e7385db: enable voice output and confirm the socket stays open with no 4001 close.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions