Files
hermes-agent/plugins/platforms
SmokeDev 0e73d1d3b6 fix(discord): track live voice credentials across re-keys instead of snapshotting at start()
VoiceReceiver.start() captured conn.secret_key (by value) and
conn.dave_session (by reference) exactly once. Both go stale in normal
operation:

- op 4 SESSION_DESCRIPTION rotates the transport secret_key on every
  voice (re)connect — the snapshot then fails NaCl decrypt forever.
- voice_state.reinit_dave_session() REPLACES conn.dave_session with a
  NEW DaveSession when none existed yet, so a receiver that starts
  before DAVE finishes negotiating keeps None forever and treats E2EE
  as off — every frame reaches opus as ciphertext for the whole
  session.

The failure is silent by construction: decrypt failures warn for the
first 10 packets then go dark, and shredded opus output raises nothing.
The operator-visible symptom is "the bot ignores me."

Fix — resolve, don't copy:

- Credentials live in ONE tuple (secret_key, dave_session,
  dave_protocol_version, dave_downgraded), re-resolved from the live
  connection and swapped in a single reference store; the receive
  thread reads one consistent generation per packet, so a refresh can
  never pair a new key with an old session.
- Refresh triggers: the voice-ws hook on op 4 and op 24 (the hook runs
  AFTER received_message dispatches the op, so it reads the new
  values); membership changes in the bot's channel (DAVE epoch bump)
  via the existing on_voice_state_update handler; and a
  25-consecutive-NaCl-failure streak as the catch-all, retried every
  streak window while the failure persists.
- Unmapped-SSRC hole closed without recreating it elsewhere: with E2EE
  actively on (protocol_version > 0 and outside a passthrough window),
  frames for an SSRC with no user mapping are dropped (after the
  existing sole-member inference) instead of being fed to opus as
  ciphertext.  A non-null session alone is NOT the predicate — the
  session object survives protocol downgrades to 0 and passthrough
  transitions, where plaintext is legitimate.  Passthrough windows
  mirror discord.py's own set_passthrough_mode calls: op 21
  pending-downgrade opens 120s; op 22 opens a 10s grace ONLY on the
  dave_downgraded True -> False edge (upgrade-after-downgrade), with
  replace-semantics so the short grace supersedes a residual long
  window; a receiver starting mid-pending-downgrade seeds the window
  from conn.dave_pending_transitions.
- Silence made visible: decrypt failures keep emitting one warning per
  250 after the first 10, and stop() logs decode-health counters
  (ok / decrypt_failed / dave_unmapped_dropped).

Tests: tests/gateway/test_discord_voice_rekey.py (24) — credential
resolution/rotation, the late-DAVE-session upgrade, failure-streak
refresh + reset, op 4/24/5 hook behavior, original-hook passthrough,
the DAVE-state drop predicate (downgraded-plaintext decodes, op-21
window decodes, expired window drops, upgrade edge replaces the long
window, same-version and duplicate op 22 open nothing,
mid-pending-downgrade startup seeds), unmapped-drop vs mapped flow,
sole-member rescue, tuple-generation consistency, teardown health
line.  Adjacent voice_command / voice_mixer / send / opus suites green
under the diff (111 passed).

Fixes #77968
2026-10-05 16:42:08 -05:00
..