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=(",", ":"),
)