mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
committed by
copyberry
parent
f88ff940c0
commit
13bc770eaf
Generated
+7
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -31,6 +31,7 @@ filegroup(
|
||||
filegroup(
|
||||
name = "build_inputs",
|
||||
srcs = [
|
||||
"assemble_package.py",
|
||||
"build_native.py",
|
||||
":source_inputs",
|
||||
],
|
||||
|
||||
+79
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user