stablyai/orca · error · Error

Expected plaintext E2EE v2 ready

Error message

Expected plaintext E2EE v2 ready

What it means

Thrown by `acceptReady` in `MobileE2EEV2PhysicalChannel` when the very first inbound frame (the desktop's ready message) is not a string. The ready frame is required to arrive as plaintext JSON text because the key schedule does not exist yet — binary cannot be decrypted. A non-string payload means the transport is delivering binary (ArrayBuffer/Blob) where text was negotiated.

Source

Thrown at mobile/src/transport/mobile-e2ee-v2-physical-channel.ts:124

    if (this.state === 'awaiting-authenticated') {
      if (typeof plaintext === 'string' && isAuthenticationRejection(plaintext)) {
        throw new MobileE2EEAuthenticationError()
      }
      if (typeof plaintext !== 'string' || !this.isAuthenticated(plaintext)) {
        throw new Error('Invalid E2EE v2 authenticated response')
      }
      this.state = 'ready'
      this.args.onAuthenticated()
    } else if (typeof plaintext === 'string') {
      this.args.onText(plaintext)
    } else {
      this.args.onBinary(plaintext)
    }
  }

  private acceptReady(raw: unknown): void {
    if (typeof raw !== 'string') {
      throw new Error('Expected plaintext E2EE v2 ready')
    }
    let ready: unknown
    try {
      ready = JSON.parse(raw)
    } catch {
      throw new Error('Invalid E2EE v2 ready JSON')
    }
    if (!this.args.session.acceptReady(ready)) {
      throw new Error('Invalid E2EE v2 ready')
    }
    this.state = 'awaiting-authenticated'
    this.outboundQueue.enqueue({
      kind: 'text',
      plaintext: JSON.stringify({
        type: 'e2ee_auth',
        v: 2,
        transcriptHashB64: this.args.session.transcriptHashB64,
        deviceToken: this.args.deviceToken

View on GitHub (pinned to 1136503c6a)

Solutions

  1. Ensure the underlying WebSocket is in text mode for the handshake — set `socket.binaryType = 'blob'` only after the ready exchange, or have the desktop send the ready frame as text.
  2. If using a custom transport, coerce the first frame to a string before calling `channel.handleMessage`.
  3. In tests, pass a JSON string for the ready message, not an encoded buffer.

Example fix

// before
socket.onmessage = (e) => channel.handleMessage(e.data) // e.data is ArrayBuffer -> throws

// after
socket.onmessage = (e) => {
  const raw = e.data instanceof ArrayBuffer ? new TextDecoder().decode(e.data) : e.data
  channel.handleMessage(raw)
}
Defensive patterns

Strategy: validation

Validate before calling

function isTextFrame(raw: unknown): raw is string { return typeof raw === 'string' }
// Before forwarding to the channel:
if (!isTextFrame(firstFrame)) { socket.close(); return }

Type guard

function isReadyText(raw: unknown): raw is string { return typeof raw === 'string' }

Try / catch

try { channel.handleMessage(firstFrame) } catch (e) { if (e.message === 'Expected plaintext E2EE v2 ready') { /* configure socket for text mode, reconnect */ } else throw e }

Prevention

When it happens

Trigger: WebSocket `onmessage` delivered an `ArrayBuffer`/`Blob` as the first frame; relay or desktop misconfiguration that sends binary framing from the start; the `decodeBinary` path being invoked before the channel expects it.

Common situations: A relay implementation that always frames as binary; a browser/React-Native WebSocket defaulting to arraybuffer type when the desktop assumed text; a test harness that feeds a `Uint8Array` instead of a JSON string.

Related errors


AI-assisted analysis of stablyai/orca@1136503c6a (2026-08-12). Data as JSON: /api/errors/b897e2e8e9b5d163. Report an issue: GitHub.