From 13bc770eaf0ad8548776bde59c3d6e5316406279 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Mon, 31 Aug 2026 19:10:17 +0000 Subject: [PATCH] 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 --- codex-rs/Cargo.lock | 7 + codex-rs/realtime-webrtc/Cargo.toml | 8 +- codex-rs/realtime-webrtc/src/client.rs | 153 +++++++++++++++++++ codex-rs/realtime-webrtc/src/client_tests.rs | 30 ++++ codex-rs/realtime-webrtc/src/lib.rs | 2 + codex-rs/utils/pty/src/pipe.rs | 16 +- codex-rs/voice-host/Cargo.toml | 2 + codex-rs/voice-host/README.md | 15 +- codex-rs/voice-host/tests/lifecycle.rs | 81 ++++++++++ third_party/voice/BUILD.bazel | 1 + third_party/voice/assemble_package.py | 79 ++++++++++ third_party/voice/test_assemble_package.py | 109 +++++++++++++ 12 files changed, 493 insertions(+), 10 deletions(-) create mode 100644 codex-rs/realtime-webrtc/src/client.rs create mode 100644 codex-rs/realtime-webrtc/src/client_tests.rs create mode 100644 third_party/voice/assemble_package.py create mode 100644 third_party/voice/test_assemble_package.py diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1d54ec0619..63fc5b26e8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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", ] diff --git a/codex-rs/realtime-webrtc/Cargo.toml b/codex-rs/realtime-webrtc/Cargo.toml index 218a5858d1..64e9e81873 100644 --- a/codex-rs/realtime-webrtc/Cargo.toml +++ b/codex-rs/realtime-webrtc/Cargo.toml @@ -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 } diff --git a/codex-rs/realtime-webrtc/src/client.rs b/codex-rs/realtime-webrtc/src/client.rs new file mode 100644 index 0000000000..98e467ff05 --- /dev/null +++ b/codex-rs/realtime-webrtc/src/client.rs @@ -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>, + exit: oneshot::Receiver, +} + +impl VoiceHost { + pub async fn connect(package: &CodexPackageLayout, build_commit: &str) -> Result { + 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) -> HashMap { + 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; diff --git a/codex-rs/realtime-webrtc/src/client_tests.rs b/codex-rs/realtime-webrtc/src/client_tests.rs new file mode 100644 index 0000000000..fe992a5632 --- /dev/null +++ b/codex-rs/realtime-webrtc/src/client_tests.rs @@ -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() + ); +} diff --git a/codex-rs/realtime-webrtc/src/lib.rs b/codex-rs/realtime-webrtc/src/lib.rs index ac68e9311b..dcbe7b49d2 100644 --- a/codex-rs/realtime-webrtc/src/lib.rs +++ b/codex-rs/realtime-webrtc/src/lib.rs @@ -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; diff --git a/codex-rs/utils/pty/src/pipe.rs b/codex-rs/utils/pty/src/pipe.rs index 3715ba72a4..e189c13be6 100644 --- a/codex-rs/utils/pty/src/pipe.rs +++ b/codex-rs/utils/pty/src/pipe.rs @@ -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, @@ -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, args: &[String], cwd: &Path, env: &HashMap, @@ -335,7 +336,7 @@ pub async fn spawn_process( inherited_fds: &[i32], ) -> Result { 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, args: &[String], cwd: &Path, env: &HashMap, @@ -357,7 +359,7 @@ pub async fn spawn_process_no_stdin( inherited_fds: &[i32], ) -> Result { spawn_process_with_stdin_mode( - program, + program.as_ref(), args, cwd, env, diff --git a/codex-rs/voice-host/Cargo.toml b/codex-rs/voice-host/Cargo.toml index e8b7248c51..c3da4ed7b3 100644 --- a/codex-rs/voice-host/Cargo.toml +++ b/codex-rs/voice-host/Cargo.toml @@ -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"] } diff --git a/codex-rs/voice-host/README.md b/codex-rs/voice-host/README.md index 75ff1501c4..ad8780dd37 100644 --- a/codex-rs/voice-host/README.md +++ b/codex-rs/voice-host/README.md @@ -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 `+`. 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. diff --git a/codex-rs/voice-host/tests/lifecycle.rs b/codex-rs/voice-host/tests/lifecycle.rs index 9f6eeb04fb..76baa13a34 100644 --- a/codex-rs/voice-host/tests/lifecycle.rs +++ b/codex-rs/voice-host/tests/lifecycle.rs @@ -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 +} diff --git a/third_party/voice/BUILD.bazel b/third_party/voice/BUILD.bazel index efcc1950fd..ef95130234 100644 --- a/third_party/voice/BUILD.bazel +++ b/third_party/voice/BUILD.bazel @@ -31,6 +31,7 @@ filegroup( filegroup( name = "build_inputs", srcs = [ + "assemble_package.py", "build_native.py", ":source_inputs", ], diff --git a/third_party/voice/assemble_package.py b/third_party/voice/assemble_package.py new file mode 100644 index 0000000000..060da50384 --- /dev/null +++ b/third_party/voice/assemble_package.py @@ -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) diff --git a/third_party/voice/test_assemble_package.py b/third_party/voice/test_assemble_package.py new file mode 100644 index 0000000000..9a759eeadb --- /dev/null +++ b/third_party/voice/test_assemble_package.py @@ -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()