Add the voice helper lifecycle foundation (#41897)

## What changed

- Add `codex-realtime-webrtc` with a length-prefixed, 256-byte JSON control protocol for `hello`, `ready`, `close`, and `closed` messages.
- Add a hardened `codex-voice-host` binary that validates protocol and build compatibility, handles orderly shutdown and parent pipe loss, and exposes its build commit.
- Keep this initial foundation limited to process lifecycle management; it does not open audio devices, load native plugins, negotiate WebRTC, or enable voice in the TUI.

## Testing

- Exercise the helper as a subprocess, covering handshake, explicit close, parent pipe loss, malformed frames, incompatible builds, and invalid message order.

GitOrigin-RevId: 6d8d7dd7ba5c4886879882d79314d235f6ab7cf4
This commit is contained in:
Benjamin Carlsson authored and copyberry committed 2026-08-31 19:08:43 +00:00
1 parent 65237aeca0
commit d60560f14e
11 files changed
+358

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
mod protocol;
pub use protocol::MAX_FRAME_BYTES;
pub use protocol::Message;
pub use protocol::decode_frame;
pub use protocol::encode_frame;
pub use protocol::read_message;
+63
View File
@@ -0,0 +1,63 @@
//! Bounded, same-build lifecycle protocol; this stage carries no audio or credentials.
use std::io;
use std::io::Read;
use serde::Deserialize;
use serde::Serialize;
pub const MAX_FRAME_BYTES: usize = 256;
#[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
pub enum Message {
Hello { protocol: u32, build_commit: String },
Ready {},
Close {},
Closed {},
}
pub fn encode_frame(message: &Message) -> io::Result<Vec<u8>> {
let payload = serde_json::to_vec(message)?;
if payload.len() > MAX_FRAME_BYTES {
return Err(io::Error::other("voice frame exceeds limit"));
}
let mut frame = (payload.len() as u32).to_be_bytes().to_vec();
frame.extend(payload);
Ok(frame)
}
pub fn decode_frame(frame: &[u8]) -> io::Result<Option<Message>> {
let [a, b, c, d, ..] = frame else {
return Ok(None);
};
let length = u32::from_be_bytes([*a, *b, *c, *d]) as usize;
if length > MAX_FRAME_BYTES || frame.len() > length + 4 {
return Err(io::Error::other("invalid voice frame length"));
}
frame
.get(4..length + 4)
.map(serde_json::from_slice)
.transpose()
.map_err(|_| io::Error::other("invalid voice frame"))
}
pub fn read_message(reader: &mut impl Read) -> io::Result<Option<Message>> {
let mut header = [0; 4];
match reader.read_exact(&mut header[..1]) {
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
result => result?,
}
reader.read_exact(&mut header[1..])?;
decode_frame(&header)?;
let length = u32::from_be_bytes(header) as usize;
let mut frame = header.to_vec();
frame.resize(length + 4, /*value*/ 0);
reader.read_exact(&mut frame[4..])?;
decode_frame(&frame)
}