Add installed voice host lifecycle support (#41902)

## What changed

- Add `VoiceHost` to resolve the packaged voice helper, launch it with an
  allowlisted environment, perform the protocol handshake, and enforce bounded
  shutdown and process cleanup.
- Preserve native executable path encoding in the pipe process APIs.
- Add `third_party/voice/assemble_package.py` to create a fresh package copy
  containing a target-compatible helper and a provenance manifest with file
  hashes.

## Testing

- Cover installed helper lifecycle, build matching, missing and symlinked
  helpers, non-UTF-8 package paths, environment filtering, package validation,
  target pairing, and failure cleanup.

GitOrigin-RevId: f893074b36ae6bb9bcedc00fbe7af6bb72745f4c
This commit is contained in:
Benjamin Carlsson
2026-08-31 19:44:44 +00:00
committed by copyberry
parent f88ff940c0
commit 13bc770eaf
12 changed files with 493 additions and 10 deletions
+7
View File
@@ -4051,8 +4051,13 @@ dependencies = [
name = "codex-realtime-webrtc"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-install-context",
"codex-utils-pty",
"pretty_assertions",
"serde",
"serde_json",
"tokio",
]
[[package]]
@@ -4818,10 +4823,12 @@ name = "codex-voice-host"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-install-context",
"codex-process-hardening",
"codex-realtime-webrtc",
"codex-utils-cargo-bin",
"pretty_assertions",
"tempfile",
"tokio",
]
+7 -1
View File
@@ -6,11 +6,17 @@ license.workspace = true
[lib]
doctest = false
test = false
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-install-context = { workspace = true }
codex-utils-pty = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync", "time"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
+153
View File
@@ -0,0 +1,153 @@
//! Resolve only the installed helper and retain the existing owned-process cleanup guard.
use std::collections::HashMap;
use std::ffi::OsString;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use codex_install_context::CodexPackageLayout;
use codex_utils_pty::ProcessHandle;
use codex_utils_pty::SpawnedProcess;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::time::timeout;
use crate::MAX_FRAME_BYTES;
use crate::Message;
use crate::decode_frame;
use crate::encode_frame;
const DEADLINE: Duration = Duration::from_secs(/*secs*/ 5);
/// Owns one helper. Dropping it terminates the process and leaves its waiter to reap it.
/// A successful handshake establishes compatibility only, not an active audio session.
pub struct VoiceHost {
process: ProcessHandle,
output: mpsc::Receiver<Vec<u8>>,
exit: oneshot::Receiver<i32>,
}
impl VoiceHost {
pub async fn connect(package: &CodexPackageLayout, build_commit: &str) -> Result<Self> {
let root = package.package_dir.as_path().canonicalize()?;
let name = if cfg!(windows) {
"codex-voice-host.exe"
} else {
"codex-voice-host"
};
let path = root.join("codex-resources/voice/bin").join(name);
ensure!(
path.canonicalize()? == path,
"voice helper must be inside the physical package"
);
let environment = child_environment(std::env::vars_os());
let SpawnedProcess {
session,
stdout_rx,
stderr_rx,
exit_rx,
} = codex_utils_pty::spawn_pipe_process(
&path,
&[],
&root,
&environment,
/*arg0*/ &None,
&[],
)
.await?;
drop(stderr_rx); // Drain and discard diagnostics rather than logging untyped child output.
let mut host = Self {
process: session,
output: stdout_rx,
exit: exit_rx,
};
host.exchange(
Message::Hello {
protocol: 1,
build_commit: build_commit.to_owned(),
},
Message::Ready {},
)
.await?;
Ok(host)
}
pub async fn close(mut self) -> Result<()> {
let result = self.exchange(Message::Close {}, Message::Closed {}).await;
if result.is_err() {
self.process.terminate();
}
let code = timeout(DEADLINE, &mut self.exit).await??;
result?;
ensure!(code == 0, "voice helper failed during shutdown");
Ok(())
}
async fn exchange(&mut self, request: Message, expected: Message) -> Result<()> {
timeout(DEADLINE, async {
self.process
.writer_sender()
.send(encode_frame(&request)?)
.await
.map_err(|_| anyhow::anyhow!("voice helper input closed"))?;
let mut frame = Vec::new();
loop {
let chunk = self
.output
.recv()
.await
.context("voice helper output closed")?;
ensure!(
frame.len() + chunk.len() <= MAX_FRAME_BYTES + 4,
"voice helper output exceeds limit"
);
frame.extend(chunk);
if let Some(response) = decode_frame(&frame)? {
ensure!(response == expected, "unexpected voice helper response");
return Ok(());
}
}
})
.await?
}
}
fn child_environment(vars: impl Iterator<Item = (OsString, OsString)>) -> HashMap<String, String> {
vars.filter_map(|(key, value)| {
let key = key.into_string().ok()?;
matches!(
key.to_ascii_uppercase().as_str(),
"SYSTEMROOT"
| "WINDIR"
| "HOME"
| "USERPROFILE"
| "LOCALAPPDATA"
| "APPDATA"
| "TEMP"
| "TMP"
| "TMPDIR"
| "XDG_RUNTIME_DIR"
| "PULSE_SERVER"
| "PULSE_COOKIE"
| "PIPEWIRE_REMOTE"
| "DBUS_SESSION_BUS_ADDRESS"
| "HTTP_PROXY"
| "HTTPS_PROXY"
| "ALL_PROXY"
| "NO_PROXY"
| "SSL_CERT_FILE"
| "SSL_CERT_DIR"
| "REQUESTS_CA_BUNDLE"
| "CURL_CA_BUNDLE"
)
.then(|| Some((key, value.into_string().ok()?)))
.flatten()
})
.collect()
}
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;
@@ -0,0 +1,30 @@
//! Child environment admission excludes loader hooks, plugin search paths, and app credentials.
use pretty_assertions::assert_eq;
use super::child_environment;
#[test]
fn forwards_only_explicit_device_network_and_os_inputs() {
let input = [
("SystemRoot", "C:\\Windows"),
("https_proxy", "http://proxy"),
("HOME", "/home/user"),
("LD_PRELOAD", "loader"),
("DYLD_INSERT_LIBRARIES", "loader"),
("PATH", "project"),
("GST_PLUGIN_PATH", "plugins"),
("OPENAI_API_KEY", "secret"),
];
assert_eq!(
child_environment(
input
.into_iter()
.map(|(key, value)| (key.into(), value.into()))
),
input[..3]
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
);
}
+2
View File
@@ -1,5 +1,7 @@
mod client;
mod protocol;
pub use client::VoiceHost;
pub use protocol::MAX_FRAME_BYTES;
pub use protocol::Message;
pub use protocol::decode_frame;
+9 -7
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::ffi::OsStr;
use std::io;
use std::io::ErrorKind;
use std::path::Path;
@@ -131,7 +132,7 @@ enum PipeStdinMode {
/// On Windows, process-tree containment is best-effort because Tokio returns
/// only after the root process starts, so job assignment cannot be atomic.
async fn spawn_process_with_stdin_mode(
program: &str,
program: &OsStr,
args: &[String],
cwd: &Path,
env: &HashMap<String, String>,
@@ -325,9 +326,9 @@ async fn spawn_process_with_stdin_mode(
}
/// Spawn a process using regular pipes and preserve selected inherited file
/// descriptors across exec on Unix.
/// descriptors across exec on Unix. The executable path retains its native encoding.
pub async fn spawn_process(
program: &str,
program: impl AsRef<OsStr>,
args: &[String],
cwd: &Path,
env: &HashMap<String, String>,
@@ -335,7 +336,7 @@ pub async fn spawn_process(
inherited_fds: &[i32],
) -> Result<SpawnedProcess> {
spawn_process_with_stdin_mode(
program,
program.as_ref(),
args,
cwd,
env,
@@ -347,9 +348,10 @@ pub async fn spawn_process(
}
/// Spawn a process using regular pipes, close stdin immediately, and preserve
/// selected inherited file descriptors across exec on Unix.
/// selected inherited file descriptors across exec on Unix. The executable path
/// retains its native encoding.
pub async fn spawn_process_no_stdin(
program: &str,
program: impl AsRef<OsStr>,
args: &[String],
cwd: &Path,
env: &HashMap<String, String>,
@@ -357,7 +359,7 @@ pub async fn spawn_process_no_stdin(
inherited_fds: &[i32],
) -> Result<SpawnedProcess> {
spawn_process_with_stdin_mode(
program,
program.as_ref(),
args,
cwd,
env,
+2
View File
@@ -17,6 +17,8 @@ codex-realtime-webrtc = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
codex-install-context = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["io-util", "macros", "process", "rt-multi-thread", "time"] }
+13 -2
View File
@@ -13,5 +13,16 @@ closed without echoing input. EOF exits even when the main worker cannot progres
Bazel stamps the binary with `STABLE_GIT_COMMIT`. Cargo builders must provide the
same variable; an unstamped source build reports `dev` via `--build-commit` and is
not a distributable build identity. The client/control crate has no native audio
dependencies. Installed-package resolution, native loading, media/privacy controls
and actual audio proof belong to the subsequent integration stages.
dependencies. `VoiceHost` resolves only the physical package's
`codex-resources/voice/bin/codex-voice-host[.exe]`, filters the child environment,
and owns process cleanup through `codex-utils-pty`. Its runtime must remain alive
to reap a dropped helper; explicit `close` waits for process exit.
For private feasibility artifacts, `third_party/voice/assemble_package.py` copies
an existing validated package into a fresh output and adds the helper. Supply
`--package`, `--helper`, `--voice-target`, `--build-commit`, and `--output`.
Linux MUSL apps require same-architecture GNU helpers; other targets must match.
The package version must end in `+<build-commit>`. The manifest records declared
build provenance and file hashes, not authentication or binary architecture proof.
Native loading, media/privacy controls and actual audio proof remain subsequent
integration stages; this assembler does not add native runtime libraries.
+81
View File
@@ -1,11 +1,14 @@
//! Exercise the real helper through installed paths and bounded process I/O.
use std::fs;
use std::process::Stdio;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use codex_install_context::InstallContext;
use codex_realtime_webrtc::Message;
use codex_realtime_webrtc::VoiceHost;
use codex_realtime_webrtc::decode_frame;
use codex_realtime_webrtc::encode_frame;
use codex_utils_cargo_bin::cargo_bin;
@@ -121,3 +124,81 @@ async fn rejects_invalid_input_without_echoing_it() -> Result<()> {
}
Ok(())
}
#[tokio::test]
async fn installed_client_rejects_mixed_builds_and_missing_helper() -> Result<()> {
let directory = tempfile::Builder::new()
.prefix("voice package ")
.tempdir()?;
let bin = directory.path().join("bin");
let helper_dir = directory.path().join("codex-resources/voice/bin");
fs::create_dir_all(&bin)?;
fs::create_dir_all(&helper_dir)?;
fs::write(directory.path().join("codex-package.json"), "{}")?;
let app = bin.join(if cfg!(windows) { "codex.exe" } else { "codex" });
fs::write(&app, [])?;
let source = cargo_bin("codex-voice-host")?;
let helper = helper_dir.join(source.file_name().unwrap());
fs::copy(&source, &helper)?;
let package = InstallContext::from_exe(
/*is_macos*/ cfg!(target_os = "macos"),
Some(&app),
/*method_override*/ None,
)
.package_layout
.unwrap();
VoiceHost::connect(&package, &build_commit().await?)
.await?
.close()
.await?;
assert!(VoiceHost::connect(&package, "wrong-build").await.is_err());
// The same executable elsewhere in the package must not become a fallback.
fs::rename(&helper, bin.join(source.file_name().unwrap()))?;
assert!(
VoiceHost::connect(&package, &build_commit().await?)
.await
.is_err()
);
#[cfg(unix)]
{
std::os::unix::fs::symlink(&source, &helper)?;
assert!(
VoiceHost::connect(&package, &build_commit().await?)
.await
.is_err()
);
}
Ok(())
}
// Linux permits raw-byte filenames; macOS filesystems reject this name themselves.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn installed_client_accepts_non_utf8_package_path() -> Result<()> {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let directory = tempfile::tempdir()?;
let root = directory
.path()
.join(OsString::from_vec(b"voice-\xff".to_vec()));
let bin = root.join("bin");
let helper_dir = root.join("codex-resources/voice/bin");
fs::create_dir_all(&bin)?;
fs::create_dir_all(&helper_dir)?;
fs::write(root.join("codex-package.json"), "{}")?;
let app = bin.join("codex");
fs::write(&app, [])?;
let source = cargo_bin("codex-voice-host")?;
fs::copy(&source, helper_dir.join(source.file_name().unwrap()))?;
let package = InstallContext::from_exe(
/*is_macos*/ cfg!(target_os = "macos"),
Some(&app),
/*method_override*/ None,
)
.package_layout
.context("package layout")?;
VoiceHost::connect(&package, &build_commit().await?)
.await?
.close()
.await
}
+1
View File
@@ -31,6 +31,7 @@ filegroup(
filegroup(
name = "build_inputs",
srcs = [
"assemble_package.py",
"build_native.py",
":source_inputs",
],
+79
View File
@@ -0,0 +1,79 @@
"""Add a lifecycle-only helper to a fresh private copy of a canonical Codex package."""
import argparse
import hashlib
import json
from pathlib import Path
import re
import shutil
def assemble(package: Path, helper: Path, voice_target: str, commit: str, output: Path):
package, helper = package.resolve(strict=True), helper.resolve(strict=True)
output = output.absolute()
if output.exists() or output.is_symlink() or output.resolve().is_relative_to(package):
raise ValueError("output must be fresh and outside the input package")
if not re.fullmatch(r"[0-9a-f]{40}", commit):
raise ValueError("a full build commit is required; dev builds are not distributable")
metadata = json.loads((package / "codex-package.json").read_text())
app_target = metadata["target"]
targets = {
f"{arch}-{suffix}": f"{arch}-{suffix.replace('musl', 'gnu')}"
for arch in ("aarch64", "x86_64")
for suffix in (
"apple-darwin", "unknown-linux-gnu", "unknown-linux-musl", "pc-windows-msvc"
)
}
if targets.get(app_target) != voice_target:
raise ValueError("incompatible app and helper targets")
suffix = ".exe" if app_target.endswith("windows-msvc") else ""
entrypoint = f"bin/codex{suffix}"
expected = {
"layoutVersion": 1, "variant": "codex", "entrypoint": entrypoint,
"resourcesDir": "codex-resources", "pathDir": "codex-path",
}
if any(metadata.get(key) != value for key, value in expected.items()):
raise ValueError("input is not a canonical Codex package")
if not metadata["version"].endswith(f"+{commit}"):
raise ValueError("package version does not match the declared build")
if (package / "codex-resources/voice").exists():
raise ValueError("input already contains voice resources")
for path in package.rglob("*"):
if path.is_symlink() or not (path.is_file() or path.is_dir()):
raise ValueError("package inputs must be regular files or directories")
if not helper.is_file() or not (package / entrypoint).is_file():
raise ValueError("helper and app entrypoint must be regular files")
if not suffix and not helper.stat().st_mode & 0o111:
raise ValueError("helper is not executable")
output.mkdir() # Exclusive creation: never clean or overwrite a pre-existing output.
try:
shutil.copytree(package, output, dirs_exist_ok=True)
relative_helper = f"codex-resources/voice/bin/codex-voice-host{suffix}"
destination = output / relative_helper
destination.parent.mkdir(parents=True)
shutil.copy2(helper, destination)
digests = {}
for relative in (entrypoint, relative_helper):
with (output / relative).open("rb") as source:
digests[relative] = hashlib.file_digest(source, "sha256").hexdigest()
manifest = {
"schemaVersion": 1, "buildCommit": commit, "appTarget": app_target,
"voiceTarget": voice_target, "appVersion": metadata["version"], "sha256": digests,
}
(destination.parent.parent / "manifest.json").write_text(
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
)
except BaseException:
shutil.rmtree(output)
raise
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--package", type=Path, required=True)
parser.add_argument("--helper", type=Path, required=True)
parser.add_argument("--voice-target", required=True)
parser.add_argument("--build-commit", required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
assemble(args.package, args.helper, args.voice_target, args.build_commit, args.output)
+109
View File
@@ -0,0 +1,109 @@
"""Exercise private package copies, target pairing, provenance, and failure cleanup."""
import hashlib
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from assemble_package import assemble
class AssembleTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(prefix="voice package ")
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.package = self.root / "app"
(self.package / "bin").mkdir(parents=True)
(self.package / "codex-resources").mkdir()
(self.package / "codex-path").mkdir()
self.commit = "a" * 40
self.metadata = {
"layoutVersion": 1, "version": f"0.0.0+{self.commit}",
"target": "aarch64-unknown-linux-musl", "variant": "codex",
"entrypoint": "bin/codex", "resourcesDir": "codex-resources", "pathDir": "codex-path",
}
(self.package / "codex-package.json").write_text(json.dumps(self.metadata))
(self.package / "bin/codex").write_bytes(b"unchanged app")
self.helper = self.root / "helper.exe"
self.helper.write_bytes(b"private helper")
self.helper.chmod(0o755)
self.output = self.root / "installed copy"
def test_copies_app_unchanged_and_records_distinct_linux_targets(self):
assemble(self.package, self.helper, "aarch64-unknown-linux-gnu", self.commit, self.output)
self.assertEqual((self.output / "bin/codex").read_bytes(), b"unchanged app")
self.assertEqual((self.package / "bin/codex").read_bytes(), b"unchanged app")
self.assertFalse((self.package / "codex-resources/voice").exists())
self.assertEqual(
(self.output / "codex-package.json").read_bytes(),
(self.package / "codex-package.json").read_bytes(),
)
self.assertEqual(
json.loads((self.output / "codex-resources/voice/manifest.json").read_text()),
{
"schemaVersion": 1, "buildCommit": self.commit,
"appTarget": self.metadata["target"], "voiceTarget": "aarch64-unknown-linux-gnu",
"appVersion": self.metadata["version"], "sha256": {
"bin/codex": hashlib.sha256(b"unchanged app").hexdigest(),
"codex-resources/voice/bin/codex-voice-host": hashlib.sha256(b"private helper").hexdigest(),
},
},
)
def test_rejects_incompatible_targets_and_unstamped_or_mixed_builds(self):
for target, commit in [
("aarch64-unknown-linux-musl", self.commit),
("x86_64-unknown-linux-gnu", self.commit),
("aarch64-unknown-linux-gnu", "dev"),
("aarch64-unknown-linux-gnu", "b" * 40),
]:
with self.subTest(target=target, commit=commit), self.assertRaises(ValueError):
assemble(self.package, self.helper, target, commit, self.output)
self.assertFalse(self.output.exists())
def test_assembles_matching_gnu_linux_app_and_helper_targets(self):
for architecture in ("aarch64", "x86_64"):
target = f"{architecture}-unknown-linux-gnu"
with self.subTest(target=target):
self.metadata["target"] = target
(self.package / "codex-package.json").write_text(json.dumps(self.metadata))
output = self.root / target
assemble(self.package, self.helper, target, self.commit, output)
manifest = json.loads((output / "codex-resources/voice/manifest.json").read_text())
self.assertEqual(
manifest,
{
"schemaVersion": 1, "buildCommit": self.commit,
"appTarget": target, "voiceTarget": target,
"appVersion": self.metadata["version"], "sha256": {
"bin/codex": hashlib.sha256(b"unchanged app").hexdigest(),
"codex-resources/voice/bin/codex-voice-host": hashlib.sha256(b"private helper").hexdigest(),
},
},
)
self.assertEqual((output / "bin/codex").read_bytes(), b"unchanged app")
self.assertEqual(
(output / "codex-resources/voice/bin/codex-voice-host").read_bytes(),
self.helper.read_bytes(),
)
def test_never_replaces_existing_or_nested_outputs(self):
for output in (self.package, self.package / "nested", self.helper):
with self.subTest(output=output), self.assertRaises(ValueError):
assemble(self.package, self.helper, "aarch64-unknown-linux-gnu", self.commit, output)
self.assertEqual(self.helper.read_bytes(), b"private helper")
self.assertFalse((self.package / "nested").exists())
def test_cleans_only_its_new_copy_on_failure(self):
with patch("assemble_package.shutil.copy2", side_effect=OSError("copy failed")):
with self.assertRaises(OSError):
assemble(self.package, self.helper, "aarch64-unknown-linux-gnu", self.commit, self.output)
self.assertFalse(self.output.exists())
self.assertEqual((self.package / "bin/codex").read_bytes(), b"unchanged app")
if __name__ == "__main__":
unittest.main()