diff --git a/codex-rs/code-mode/src/remote_session.rs b/codex-rs/code-mode/src/remote_session.rs index af7d27845b7b..ae0a82e6dcca 100644 --- a/codex-rs/code-mode/src/remote_session.rs +++ b/codex-rs/code-mode/src/remote_session.rs @@ -22,6 +22,7 @@ use tokio::sync::Semaphore; use tokio::sync::watch; use self::connection::Connection; +use self::connection::ConnectionError; use self::connection::RemoteSession; use self::connection::SessionCleanup; use crate::NoopCodeModeSessionDelegate; @@ -34,30 +35,32 @@ type ShutdownResultReceiver = watch::Receiver>>; /// Creates code-mode sessions backed by one lazily spawned process host. pub struct ProcessOwnedCodeModeSessionProvider { - host_program: PathBuf, - process_host: StdMutex>>, + state: StdMutex, +} + +enum ProviderState { + OwnedProcess(Arc), + InProcess, } impl ProcessOwnedCodeModeSessionProvider { pub fn with_host_program(host_program: PathBuf) -> Self { Self { - host_program, - process_host: StdMutex::new(None), + state: StdMutex::new(ProviderState::OwnedProcess(Arc::new( + OwnedProcessHost::new(host_program), + ))), } } - fn process_host(&self) -> Arc { - let mut process_host = self - .process_host + fn process_host(&self) -> Option> { + match &*self + .state .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(process_host) = process_host.as_ref() { - return Arc::clone(process_host); + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + ProviderState::OwnedProcess(process_host) => Some(Arc::clone(process_host)), + ProviderState::InProcess => None, } - - let new_process_host = Arc::new(OwnedProcessHost::new(self.host_program.clone())); - *process_host = Some(Arc::clone(&new_process_host)); - new_process_host } } @@ -72,8 +75,28 @@ impl CodeModeSessionProvider for ProcessOwnedCodeModeSessionProvider { &'a self, delegate: Arc, ) -> CodeModeSessionProviderFuture<'a> { - let session = ProcessOwnedCodeModeSession::with_process_host(delegate, self.process_host()); Box::pin(async move { + let Some(process_host) = self.process_host() else { + let session: Arc = + Arc::new(crate::InProcessCodeModeSession::with_delegate(delegate)); + return Ok(session); + }; + + match process_host.connection().await { + Ok(_) => {} + Err(error) if error.host_program_not_found() => { + *self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + ProviderState::InProcess; + let session: Arc = + Arc::new(crate::InProcessCodeModeSession::with_delegate(delegate)); + return Ok(session); + } + Err(error) => return Err(error.to_string()), + } + let session = ProcessOwnedCodeModeSession::with_process_host(delegate, process_host); session.connection().await?; let session: Arc = Arc::new(session); Ok(session) @@ -98,16 +121,14 @@ impl OwnedProcessHost { } } - async fn connection(&self) -> Result, String> { + async fn connection(&self) -> Result, ConnectionError> { if let Some(connection) = self.live_connection() { return Ok(connection); } - let _spawn_permit = self - .spawn_permit - .acquire() - .await - .map_err(|_| "code-mode host spawn coordinator closed".to_string())?; + let _spawn_permit = self.spawn_permit.acquire().await.map_err(|_| { + ConnectionError::Other("code-mode host spawn coordinator closed".into()) + })?; if let Some(connection) = self.live_connection() { return Ok(connection); } @@ -284,7 +305,7 @@ impl SessionInner { cleanup, }) } - Err(err) => Err(err), + Err(err) => Err(err.to_string()), }; { let mut state = self diff --git a/codex-rs/code-mode/src/remote_session/connection.rs b/codex-rs/code-mode/src/remote_session/connection.rs index 7154784def5f..18ba5e09743c 100644 --- a/codex-rs/code-mode/src/remote_session/connection.rs +++ b/codex-rs/code-mode/src/remote_session/connection.rs @@ -1,4 +1,7 @@ +use std::fmt; +use std::io; use std::path::Path; +use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -46,6 +49,39 @@ mod reader; const IPC_CHANNEL_CAPACITY: usize = 128; const HOST_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); +pub(super) enum ConnectionError { + Spawn { + host_program: PathBuf, + error: io::Error, + }, + Other(String), +} + +impl ConnectionError { + pub(super) fn host_program_not_found(&self) -> bool { + matches!( + self, + Self::Spawn { error, .. } if error.kind() == io::ErrorKind::NotFound + ) + } +} + +impl fmt::Display for ConnectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Spawn { + host_program, + error, + } => write!( + formatter, + "failed to spawn code-mode host {}: {error}", + host_program.display() + ), + Self::Other(message) => formatter.write_str(message), + } + } +} + pub(super) struct Connection { command_tx: mpsc::Sender, execute_claim_tx: mpsc::UnboundedSender, @@ -96,7 +132,7 @@ impl Drop for CallerCancellation { } impl Connection { - pub(super) async fn spawn(host_program: &Path) -> Result { + pub(super) async fn spawn(host_program: &Path) -> Result { let mut command = Command::new(host_program); #[cfg(unix)] command.process_group(0); @@ -106,11 +142,9 @@ impl Connection { .stderr(Stdio::piped()) .kill_on_drop(true) .spawn() - .map_err(|err| { - format!( - "failed to spawn code-mode host {}: {err}", - host_program.display() - ) + .map_err(|error| ConnectionError::Spawn { + host_program: host_program.to_path_buf(), + error, })?; if let Some(stderr) = child.stderr.take() { @@ -132,11 +166,11 @@ impl Connection { let stdin = child .stdin .take() - .ok_or_else(|| "spawned code-mode host has no stdin".to_string())?; + .ok_or_else(|| ConnectionError::Other("spawned code-mode host has no stdin".into()))?; let stdout = child .stdout .take() - .ok_or_else(|| "spawned code-mode host has no stdout".to_string())?; + .ok_or_else(|| ConnectionError::Other("spawned code-mode host has no stdout".into()))?; let mut reader = FramedReader::new(stdout); let mut writer = FramedWriter::new(stdin); let handshake = async { @@ -174,12 +208,14 @@ impl Connection { Ok(result) => result, Err(_) => { kill_and_reap(&mut child).await; - return Err("timed out negotiating with the code-mode host".to_string()); + return Err(ConnectionError::Other( + "timed out negotiating with the code-mode host".into(), + )); } }; if let Err(err) = handshake_result { kill_and_reap(&mut child).await; - return Err(err); + return Err(ConnectionError::Other(err)); } let (command_tx, command_rx) = mpsc::channel(IPC_CHANNEL_CAPACITY); diff --git a/codex-rs/code-mode/src/remote_session_tests.rs b/codex-rs/code-mode/src/remote_session_tests.rs index 03d75e5239be..e48ac2d71ef0 100644 --- a/codex-rs/code-mode/src/remote_session_tests.rs +++ b/codex-rs/code-mode/src/remote_session_tests.rs @@ -3,6 +3,10 @@ use std::path::PathBuf; use std::sync::Arc; use codex_code_mode_protocol::CodeModeSessionProvider; +use codex_code_mode_protocol::ExecuteRequest; +use codex_code_mode_protocol::FunctionCallOutputContentItem; +use codex_code_mode_protocol::RuntimeResponse; +use pretty_assertions::assert_eq; use super::ProcessOwnedCodeModeSession; use super::ProcessOwnedCodeModeSessionProvider; @@ -13,8 +17,8 @@ use crate::NoopCodeModeSessionDelegate; fn provider_reuses_its_live_process_host() { let provider = ProcessOwnedCodeModeSessionProvider::default(); - let first = provider.process_host(); - let second = provider.process_host(); + let first = provider.process_host().expect("owned process host"); + let second = provider.process_host().expect("owned process host"); assert!(Arc::ptr_eq(&first, &second)); } @@ -68,18 +72,39 @@ fn host_program_falls_back_to_its_name_when_main_executable_is_unknown() { } #[tokio::test] -async fn provider_reports_host_spawn_failure() { +async fn provider_falls_back_to_in_process_session_when_host_is_missing() { let provider = ProcessOwnedCodeModeSessionProvider::with_host_program( "codex-code-mode-host-does-not-exist".into(), ); - let error = provider + let session = provider .create_session(Arc::new(NoopCodeModeSessionDelegate)) .await - .err() - .expect("session creation should fail"); + .expect("missing host should fall back to an in-process session"); + let response = session + .execute(ExecuteRequest { + tool_call_id: "call-1".to_string(), + enabled_tools: Vec::new(), + source: "text('fallback')".to_string(), + yield_time_ms: None, + max_output_tokens: None, + }) + .await + .expect("execute fallback session") + .initial_response() + .await + .expect("read fallback response"); - assert!(error.contains("failed to spawn code-mode host")); + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: codex_code_mode_protocol::CellId::new("1".to_string()), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "fallback".to_string(), + }], + error_text: None, + } + ); } #[tokio::test] diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index ad746a1de243..bde373de94b1 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -367,7 +367,9 @@ mod tests { use crate::tools::context::ToolPayload; use codex_code_mode::CodeModeToolKind; use codex_code_mode::ExecuteRequest; + use codex_code_mode::FunctionCallOutputContentItem as CodeModeOutputContentItem; use codex_code_mode::ProcessOwnedCodeModeSessionProvider; + use codex_code_mode::RuntimeResponse; use codex_protocol::models::FunctionCallOutputContentItem; use codex_tools::ToolName; use serde_json::json; @@ -426,26 +428,37 @@ mod tests { } #[tokio::test] - async fn missing_process_host_is_reported_without_failing_service_creation() { + async fn missing_process_host_falls_back_to_in_process_session() { let service = CodeModeService::new(Arc::new( ProcessOwnedCodeModeSessionProvider::with_host_program( "codex-code-mode-host-does-not-exist".into(), ), )); - let error = service + let response = service .execute(ExecuteRequest { tool_call_id: "call-1".to_string(), enabled_tools: Vec::new(), - source: "text('unreachable')".to_string(), + source: "text('fallback')".to_string(), yield_time_ms: None, max_output_tokens: None, }) .await - .err() - .expect("missing host should reject execution"); + .expect("missing host should fall back to an in-process session") + .initial_response() + .await + .expect("read fallback response"); - assert!(error.contains("failed to spawn code-mode host")); - service.shutdown().await.expect("shutdown unused service"); + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: codex_code_mode::CellId::new("1".to_string()), + content_items: vec![CodeModeOutputContentItem::InputText { + text: "fallback".to_string(), + }], + error_text: None, + } + ); + service.shutdown().await.expect("shutdown service"); } } diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 3e9d76931086..f5b0dcc0f20d 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -230,7 +230,7 @@ async fn run_code_mode_turn_with_builder( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn missing_process_host_returns_a_tool_error() -> Result<()> { +async fn missing_process_host_falls_back_to_in_process_code_mode() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -244,16 +244,15 @@ async fn missing_process_host_returns_a_tool_error() -> Result<()> { .expect("code mode should be enabled"); }); let (_test, follow_up_mock) = - run_code_mode_turn_with_builder(&server, "Run code mode", "text('unreachable')", builder) + run_code_mode_turn_with_builder(&server, "Run code mode", "text('fallback')", builder) .await?; - let output = follow_up_mock - .single_request() - .custom_tool_call_output("call-1"); - assert!( - output["output"] - .as_str() - .is_some_and(|output| output.contains("failed to spawn code-mode host")) + assert_eq!( + text_item( + &custom_tool_output_items(&follow_up_mock.single_request(), "call-1"), + /*index*/ 1, + ), + "fallback" ); Ok(()) diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 12b66f958a5e..0c52542a7ab3 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -7,6 +7,7 @@ NON_INTERACTIVE="${CODEX_NON_INTERACTIVE:-false}" BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}" BIN_PATH="$BIN_DIR/codex" +CODE_MODE_HOST_BIN_PATH="$BIN_DIR/codex-code-mode-host" CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}" STANDALONE_ROOT="$CODEX_HOME_DIR/packages/standalone" RELEASES_DIR="$STANDALONE_ROOT/releases" @@ -125,6 +126,113 @@ download_text() { exit 1 } +parse_release_metadata() { + # Bound awk's record size so compact, single-line JSON stays fast on every + # supported awk implementation. JSON strings cannot contain literal newlines, + # so the record boundaries inserted by fold do not change the document. + LC_ALL=C fold -b -w 4096 | LC_ALL=C awk ' + function finish_string(value) { + if (object_depth == 1 && key == "tag_name") { + print "tag_name\t" value + } else if (object_depth == asset_object_depth) { + if (key == "name") { + asset_name = value + } else if (key == "digest") { + asset_digest = value + } + } + + expecting_value = 0 + key = "" + } + + { + for (i = 1; i <= length($0); i++) { + char = substr($0, i, 1) + + if (in_string) { + if (escaped) { + token = token "\\" char + escaped = 0 + } else if (char == "\\") { + escaped = 1 + } else if (char == "\"") { + in_string = 0 + if (string_is_value) { + finish_string(token) + } else { + pending_key = token + } + } else { + token = token char + } + continue + } + + if (char == "\"") { + in_string = 1 + token = "" + escaped = 0 + string_is_value = expecting_value + } else if (char == ":" && pending_key != "") { + key = pending_key + pending_key = "" + expecting_value = 1 + } else if (char == "{") { + object_depth++ + if (assets_array_depth != 0 && + array_depth == assets_array_depth && + asset_object_depth == 0) { + asset_object_depth = object_depth + asset_name = "" + asset_digest = "" + } + expecting_value = 0 + key = "" + } else if (char == "}") { + if (object_depth == asset_object_depth) { + if (asset_name != "" && asset_digest != "") { + print "asset\t" asset_name "\t" asset_digest + } + asset_object_depth = 0 + asset_name = "" + asset_digest = "" + } + object_depth-- + expecting_value = 0 + key = "" + pending_key = "" + } else if (char == "[") { + array_depth++ + if (expecting_value && key == "assets" && object_depth == 1) { + assets_array_depth = array_depth + } + expecting_value = 0 + key = "" + } else if (char == "]") { + if (array_depth == assets_array_depth) { + assets_array_depth = 0 + } + array_depth-- + expecting_value = 0 + key = "" + pending_key = "" + } else if (char == ",") { + expecting_value = 0 + key = "" + pending_key = "" + } + } + } + + END { + if (in_string || object_depth != 0 || array_depth != 0) { + exit 1 + } + } + ' +} + release_url_for_asset() { asset="$1" resolved_version="$2" @@ -156,8 +264,17 @@ resolve_release() { exit 1 fi + if ! release_metadata="$(printf '%s\n' "$release_json" | parse_release_metadata)"; then + echo "Could not parse GitHub release metadata for Codex $requested_release." >&2 + exit 1 + fi + if [ "$normalized_version" = "latest" ]; then - resolved_version="$(printf '%s\n' "$release_json" | sed -n 's/.*"tag_name":[[:space:]]*"rust-v\([^"]*\)".*/\1/p' | head -n 1)" + release_tag="$(printf '%s\n' "$release_metadata" | awk -F '\t' '$1 == "tag_name" { print $2; exit }')" + case "$release_tag" in + rust-v*) resolved_version="${release_tag#rust-v}" ;; + *) resolved_version="" ;; + esac if [ -z "$resolved_version" ]; then echo "Failed to resolve the latest Codex release version." >&2 exit 1 @@ -169,38 +286,10 @@ resolve_release() { release_asset_digest_or_empty() { asset="$1" - digest="$(printf '%s\n' "$release_json" | awk -v asset="$asset" ' - /"name":[[:space:]]*"[^"]+"/ { - name = $0 - sub(/^.*"name":[[:space:]]*"/, "", name) - sub(/".*$/, "", name) - if (name == asset) { - in_asset = 1 - asset_depth = depth - } - } - - in_asset && /"digest":[[:space:]]*"[^"]+"/ { - digest = $0 - sub(/^.*"digest":[[:space:]]*"/, "", digest) - sub(/".*$/, "", digest) - } - - { - line = $0 - opens = gsub(/\{/, "{", line) - closes = gsub(/\}/, "}", line) - depth += opens - closes - - if (in_asset && depth < asset_depth) { - in_asset = 0 - } - } - - END { - if (digest != "") { - print digest - } + digest="$(printf '%s\n' "$release_metadata" | awk -F '\t' -v asset="$asset" ' + $1 == "asset" && $2 == asset { + print $3 + exit } ')" @@ -781,10 +870,23 @@ update_visible_command() { codex_relative_path="$(release_codex_relative_path "$release_dir")" replace_path_with_symlink "$BIN_PATH" "$CURRENT_LINK/$codex_relative_path" "$tmp_link" + + if [ "$os" = "darwin" ] && [ -x "$release_dir/bin/codex-code-mode-host" ]; then + replace_path_with_symlink \ + "$CODE_MODE_HOST_BIN_PATH" \ + "$CURRENT_LINK/bin/codex-code-mode-host" \ + "$tmp_link" + elif [ "$(readlink "$CODE_MODE_HOST_BIN_PATH" 2>/dev/null || true)" = \ + "$CURRENT_LINK/bin/codex-code-mode-host" ]; then + rm -f "$CODE_MODE_HOST_BIN_PATH" + fi } verify_visible_command() { "$BIN_PATH" --version >/dev/null + if [ "$os" = "darwin" ] && [ "$install_layout" = "package" ]; then + [ -x "$CODE_MODE_HOST_BIN_PATH" ] + fi } parse_args "$@" diff --git a/scripts/install/test_install_sh.py b/scripts/install/test_install_sh.py index 556c6213bb01..a805fbf23d68 100644 --- a/scripts/install/test_install_sh.py +++ b/scripts/install/test_install_sh.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 +import hashlib import json import os from pathlib import Path import subprocess +import tarfile import tempfile import textwrap import unittest @@ -60,82 +62,238 @@ def test_latest_release_reuses_version_metadata(self) -> None: ) self.assertIn(f"Resolved version: {VERSION}", result.stdout) + def test_compact_metadata_is_independent_of_field_order(self) -> None: + result, requests = run_installer( + "latest", metadata_json=release_metadata(compact=True, reorder=True) + ) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual( + requests, + [ + "https://api.github.com/repos/openai/codex/releases/latest", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package_SHA256SUMS", + ], + ) + self.assertIn(f"Resolved version: {VERSION}", result.stdout) + + def test_json_like_strings_and_nested_fields_do_not_define_assets(self) -> None: + result, requests = run_installer( + VERSION, metadata_json=legacy_release_metadata_with_decoys() + ) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(len(requests), 2) + self.assertIn("/codex-npm-", requests[1]) + self.assertNotIn("codex-package_SHA256SUMS", requests[1]) + + def test_macos_install_exposes_code_mode_host_beside_codex(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive_path, checksum_path, metadata_json = create_package_release(root) + + result, _requests = run_installer_in( + root, + VERSION, + metadata_json=metadata_json, + archive_path=archive_path, + checksum_path=checksum_path, + force_macos=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + install_bin = root / "install-bin" + current = root / "codex-home" / "packages" / "standalone" / "current" + codex_path = install_bin / "codex" + host_path = install_bin / "codex-code-mode-host" + self.assertEqual(os.readlink(codex_path), str(current / "bin" / "codex")) + self.assertEqual( + os.readlink(host_path), + str(current / "bin" / "codex-code-mode-host"), + ) + self.assertTrue(os.access(host_path, os.X_OK)) + def run_installer( - release: str, *, metadata_failure: bool = False + release: str, + *, + metadata_failure: bool = False, + metadata_json: str | None = None, ) -> tuple[subprocess.CompletedProcess[str], list[str]]: with tempfile.TemporaryDirectory() as temp_dir: - root = Path(temp_dir) - bin_dir = root / "bin" - bin_dir.mkdir() - request_log = root / "requests.log" - fake_curl = bin_dir / "curl" - fake_curl.write_text( - textwrap.dedent( - """\ - #!/bin/sh - url="" - for arg in "$@"; do - case "$arg" in - https://*) url="$arg" ;; - esac - done - printf '%s\n' "$url" >>"$CODEX_TEST_REQUEST_LOG" - - case "$url" in - https://api.github.com/*) - if [ "$CODEX_TEST_METADATA_FAILURE" = "1" ]; then - echo "curl: (22) The requested URL returned error: 403" >&2 - exit 22 - fi - printf '%s\n' "$CODEX_TEST_METADATA_JSON" - ;; - *) - exit 22 - ;; - esac - """ - ), - encoding="utf-8", - ) - fake_curl.chmod(0o755) - - env = os.environ.copy() - env.update( - { - "CODEX_HOME": str(root / "codex-home"), - "CODEX_INSTALL_DIR": str(root / "install-bin"), - "CODEX_NON_INTERACTIVE": "1", - "CODEX_RELEASE": release, - "CODEX_TEST_METADATA_FAILURE": "1" if metadata_failure else "0", - "CODEX_TEST_METADATA_JSON": release_metadata(), - "CODEX_TEST_REQUEST_LOG": str(request_log), - "HOME": str(root / "home"), - "PATH": f"{bin_dir}:/usr/bin:/bin", - "SHELL": "/bin/sh", - } - ) - result = subprocess.run( - ["/bin/sh", str(INSTALL_SCRIPT)], - capture_output=True, - check=False, - env=env, - text=True, - ) - requests = ( - request_log.read_text(encoding="utf-8").splitlines() - if request_log.exists() - else [] + return run_installer_in( + Path(temp_dir), + release, + metadata_failure=metadata_failure, + metadata_json=metadata_json, ) - return result, requests -def release_metadata() -> str: - assets = [ +def run_installer_in( + root: Path, + release: str, + *, + metadata_failure: bool = False, + metadata_json: str | None = None, + archive_path: Path | None = None, + checksum_path: Path | None = None, + force_macos: bool = False, +) -> tuple[subprocess.CompletedProcess[str], list[str]]: + bin_dir = root / "bin" + bin_dir.mkdir() + request_log = root / "requests.log" + fake_curl = bin_dir / "curl" + fake_curl.write_text( + textwrap.dedent( + """\ + #!/bin/sh + url="" + output="" + previous="" + for arg in "$@"; do + case "$arg" in + https://*) url="$arg" ;; + esac + if [ "$previous" = "-o" ]; then + output="$arg" + fi + previous="$arg" + done + printf '%s\n' "$url" >>"$CODEX_TEST_REQUEST_LOG" + + case "$url" in + https://api.github.com/*) + if [ "$CODEX_TEST_METADATA_FAILURE" = "1" ]; then + echo "curl: (22) The requested URL returned error: 403" >&2 + exit 22 + fi + printf '%s\n' "$CODEX_TEST_METADATA_JSON" + ;; + */codex-package_SHA256SUMS) + if [ -n "$CODEX_TEST_CHECKSUM_PATH" ]; then + cp "$CODEX_TEST_CHECKSUM_PATH" "$output" + else + exit 22 + fi + ;; + */codex-package-*.tar.gz) + if [ -n "$CODEX_TEST_ARCHIVE_PATH" ]; then + cp "$CODEX_TEST_ARCHIVE_PATH" "$output" + else + exit 22 + fi + ;; + *) + exit 22 + ;; + esac + """ + ), + encoding="utf-8", + ) + fake_curl.chmod(0o755) + if force_macos: + fake_uname = bin_dir / "uname" + fake_uname.write_text( + "#!/bin/sh\n" + 'case "$1" in\n' + " -s) printf 'Darwin\\n' ;;\n" + " -m) printf 'arm64\\n' ;;\n" + "esac\n", + encoding="utf-8", + ) + fake_uname.chmod(0o755) + + home = root / "home" + home.mkdir() + env = os.environ.copy() + env.update( { - "name": f"codex-package-{target}.tar.gz", - "digest": f"sha256:{'a' * 64}", + "CODEX_HOME": str(root / "codex-home"), + "CODEX_INSTALL_DIR": str(root / "install-bin"), + "CODEX_NON_INTERACTIVE": "1", + "CODEX_RELEASE": release, + "CODEX_TEST_ARCHIVE_PATH": str(archive_path or ""), + "CODEX_TEST_CHECKSUM_PATH": str(checksum_path or ""), + "CODEX_TEST_METADATA_FAILURE": "1" if metadata_failure else "0", + "CODEX_TEST_METADATA_JSON": ( + metadata_json if metadata_json is not None else release_metadata() + ), + "CODEX_TEST_REQUEST_LOG": str(request_log), + "HOME": str(home), + "PATH": f"{bin_dir}:/usr/bin:/bin", + "SHELL": "/bin/sh", } + ) + result = subprocess.run( + ["/bin/sh", str(INSTALL_SCRIPT)], + capture_output=True, + check=False, + env=env, + text=True, + ) + requests = ( + request_log.read_text(encoding="utf-8").splitlines() + if request_log.exists() + else [] + ) + return result, requests + + +def create_package_release(root: Path) -> tuple[Path, Path, str]: + package_dir = root / "package" + (package_dir / "bin").mkdir(parents=True) + (package_dir / "codex-path").mkdir() + (package_dir / "codex-package.json").write_text("{}\n", encoding="utf-8") + write_executable( + package_dir / "bin" / "codex", + f"#!/bin/sh\nprintf 'codex-cli {VERSION}\\n'\n", + ) + write_executable( + package_dir / "bin" / "codex-code-mode-host", + "#!/bin/sh\nexit 0\n", + ) + write_executable(package_dir / "codex-path" / "rg", "#!/bin/sh\nexit 0\n") + + asset = "codex-package-aarch64-apple-darwin.tar.gz" + archive_path = root / asset + with tarfile.open(archive_path, "w:gz") as archive: + for path in package_dir.iterdir(): + archive.add(path, arcname=path.name) + + archive_digest = hashlib.sha256(archive_path.read_bytes()).hexdigest() + checksum_path = root / "codex-package_SHA256SUMS" + checksum_path.write_text(f"{archive_digest} {asset}\n", encoding="utf-8") + checksum_digest = hashlib.sha256(checksum_path.read_bytes()).hexdigest() + metadata_json = json.dumps( + { + "assets": [ + {"name": asset, "digest": f"sha256:{archive_digest}"}, + { + "name": "codex-package_SHA256SUMS", + "digest": f"sha256:{checksum_digest}", + }, + ], + "tag_name": f"rust-v{VERSION}", + }, + indent=2, + ) + return archive_path, checksum_path, metadata_json + + +def write_executable(path: Path, contents: str) -> None: + path.write_text(contents, encoding="utf-8") + path.chmod(0o755) + + +def release_metadata(*, compact: bool = False, reorder: bool = False) -> str: + assets = [ + asset_metadata( + f"codex-package-{target}.tar.gz", + f"sha256:{'a' * 64}", + reorder=reorder, + ) for target in ( "aarch64-apple-darwin", "x86_64-apple-darwin", @@ -144,14 +302,48 @@ def release_metadata() -> str: ) ] assets.append( + asset_metadata( + "codex-package_SHA256SUMS", + f"sha256:{'b' * 64}", + reorder=reorder, + ) + ) + separators = (",", ":") if compact else None + return json.dumps( + {"assets": assets, "body": "braces: { } [ ]", "tag_name": f"rust-v{VERSION}"}, + indent=None if compact else 2, + separators=separators, + ) + + +def asset_metadata(name: str, digest: str, *, reorder: bool) -> dict[str, str]: + if reorder: + return {"digest": digest, "name": name} + return {"name": name, "digest": digest} + + +def legacy_release_metadata_with_decoys() -> str: + fake_digest = f"sha256:{'0' * 64}" + assets = [ { - "name": "codex-package_SHA256SUMS", - "digest": f"sha256:{'b' * 64}", + "metadata": { + "name": "codex-package-x86_64-unknown-linux-musl.tar.gz", + "digest": fake_digest, + }, + "digest": f"sha256:{'c' * 64}", + "name": f"codex-npm-{target}-{VERSION}.tgz", } - ) + for target in ("darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64") + ] return json.dumps( - {"tag_name": f"rust-v{VERSION}", "assets": assets}, - indent=2, + { + "body": ( + f'fake: {{"name":"codex-package_SHA256SUMS","digest":"{fake_digest}"}}' + ), + "assets": assets, + "tag_name": f"rust-v{VERSION}", + }, + separators=(",", ":"), )