diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6a235663be..ced7f23245 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2180,7 +2180,6 @@ dependencies = [ "codex-exec-server", "codex-install-context", "codex-linux-sandbox", - "codex-runtime-install", "codex-sandboxing", "codex-shell-escalation", "codex-utils-absolute-path", @@ -3747,10 +3746,11 @@ name = "codex-runtime-install" version = "0.0.0" dependencies = [ "codex-app-server-protocol", + "codex-exec-server", + "codex-protocol", "codex-utils-absolute-path", - "futures", + "codex-utils-path-uri", "pretty_assertions", - "reqwest 0.12.28", "serde", "serde_json", "sha2 0.10.9", @@ -3758,7 +3758,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", - "zip 2.4.2", + "uuid", ] [[package]] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b6028a0f73..08f5073c55 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -220,7 +220,7 @@ Example with notification opt-out: - `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. - `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). -- `runtime/install` — install the selected primary runtime bundle and return its executable, dependency, bundled-skill, and bundled-marketplace paths after app-server config synchronization. Runtime installs are process-wide and serialized. +- `runtime/install` — install the selected primary runtime bundle and return its executable, dependency, bundled-skill, and bundled-marketplace paths after app-server config synchronization. App-server coordinates the install through the selected environment's generic process and filesystem operations; runtime installs are process-wide and serialized. - `runtime/install/progress` — notification sent to the connection that requested `runtime/install` as the active install moves through checking, downloading, verifying, extracting, validating, installed, and configuring phases. Download notifications include byte counts when available. - `runtime/install/cancel` — cancel the one active `runtime/install` request for this app-server process and report whether an install was found. - `plugin/uninstall` — uninstall a local plugin by `pluginId` in `@` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**). diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 86084cc804..d7a37a4128 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -103,7 +103,6 @@ mod outgoing_message; mod request_processors; mod request_serialization; mod runtime_install; -mod runtime_install_worker; mod server_request_error; mod skills_watcher; mod thread_state; diff --git a/codex-rs/app-server/src/request_processors/runtime_install_processor.rs b/codex-rs/app-server/src/request_processors/runtime_install_processor.rs index 01de9047a5..b39aa36d91 100644 --- a/codex-rs/app-server/src/request_processors/runtime_install_processor.rs +++ b/codex-rs/app-server/src/request_processors/runtime_install_processor.rs @@ -61,7 +61,7 @@ impl RuntimeInstallRequestProcessor { } }); - let install_result = crate::runtime_install_worker::install_runtime_with_progress( + let install_result = codex_runtime_install::install_runtime_with_progress( &environment, params, progress_tx, diff --git a/codex-rs/app-server/src/runtime_install_worker.rs b/codex-rs/app-server/src/runtime_install_worker.rs deleted file mode 100644 index 28de67798b..0000000000 --- a/codex-rs/app-server/src/runtime_install_worker.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::collections::HashMap; - -use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::RuntimeInstallParams; -use codex_app_server_protocol::RuntimeInstallProgressNotification; -use codex_app_server_protocol::RuntimeInstallResponse; -use codex_exec_server::Environment; -use codex_exec_server::ExecEnvPolicy; -use codex_exec_server::ExecOutputStream; -use codex_exec_server::ExecParams; -use codex_exec_server::ExecProcessEvent; -use codex_exec_server::ProcessId; -use codex_exec_server::WriteStatus; -use codex_protocol::config_types::ShellEnvironmentPolicyInherit; -use codex_runtime_install::CODEX_RUNTIME_INSTALL_HELPER_ARG1; -use codex_runtime_install::RuntimeInstallHelperMessage; -use codex_runtime_install::RuntimeInstallHelperRequest; -use codex_utils_path_uri::PathUri; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; -use uuid::Uuid; - -use crate::error_code::internal_error; - -pub(crate) async fn install_runtime_with_progress( - environment: &Environment, - params: RuntimeInstallParams, - progress: mpsc::UnboundedSender, - cancellation: CancellationToken, -) -> Result { - let executable = environment.codex_self_exe().await?; - let cwd = environment.codex_home().await?; - let started = environment - .get_exec_backend() - .start(ExecParams { - process_id: ProcessId::from(format!("runtime-install-{}", Uuid::now_v7())), - argv: vec![ - executable.as_path().to_string_lossy().into_owned(), - CODEX_RUNTIME_INSTALL_HELPER_ARG1.to_string(), - ], - cwd: PathUri::from_abs_path(&cwd), - env_policy: Some(ExecEnvPolicy { - inherit: ShellEnvironmentPolicyInherit::All, - ignore_default_excludes: true, - exclude: Vec::new(), - r#set: HashMap::new(), - include_only: Vec::new(), - }), - env: HashMap::new(), - tty: false, - pipe_stdin: true, - arg0: None, - }) - .await - .map_err(|err| internal_error(format!("failed to start runtime install helper: {err}")))?; - write_helper_request( - started.process.as_ref(), - &RuntimeInstallHelperRequest::Install { params }, - ) - .await?; - - let mut events = started.process.subscribe_events(); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - let mut exit_code = None; - let mut cancellation_sent = false; - loop { - tokio::select! { - _ = cancellation.cancelled(), if !cancellation_sent => { - write_helper_request(started.process.as_ref(), &RuntimeInstallHelperRequest::Cancel).await?; - cancellation_sent = true; - } - event = events.recv() => { - let event = event.map_err(|err| { - internal_error(format!("runtime install helper output stream failed: {err}")) - })?; - match event { - ExecProcessEvent::Output(chunk) => match chunk.stream { - ExecOutputStream::Stdout => { - stdout.extend_from_slice(&chunk.chunk.0); - while let Some(line_end) = stdout.iter().position(|byte| *byte == b'\n') { - let line = stdout.drain(..=line_end).collect::>(); - let message: RuntimeInstallHelperMessage = serde_json::from_slice( - line.strip_suffix(b"\n").unwrap_or(line.as_slice()), - ) - .map_err(|err| { - internal_error(format!("runtime install helper returned invalid output: {err}")) - })?; - match message { - RuntimeInstallHelperMessage::Progress { progress: update } => { - let _ = progress.send(update); - } - RuntimeInstallHelperMessage::Complete { response } => { - return Ok(response); - } - RuntimeInstallHelperMessage::Error { error } => return Err(error), - } - } - } - ExecOutputStream::Stderr => stderr.extend_from_slice(&chunk.chunk.0), - ExecOutputStream::Pty => { - return Err(internal_error("runtime install helper unexpectedly used a pty")); - } - }, - ExecProcessEvent::Exited { exit_code: code, .. } => exit_code = Some(code), - ExecProcessEvent::Closed { .. } => { - let stderr = String::from_utf8_lossy(&stderr); - return Err(internal_error(format!( - "runtime install helper exited without a result (exit code {}; stderr: {stderr})", - exit_code.unwrap_or(-1) - ))); - } - ExecProcessEvent::Failed(message) => { - return Err(internal_error(format!("runtime install helper process failed: {message}"))); - } - } - } - } - } -} - -async fn write_helper_request( - process: &dyn codex_exec_server::ExecProcess, - request: &RuntimeInstallHelperRequest, -) -> Result<(), JSONRPCErrorError> { - let mut encoded = serde_json::to_vec(request).map_err(|err| { - internal_error(format!( - "failed to serialize runtime install request: {err}" - )) - })?; - encoded.push(b'\n'); - let response = process.write(encoded).await.map_err(|err| { - internal_error(format!( - "failed to write runtime install helper input: {err}" - )) - })?; - if response.status != WriteStatus::Accepted { - return Err(internal_error(format!( - "runtime install helper rejected stdin: {:?}", - response.status - ))); - } - Ok(()) -} diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 4dc3d07904..55526b4d06 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -18,7 +18,6 @@ codex-apply-patch = { workspace = true } codex-exec-server = { workspace = true } codex-install-context = { workspace = true } codex-linux-sandbox = { workspace = true } -codex-runtime-install = { workspace = true } codex-sandboxing = { workspace = true } codex-shell-escalation = { workspace = true } codex-utils-absolute-path = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index deb74af206..ba254d57ab 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -7,7 +7,6 @@ use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use codex_exec_server::CODEX_FS_HELPER_ARG1; use codex_install_context::InstallContext; -use codex_runtime_install::CODEX_RUNTIME_INSTALL_HELPER_ARG1; use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_utils_home_dir::find_codex_home; #[cfg(unix)] @@ -100,9 +99,6 @@ pub fn arg0_dispatch() -> Option { if argv1 == CODEX_FS_HELPER_ARG1 { codex_exec_server::run_fs_helper_main(); } - if argv1 == CODEX_RUNTIME_INSTALL_HELPER_ARG1 { - codex_runtime_install::run_runtime_install_helper_main(); - } if argv1 == CODEX_CORE_APPLY_PATCH_ARG1 { let patch_arg = args.next().and_then(|s| s.to_str().map(str::to_owned)); let exit_code = match patch_arg { diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index cc5792c515..4e8d9a40d9 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -187,7 +187,6 @@ struct Inner { http_body_stream_next_id: AtomicU64, session_id: std::sync::RwLock>, codex_home: std::sync::RwLock>, - codex_self_exe: std::sync::RwLock>, reader_task: tokio::task::JoinHandle<()>, } @@ -368,14 +367,6 @@ impl ExecServerClient { .unwrap_or_else(std::sync::PoisonError::into_inner); *codex_home = Some(response.codex_home.clone()); } - { - let mut codex_self_exe = self - .inner - .codex_self_exe - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *codex_self_exe = Some(response.codex_self_exe.clone()); - } self.notify_initialized().await?; Ok(response) }) @@ -534,14 +525,6 @@ impl ExecServerClient { .clone() } - pub fn codex_self_exe(&self) -> Option { - self.inner - .codex_self_exe - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() - } - pub(crate) async fn connect( connection: JsonRpcConnection, options: ExecServerClientConnectOptions, @@ -590,7 +573,6 @@ impl ExecServerClient { http_body_stream_next_id: AtomicU64::new(1), session_id: std::sync::RwLock::new(None), codex_home: std::sync::RwLock::new(None), - codex_self_exe: std::sync::RwLock::new(None), reader_task, } }); @@ -1159,10 +1141,6 @@ mod tests { std::env::current_dir().expect("current dir"), ) .expect("absolute current dir"), - codex_self_exe: AbsolutePathBuf::try_from( - std::env::current_exe().expect("current exe"), - ) - .expect("absolute current exe"), }) .expect("initialize response should serialize"), }), @@ -1198,7 +1176,7 @@ mod tests { program: "sh".to_string(), args: vec![ "-c".to_string(), - "read _line; printf '%s\\n' '{\"id\":1,\"result\":{\"sessionId\":\"stdio-test\",\"codexHome\":\"/tmp\",\"codexSelfExe\":\"/tmp/codex\"}}'; read _line; sleep 60".to_string(), + "read _line; printf '%s\\n' '{\"id\":1,\"result\":{\"sessionId\":\"stdio-test\",\"codexHome\":\"/tmp\"}}'; read _line; sleep 60".to_string(), ], env: HashMap::new(), cwd: None, @@ -1222,7 +1200,7 @@ mod tests { program: "sh".to_string(), args: vec![ "-c".to_string(), - "read _line; printf '%s\\n' '{\"id\":1,\"result\":{\"sessionId\":\"stdio-test\",\"codexHome\":\"/tmp\",\"codexSelfExe\":\"/tmp/codex\"}}'; read _line; sleep 60".to_string(), + "read _line; printf '%s\\n' '{\"id\":1,\"result\":{\"sessionId\":\"stdio-test\",\"codexHome\":\"/tmp\"}}'; read _line; sleep 60".to_string(), ], env: HashMap::new(), cwd: None, @@ -1245,7 +1223,7 @@ mod tests { args: vec![ "-NoProfile".to_string(), "-Command".to_string(), - "$null = [Console]::In.ReadLine(); [Console]::Out.WriteLine('{\"id\":1,\"result\":{\"sessionId\":\"stdio-test\",\"codexHome\":\"C:\\\\Users\\\\codex\\\\.codex\",\"codexSelfExe\":\"C:\\\\codex\\\\codex.exe\"}}'); $null = [Console]::In.ReadLine(); Start-Sleep -Seconds 60".to_string(), + "$null = [Console]::In.ReadLine(); [Console]::Out.WriteLine('{\"id\":1,\"result\":{\"sessionId\":\"stdio-test\",\"codexHome\":\"C:\\\\Users\\\\codex\\\\.codex\"}}'); $null = [Console]::In.ReadLine(); Start-Sleep -Seconds 60".to_string(), ], env: HashMap::new(), cwd: None, @@ -1270,7 +1248,7 @@ mod tests { "read _line; \ echo \"$$\" > {}; \ sleep 60 >/dev/null 2>&1 & echo \"$!\" > {}; \ - printf '%s\\n' '{{\"id\":1,\"result\":{{\"sessionId\":\"stdio-test\",\"codexHome\":\"/tmp\",\"codexSelfExe\":\"/tmp/codex\"}}}}'; \ + printf '%s\\n' '{{\"id\":1,\"result\":{{\"sessionId\":\"stdio-test\",\"codexHome\":\"/tmp\"}}}}'; \ read _line; \ wait", shell_quote(pid_file.as_path()), @@ -1400,10 +1378,6 @@ mod tests { std::env::current_dir().expect("current dir"), ) .expect("absolute current dir"), - codex_self_exe: AbsolutePathBuf::try_from( - std::env::current_exe().expect("current exe"), - ) - .expect("absolute current exe"), }) .expect("initialize response should serialize"), }), @@ -1551,10 +1525,6 @@ mod tests { std::env::current_dir().expect("current dir"), ) .expect("absolute current dir"), - codex_self_exe: AbsolutePathBuf::try_from( - std::env::current_exe().expect("current exe"), - ) - .expect("absolute current exe"), }) .expect("initialize response should serialize"), }), @@ -1696,10 +1666,6 @@ mod tests { std::env::current_dir().expect("current dir"), ) .expect("absolute current dir"), - codex_self_exe: AbsolutePathBuf::try_from( - std::env::current_exe().expect("current exe"), - ) - .expect("absolute current exe"), }) .expect("initialize response should serialize"), }), diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 5e9fbd6ba5..7b93b96ae7 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -531,21 +531,6 @@ impl Environment { .codex_home() .ok_or_else(|| internal_error("remote exec-server did not report a codex home")) } - - pub async fn codex_self_exe(&self) -> Result { - if let Some(client) = self.remote_client.as_ref() { - let client = client.get().await.map_err(exec_server_error_to_jsonrpc)?; - return client.codex_self_exe().ok_or_else(|| { - internal_error("remote exec-server did not report its Codex executable") - }); - } - self.local_runtime_paths - .as_ref() - .map(|runtime_paths| runtime_paths.codex_self_exe.clone()) - .ok_or_else(|| { - internal_error("failed to locate local Codex executable for runtime install") - }) - } } fn default_local_codex_home() -> Option { diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index a2bc0f6a94..4c7451b6d3 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -63,7 +63,6 @@ pub struct InitializeParams { pub struct InitializeResponse { pub session_id: String, pub codex_home: AbsolutePathBuf, - pub codex_self_exe: AbsolutePathBuf, } /// Information about an execution/filesystem environment. diff --git a/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs b/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs index 894f8979f5..622589f09e 100644 --- a/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs +++ b/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs @@ -154,10 +154,6 @@ async fn complete_websocket_initialize(websocket: &mut WebSocketStream &'static str { + match self { + Self::Unix => "unix", + Self::Windows => "win32", + } + } +} + +pub(crate) struct InstallTarget { + pub(crate) install_root: AbsolutePathBuf, + pub(crate) platform: TargetPlatform, +} + +/// Executes runtime installation operations in the selected environment through +/// the generic executor process and filesystem interfaces. +pub(crate) struct RuntimeExecutor { + backend: Arc, + filesystem: Arc, + cwd: PathUri, +} + +impl RuntimeExecutor { + pub(crate) async fn new(environment: &Environment) -> Result { + let codex_home = environment.codex_home().await?; + Ok(Self { + backend: environment.get_exec_backend(), + filesystem: environment.get_filesystem(), + cwd: path_uri(&codex_home), + }) + } + + pub(crate) fn filesystem(&self) -> Arc { + Arc::clone(&self.filesystem) + } + + pub(crate) async fn discover_target( + &self, + cancellation: &CancellationToken, + ) -> Result { + let unix_output = self + .run_command( + vec![ + "sh".to_string(), + "-c".to_string(), + "case \"$(uname -s)\" in CYGWIN*|MINGW*|MSYS*) exit 1;; esac; test -n \"$HOME\" || exit 1; printf 'unix\\n%s/.cache/codex-runtimes\\n' \"$HOME\"".to_string(), + ], + cancellation, + "inspect Unix runtime install environment", + ) + .await; + if let Ok(output) = unix_output { + return parse_install_target(&output); + } + ensure_not_cancelled(cancellation)?; + + let windows_output = self + .run_command( + vec![ + "powershell".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + "if (-not $env:USERPROFILE) { exit 1 }; [Console]::Out.WriteLine('win32'); [Console]::Out.WriteLine([IO.Path]::Combine($env:USERPROFILE, '.cache', 'codex-runtimes'))".to_string(), + ], + cancellation, + "inspect Windows runtime install environment", + ) + .await?; + parse_install_target(&windows_output) + } + + pub(crate) async fn download_archive( + &self, + platform: TargetPlatform, + url: &str, + destination: &AbsolutePathBuf, + cancellation: &CancellationToken, + ) -> Result<(), JSONRPCErrorError> { + let argv = match platform { + TargetPlatform::Unix => vec![ + "curl".to_string(), + "--fail".to_string(), + "--location".to_string(), + "--silent".to_string(), + "--show-error".to_string(), + "--output".to_string(), + destination.display().to_string(), + url.to_string(), + ], + TargetPlatform::Windows => vec![ + "powershell".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + "$ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest -UseBasicParsing -Uri $args[0] -OutFile $args[1]".to_string(), + url.to_string(), + destination.display().to_string(), + ], + }; + self.run_command(argv, cancellation, "download runtime archive") + .await + .map(|_| ()) + } + + pub(crate) async fn archive_checksum( + &self, + platform: TargetPlatform, + archive_path: &AbsolutePathBuf, + cancellation: &CancellationToken, + ) -> Result { + let argv = match platform { + TargetPlatform::Unix => vec![ + "sh".to_string(), + "-c".to_string(), + "if command -v sha256sum >/dev/null 2>&1; then sha256sum \"$1\"; else shasum -a 256 \"$1\"; fi".to_string(), + "runtime-checksum".to_string(), + archive_path.display().to_string(), + ], + TargetPlatform::Windows => vec![ + "powershell".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + "[Console]::Out.WriteLine((Get-FileHash -LiteralPath $args[0] -Algorithm SHA256).Hash)".to_string(), + archive_path.display().to_string(), + ], + }; + let output = self + .run_command(argv, cancellation, "checksum runtime archive") + .await?; + output + .split_whitespace() + .next() + .map(str::to_string) + .ok_or_else(|| internal_error("checksum runtime archive returned no digest")) + } + + pub(crate) async fn list_archive_entries( + &self, + format: RuntimeArchiveFormat, + platform: TargetPlatform, + archive_path: &AbsolutePathBuf, + cancellation: &CancellationToken, + ) -> Result, JSONRPCErrorError> { + let argv = match (format, platform) { + (RuntimeArchiveFormat::TarXz, _) => vec![ + "tar".to_string(), + "-tf".to_string(), + archive_path.display().to_string(), + ], + (RuntimeArchiveFormat::Zip, TargetPlatform::Unix) => vec![ + "unzip".to_string(), + "-Z1".to_string(), + archive_path.display().to_string(), + ], + (RuntimeArchiveFormat::Zip, TargetPlatform::Windows) => vec![ + "powershell".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + "Add-Type -AssemblyName System.IO.Compression.FileSystem; $archive = [IO.Compression.ZipFile]::OpenRead($args[0]); try { $archive.Entries | ForEach-Object { [Console]::Out.WriteLine($_.FullName) } } finally { $archive.Dispose() }".to_string(), + archive_path.display().to_string(), + ], + }; + let output = self + .run_command(argv, cancellation, "list runtime archive") + .await?; + Ok(output + .lines() + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + .collect()) + } + + pub(crate) async fn extract_archive( + &self, + format: RuntimeArchiveFormat, + platform: TargetPlatform, + archive_path: &AbsolutePathBuf, + extract_dir: &AbsolutePathBuf, + cancellation: &CancellationToken, + ) -> Result<(), JSONRPCErrorError> { + let argv = match (format, platform) { + (RuntimeArchiveFormat::TarXz, _) => vec![ + "tar".to_string(), + "-xJf".to_string(), + archive_path.display().to_string(), + "-C".to_string(), + extract_dir.display().to_string(), + ], + (RuntimeArchiveFormat::Zip, TargetPlatform::Unix) => vec![ + "unzip".to_string(), + "-q".to_string(), + archive_path.display().to_string(), + "-d".to_string(), + extract_dir.display().to_string(), + ], + (RuntimeArchiveFormat::Zip, TargetPlatform::Windows) => vec![ + "powershell".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + "Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force".to_string(), + archive_path.display().to_string(), + extract_dir.display().to_string(), + ], + }; + self.run_command(argv, cancellation, "extract runtime archive") + .await + .map(|_| ()) + } + + pub(crate) async fn move_directory( + &self, + platform: TargetPlatform, + source: &AbsolutePathBuf, + destination: &AbsolutePathBuf, + cancellation: &CancellationToken, + ) -> Result<(), JSONRPCErrorError> { + let argv = match platform { + TargetPlatform::Unix => vec![ + "mv".to_string(), + source.display().to_string(), + destination.display().to_string(), + ], + TargetPlatform::Windows => vec![ + "powershell".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + "Move-Item -LiteralPath $args[0] -Destination $args[1]".to_string(), + source.display().to_string(), + destination.display().to_string(), + ], + }; + self.run_command(argv, cancellation, "move runtime directory") + .await + .map(|_| ()) + } + + async fn run_command( + &self, + argv: Vec, + cancellation: &CancellationToken, + operation: &str, + ) -> Result { + ensure_not_cancelled(cancellation)?; + let started = self + .backend + .start(ExecParams { + process_id: ProcessId::from(format!("runtime-install-{}", Uuid::now_v7())), + argv, + cwd: self.cwd.clone(), + env_policy: Some(ExecEnvPolicy { + inherit: ShellEnvironmentPolicyInherit::All, + ignore_default_excludes: true, + exclude: Vec::new(), + r#set: HashMap::new(), + include_only: Vec::new(), + }), + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + }) + .await + .map_err(|err| internal_error(format!("failed to {operation}: {err}")))?; + let mut events = started.process.subscribe_events(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut exit_code = None; + loop { + tokio::select! { + _ = cancellation.cancelled() => { + let _ = started.process.terminate().await; + return Err(runtime_install_canceled()); + } + event = events.recv() => { + let event = event.map_err(|err| internal_error(format!("{operation} output stream failed: {err}")))?; + match event { + ExecProcessEvent::Output(chunk) => match chunk.stream { + ExecOutputStream::Stdout => stdout.extend_from_slice(&chunk.chunk.0), + ExecOutputStream::Stderr | ExecOutputStream::Pty => { + stderr.extend_from_slice(&chunk.chunk.0); + } + }, + ExecProcessEvent::Exited { exit_code: code, .. } => exit_code = Some(code), + ExecProcessEvent::Closed { .. } => { + if exit_code == Some(0) { + return String::from_utf8(stdout).map_err(|err| { + internal_error(format!("{operation} returned invalid UTF-8: {err}")) + }); + } + return Err(internal_error(format!( + "{operation} failed (exit code {}): {}", + exit_code.unwrap_or(-1), + String::from_utf8_lossy(&stderr).trim() + ))); + } + ExecProcessEvent::Failed(message) => { + return Err(internal_error(format!("{operation} process failed: {message}"))); + } + } + } + } + } + } +} + +pub(crate) fn path_uri(path: &AbsolutePathBuf) -> PathUri { + PathUri::from_abs_path(path) +} + +fn parse_install_target(output: &str) -> Result { + let mut lines = output.lines(); + let platform = match lines.next().map(str::trim) { + Some("unix") => TargetPlatform::Unix, + Some("win32") => TargetPlatform::Windows, + _ => { + return Err(internal_error( + "runtime install environment returned invalid platform", + )); + } + }; + let install_root = lines + .next() + .map(str::trim) + .filter(|line| !line.is_empty()) + .ok_or_else(|| internal_error("runtime install environment returned no install root"))?; + let install_root = AbsolutePathBuf::from_absolute_path_checked(PathBuf::from(install_root)) + .map_err(|err| internal_error(format!("runtime install root is not absolute: {err}")))?; + Ok(InstallTarget { + install_root, + platform, + }) +} + +fn ensure_not_cancelled(cancellation: &CancellationToken) -> Result<(), JSONRPCErrorError> { + if cancellation.is_cancelled() { + Err(runtime_install_canceled()) + } else { + Ok(()) + } +} + +pub(crate) fn runtime_install_canceled() -> JSONRPCErrorError { + internal_error("runtime install canceled") +} diff --git a/codex-rs/runtime-install/src/helper.rs b/codex-rs/runtime-install/src/helper.rs deleted file mode 100644 index 16d9baa771..0000000000 --- a/codex-rs/runtime-install/src/helper.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::error::Error; - -use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::RuntimeInstallParams; -use codex_app_server_protocol::RuntimeInstallProgressNotification; -use codex_app_server_protocol::RuntimeInstallResponse; -use serde::Deserialize; -use serde::Serialize; -use tokio::io; -use tokio::io::AsyncBufReadExt; -use tokio::io::AsyncWriteExt; -use tokio::io::BufReader; -use tokio_util::sync::CancellationToken; - -use crate::installer::install_runtime_with_progress; - -pub const CODEX_RUNTIME_INSTALL_HELPER_ARG1: &str = "--codex-run-as-runtime-install-helper"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum RuntimeInstallHelperRequest { - Install { params: RuntimeInstallParams }, - Cancel, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum RuntimeInstallHelperMessage { - Progress { - progress: RuntimeInstallProgressNotification, - }, - Complete { - response: RuntimeInstallResponse, - }, - Error { - error: JSONRPCErrorError, - }, -} - -pub fn main() -> ! { - let exit_code = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(runtime) => match runtime.block_on(run_main()) { - Ok(()) => 0, - Err(err) => { - eprintln!("runtime install helper failed: {err}"); - 1 - } - }, - Err(err) => { - eprintln!("failed to start runtime install helper runtime: {err}"); - 1 - } - }; - std::process::exit(exit_code); -} - -async fn run_main() -> Result<(), Box> { - let mut input = BufReader::new(io::stdin()).lines(); - let request = input - .next_line() - .await? - .ok_or("runtime install helper requires an install request")?; - let RuntimeInstallHelperRequest::Install { params } = serde_json::from_str(&request)? else { - return Err("runtime install helper first request must be install".into()); - }; - - let cancellation = CancellationToken::new(); - let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel(); - let install = install_runtime_with_progress(params, progress_tx, cancellation.clone()); - tokio::pin!(install); - let mut stdout = io::stdout(); - let mut stdin_closed = false; - - loop { - tokio::select! { - request = input.next_line(), if !stdin_closed => { - match request? { - Some(request) => match serde_json::from_str(&request)? { - RuntimeInstallHelperRequest::Cancel => cancellation.cancel(), - RuntimeInstallHelperRequest::Install { .. } => { - return Err("runtime install helper accepts one install request".into()); - } - }, - None => stdin_closed = true - } - } - progress = progress_rx.recv() => { - if let Some(progress) = progress { - write_message(&mut stdout, RuntimeInstallHelperMessage::Progress { progress }).await?; - } - } - response = &mut install => { - let message = match response { - Ok(response) => RuntimeInstallHelperMessage::Complete { response }, - Err(error) => RuntimeInstallHelperMessage::Error { error }, - }; - write_message(&mut stdout, message).await?; - return Ok(()); - } - } - } -} - -async fn write_message( - stdout: &mut io::Stdout, - message: RuntimeInstallHelperMessage, -) -> Result<(), Box> { - stdout - .write_all(serde_json::to_string(&message)?.as_bytes()) - .await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - Ok(()) -} diff --git a/codex-rs/runtime-install/src/installer.rs b/codex-rs/runtime-install/src/installer.rs index 647a0aa1fc..774c98f223 100644 --- a/codex-rs/runtime-install/src/installer.rs +++ b/codex-rs/runtime-install/src/installer.rs @@ -1,50 +1,34 @@ -use std::future::Future; -use std::io::ErrorKind; -use std::path::Path; -use std::path::PathBuf; -use std::process::Stdio; +use std::io; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RuntimeInstallManifestParams; use codex_app_server_protocol::RuntimeInstallParams; -use codex_app_server_protocol::RuntimeInstallPaths; use codex_app_server_protocol::RuntimeInstallProgressNotification; use codex_app_server_protocol::RuntimeInstallProgressPhase; use codex_app_server_protocol::RuntimeInstallResponse; use codex_app_server_protocol::RuntimeInstallStatus; +use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::Environment; +use codex_exec_server::RemoveOptions; use codex_utils_absolute_path::AbsolutePathBuf; -use futures::StreamExt; -use serde::Deserialize; -use sha2::Digest; -use sha2::Sha256; -use tokio::fs; -use tokio::io::AsyncReadExt; -use tokio::io::AsyncWriteExt; -use tokio::process::Command; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; +use uuid::Uuid; use crate::errors::internal_error; use crate::errors::invalid_params; - -const PUBLISHED_ARTIFACT_NAME: &str = "codex-primary-runtime"; -const USER_AGENT: &str = "codex-runtime-installer"; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RuntimeArchiveFormat { - TarXz, - Zip, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct InstalledRuntimeMetadata { - bundle_format_version: Option, - bundle_version: Option, - bundled_plugins: Option>, - bundled_skills: Option>, - skills_to_remove: Option>, -} +use crate::executor::RuntimeExecutor; +use crate::executor::TargetPlatform; +use crate::executor::path_uri; +use crate::executor::runtime_install_canceled; +use crate::validation::absolute_path; +use crate::validation::assert_archive_entries_stay_within_directory; +use crate::validation::default_archive_name; +use crate::validation::read_installed_runtime_metadata; +use crate::validation::runtime_archive_format; +use crate::validation::runtime_root_directory_name; +use crate::validation::validate_manifest; +use crate::validation::validate_runtime_root; pub type RuntimeInstallProgressSender = mpsc::UnboundedSender; @@ -94,18 +78,30 @@ impl RuntimeInstallProgressReporter { } } -pub(crate) async fn install_runtime_with_progress( +pub async fn install_runtime_with_progress( + environment: &Environment, params: RuntimeInstallParams, progress: RuntimeInstallProgressSender, cancellation: CancellationToken, ) -> Result { - let install_root = default_install_root()?; - install_runtime_with_root_and_control(params, install_root, Some(progress), cancellation).await + let executor = RuntimeExecutor::new(environment).await?; + let target = executor.discover_target(&cancellation).await?; + install_runtime_with_root_and_control( + &executor, + params, + target.install_root, + target.platform, + Some(progress), + cancellation, + ) + .await } async fn install_runtime_with_root_and_control( + executor: &RuntimeExecutor, params: RuntimeInstallParams, - install_root: PathBuf, + install_root: AbsolutePathBuf, + platform: TargetPlatform, progress: Option, cancellation: CancellationToken, ) -> Result { @@ -116,146 +112,156 @@ async fn install_runtime_with_root_and_control( .archive_name .clone() .unwrap_or_else(|| default_archive_name(archive_format).to_string()); - validate_path_segment(&archive_name, "archiveName")?; let progress = RuntimeInstallProgressReporter::new(params.manifest.bundle_version.clone(), progress); progress.phase(RuntimeInstallProgressPhase::Checking); ensure_not_cancelled(&cancellation)?; - if let Some(response) = - reuse_current_runtime(¶ms.manifest, &install_root, &progress, &cancellation).await? + if let Some(response) = reuse_current_runtime( + executor, + ¶ms.manifest, + &install_root, + platform, + &progress, + &cancellation, + ) + .await? { return Ok(response); } - let staging_dir = make_staging_dir(&install_root).await?; - let archive_path = staging_dir.join(archive_name); + + let staging_dir = make_staging_dir(executor, &install_root).await?; + let archive_path = absolute_path(staging_dir.as_path().join(archive_name))?; let result = async { progress.download_progress( /*downloaded_bytes*/ 0, params.manifest.archive_size_bytes, ); - download_archive( - ¶ms.manifest.archive_url, - &archive_path, - params.manifest.archive_size_bytes, - &progress, - &cancellation, - ) - .await?; + executor + .download_archive( + platform, + ¶ms.manifest.archive_url, + &archive_path, + &cancellation, + ) + .await?; + if let Some(total_bytes) = params.manifest.archive_size_bytes { + progress.download_progress(total_bytes, Some(total_bytes)); + } install_runtime_from_archive_with_control( + executor, ¶ms.manifest, &archive_path, &install_root, + platform, &progress, &cancellation, ) .await } .await; - let cleanup_result = fs::remove_dir_all(&staging_dir).await; - if let Err(err) = cleanup_result - && err.kind() != ErrorKind::NotFound - { - tracing::warn!( - "failed to remove runtime install staging directory {}: {err}", - staging_dir.display() - ); - } + cleanup_directory(executor, &staging_dir, "runtime install staging directory").await; result } -#[cfg(test)] -async fn install_runtime_from_archive( - manifest: &RuntimeInstallManifestParams, - archive_path: &Path, - install_root: &Path, -) -> Result { - install_runtime_from_archive_with_control( - manifest, - archive_path, - install_root, - &RuntimeInstallProgressReporter::new(manifest.bundle_version.clone(), None), - &CancellationToken::new(), - ) - .await -} - async fn install_runtime_from_archive_with_control( + executor: &RuntimeExecutor, manifest: &RuntimeInstallManifestParams, - archive_path: &Path, - install_root: &Path, + archive_path: &AbsolutePathBuf, + install_root: &AbsolutePathBuf, + platform: TargetPlatform, progress: &RuntimeInstallProgressReporter, cancellation: &CancellationToken, ) -> Result { let runtime_root_directory_name = runtime_root_directory_name(manifest)?; - let installed_runtime_root = install_root.join(&runtime_root_directory_name); - let target_platform = target_platform(); + let installed_runtime_root = + absolute_path(install_root.as_path().join(&runtime_root_directory_name))?; - if let Some(response) = - reuse_current_runtime(manifest, install_root, progress, cancellation).await? + if let Some(response) = reuse_current_runtime( + executor, + manifest, + install_root, + platform, + progress, + cancellation, + ) + .await? { return Ok(response); } - fs::create_dir_all(install_root) - .await - .map_err(|err| internal_error(format!("failed to create runtime install root: {err}")))?; - + create_directory(executor, install_root).await?; progress.phase(RuntimeInstallProgressPhase::Verifying); - verify_archive_checksum( - archive_path, - &manifest.archive_sha256, - &manifest.archive_url, - cancellation, - ) - .await?; + verify_archive_checksum(executor, platform, archive_path, manifest, cancellation).await?; let archive_format = runtime_archive_format(manifest)?; ensure_not_cancelled(cancellation)?; - let staging_dir = make_staging_dir(install_root).await?; + let staging_dir = make_staging_dir(executor, install_root).await?; let result = async { - let extract_dir = staging_dir.join("payload"); - fs::create_dir_all(&extract_dir).await.map_err(|err| { - internal_error(format!("failed to create runtime extract dir: {err}")) - })?; + let extract_dir = absolute_path(staging_dir.as_path().join("payload"))?; + create_directory(executor, &extract_dir).await?; progress.phase(RuntimeInstallProgressPhase::Extracting); ensure_not_cancelled(cancellation)?; - let entries = list_archive_entries(archive_format, archive_path).await?; - assert_archive_entries_stay_within_directory(&entries, &extract_dir)?; + let entries = executor + .list_archive_entries(archive_format, platform, archive_path, cancellation) + .await?; + assert_archive_entries_stay_within_directory(&entries, extract_dir.as_path())?; ensure_not_cancelled(cancellation)?; - extract_archive(archive_format, archive_path, &extract_dir).await?; + executor + .extract_archive( + archive_format, + platform, + archive_path, + &extract_dir, + cancellation, + ) + .await?; - let extracted_runtime_root = extract_dir.join(&runtime_root_directory_name); + let extracted_runtime_root = + absolute_path(extract_dir.as_path().join(&runtime_root_directory_name))?; progress.phase(RuntimeInstallProgressPhase::Validating); ensure_not_cancelled(cancellation)?; validate_runtime_root( + executor, &extracted_runtime_root, manifest.bundle_format_version, - target_platform, + platform, ) .await?; ensure_not_cancelled(cancellation)?; - let previous_runtime_root = - install_root.join(format!("{runtime_root_directory_name}.previous")); - remove_dir_if_exists(&previous_runtime_root).await?; - if path_exists(&installed_runtime_root).await { - fs::rename(&installed_runtime_root, &previous_runtime_root) - .await - .map_err(|err| { - internal_error(format!("failed to move previous runtime aside: {err}")) - })?; + let previous_runtime_root = absolute_path( + install_root + .as_path() + .join(format!("{runtime_root_directory_name}.previous")), + )?; + remove_dir_if_exists(executor, &previous_runtime_root).await?; + if path_exists(executor, &installed_runtime_root).await? { + executor + .move_directory( + platform, + &installed_runtime_root, + &previous_runtime_root, + cancellation, + ) + .await?; } let install_result = async { - fs::rename(&extracted_runtime_root, &installed_runtime_root) - .await - .map_err(|err| internal_error(format!("failed to install runtime: {err}")))?; + executor + .move_directory( + platform, + &extracted_runtime_root, + &installed_runtime_root, + cancellation, + ) + .await?; validate_runtime_root( + executor, &installed_runtime_root, manifest.bundle_format_version, - target_platform, + platform, ) .await } @@ -264,18 +270,21 @@ async fn install_runtime_from_archive_with_control( let paths = match install_result { Ok(paths) => paths, Err(error) => { - remove_dir_if_exists(&installed_runtime_root).await?; - if path_exists(&previous_runtime_root).await { - fs::rename(&previous_runtime_root, &installed_runtime_root) - .await - .map_err(|err| { - internal_error(format!("failed to restore previous runtime: {err}")) - })?; + remove_dir_if_exists(executor, &installed_runtime_root).await?; + if path_exists(executor, &previous_runtime_root).await? { + executor + .move_directory( + platform, + &previous_runtime_root, + &installed_runtime_root, + cancellation, + ) + .await?; } return Err(error); } }; - remove_dir_if_exists(&previous_runtime_root).await?; + remove_dir_if_exists(executor, &previous_runtime_root).await?; Ok(RuntimeInstallResponse { bundle_version: manifest.bundle_version.clone(), paths, @@ -283,15 +292,12 @@ async fn install_runtime_from_archive_with_control( }) } .await; - let cleanup_result = fs::remove_dir_all(&staging_dir).await; - if let Err(err) = cleanup_result - && err.kind() != ErrorKind::NotFound - { - tracing::warn!( - "failed to remove runtime install extraction directory {}: {err}", - staging_dir.display() - ); - } + cleanup_directory( + executor, + &staging_dir, + "runtime install extraction directory", + ) + .await; if result.is_ok() { progress.phase(RuntimeInstallProgressPhase::Installed); } @@ -299,20 +305,28 @@ async fn install_runtime_from_archive_with_control( } async fn reuse_current_runtime( + executor: &RuntimeExecutor, manifest: &RuntimeInstallManifestParams, - install_root: &Path, + install_root: &AbsolutePathBuf, + platform: TargetPlatform, progress: &RuntimeInstallProgressReporter, cancellation: &CancellationToken, ) -> Result, JSONRPCErrorError> { - let installed_runtime_root = install_root.join(runtime_root_directory_name(manifest)?); + let installed_runtime_root = absolute_path( + install_root + .as_path() + .join(runtime_root_directory_name(manifest)?), + )?; ensure_not_cancelled(cancellation)?; if let Some(bundle_version) = manifest.bundle_version.as_ref() - && let Ok(Some(metadata)) = read_installed_runtime_metadata(&installed_runtime_root).await + && let Ok(Some(metadata)) = + read_installed_runtime_metadata(executor, &installed_runtime_root).await && metadata.bundle_version.as_ref() == Some(bundle_version) && let Ok(paths) = validate_runtime_root( + executor, &installed_runtime_root, manifest.bundle_format_version, - target_platform(), + platform, ) .await { @@ -326,209 +340,110 @@ async fn reuse_current_runtime( Ok(None) } -fn default_install_root() -> Result { - let home = std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) - .ok_or_else(|| internal_error("failed to locate home directory for runtime install"))?; - Ok(home.join(".cache").join("codex-runtimes")) +async fn make_staging_dir( + executor: &RuntimeExecutor, + install_root: &AbsolutePathBuf, +) -> Result { + create_directory(executor, install_root).await?; + let staging_dir = absolute_path( + install_root + .as_path() + .join(format!(".codex-runtime-install-{}", Uuid::now_v7())), + )?; + create_directory(executor, &staging_dir).await?; + Ok(staging_dir) } -async fn make_staging_dir(install_root: &Path) -> Result { - fs::create_dir_all(install_root) +async fn verify_archive_checksum( + executor: &RuntimeExecutor, + platform: TargetPlatform, + archive_path: &AbsolutePathBuf, + manifest: &RuntimeInstallManifestParams, + cancellation: &CancellationToken, +) -> Result<(), JSONRPCErrorError> { + let actual_sha256 = executor + .archive_checksum(platform, archive_path, cancellation) + .await?; + if !actual_sha256.eq_ignore_ascii_case(&manifest.archive_sha256) { + return Err(invalid_params(format!( + "checksum mismatch for '{}': expected {}, got {actual_sha256}", + manifest.archive_url, manifest.archive_sha256 + ))); + } + Ok(()) +} + +async fn path_exists( + executor: &RuntimeExecutor, + path: &AbsolutePathBuf, +) -> Result { + match executor + .filesystem() + .get_metadata(&path_uri(path), /*sandbox*/ None) + .await + { + Ok(_) => Ok(true), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(internal_error(format!( + "failed to inspect runtime path {}: {err}", + path.display() + ))), + } +} + +async fn create_directory( + executor: &RuntimeExecutor, + path: &AbsolutePathBuf, +) -> Result<(), JSONRPCErrorError> { + executor + .filesystem() + .create_directory( + &path_uri(path), + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) .await - .map_err(|err| internal_error(format!("failed to create runtime install root: {err}")))?; - tempfile::Builder::new() - .prefix("codex-runtime-install-") - .tempdir_in(install_root) - .map(tempfile::TempDir::keep) .map_err(|err| { internal_error(format!( - "failed to create runtime install staging dir: {err}" + "failed to create runtime directory {}: {err}", + path.display() )) }) } -fn validate_manifest(manifest: &RuntimeInstallManifestParams) -> Result<(), JSONRPCErrorError> { - if manifest.archive_url.trim().is_empty() { - return Err(invalid_params( - "runtime manifest archiveUrl must not be empty", - )); - } - if !is_sha256(&manifest.archive_sha256) { - return Err(invalid_params( - "runtime manifest archiveSha256 must be a 64-character hex digest", - )); - } - if let Some(archive_name) = manifest.archive_name.as_ref() { - validate_path_segment(archive_name, "archiveName")?; - } - if let Some(runtime_root_directory_name) = manifest.runtime_root_directory_name.as_ref() { - validate_path_segment(runtime_root_directory_name, "runtimeRootDirectoryName")?; - } - Ok(()) -} - -fn is_sha256(value: &str) -> bool { - value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) -} - -fn validate_path_segment(value: &str, field_name: &str) -> Result<(), JSONRPCErrorError> { - let value = value.trim(); - if value.is_empty() - || value == "." - || value == ".." - || value.contains('/') - || value.contains('\\') - { - return Err(invalid_params(format!( - "runtime manifest {field_name} must be a single path segment" - ))); - } - Ok(()) -} - -fn runtime_root_directory_name( - manifest: &RuntimeInstallManifestParams, -) -> Result { - let runtime_root_directory_name = manifest - .runtime_root_directory_name - .clone() - .unwrap_or_else(|| PUBLISHED_ARTIFACT_NAME.to_string()); - validate_path_segment(&runtime_root_directory_name, "runtimeRootDirectoryName")?; - Ok(runtime_root_directory_name) -} - -fn runtime_archive_format( - manifest: &RuntimeInstallManifestParams, -) -> Result { - if let Some(format) = manifest.format.as_deref() { - match format.to_ascii_lowercase().as_str() { - "tar.xz" => return Ok(RuntimeArchiveFormat::TarXz), - "zip" => return Ok(RuntimeArchiveFormat::Zip), - _ => { - return Err(invalid_params(format!( - "unsupported runtime archive format: {format}" - ))); - } - } - } - if manifest - .archive_name - .as_deref() - .is_some_and(|name| name.to_ascii_lowercase().ends_with(".zip")) - || manifest.archive_url.to_ascii_lowercase().ends_with(".zip") - { - return Ok(RuntimeArchiveFormat::Zip); - } - Ok(RuntimeArchiveFormat::TarXz) -} - -fn default_archive_name(format: RuntimeArchiveFormat) -> &'static str { - match format { - RuntimeArchiveFormat::TarXz => "node-runtime.tar.xz", - RuntimeArchiveFormat::Zip => "node-runtime.zip", - } -} - -async fn download_archive( - url: &str, - destination: &Path, - expected_size_bytes: Option, - progress: &RuntimeInstallProgressReporter, - cancellation: &CancellationToken, +async fn remove_dir_if_exists( + executor: &RuntimeExecutor, + path: &AbsolutePathBuf, ) -> Result<(), JSONRPCErrorError> { - let response = tokio::select! { - _ = cancellation.cancelled() => return Err(runtime_install_canceled()), - response = reqwest::Client::new() - .get(url) - .header(reqwest::header::USER_AGENT, USER_AGENT) - .send() => response - } - .map_err(|err| internal_error(format!("failed to download runtime archive: {err}")))?; - if !response.status().is_success() { - return Err(internal_error(format!( - "failed to download runtime archive ({} {})", - response.status().as_u16(), - response - .status() - .canonical_reason() - .unwrap_or("unknown status") - ))); - } - - let mut file = fs::File::create(destination) + match executor + .filesystem() + .remove( + &path_uri(path), + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) .await - .map_err(|err| internal_error(format!("failed to create runtime archive file: {err}")))?; - let total_bytes = response.content_length().or(expected_size_bytes); - let mut downloaded_bytes = 0_u64; - let mut stream = response.bytes_stream(); - loop { - let chunk = tokio::select! { - _ = cancellation.cancelled() => return Err(runtime_install_canceled()), - chunk = stream.next() => chunk - }; - let Some(chunk) = chunk else { - break; - }; - let chunk = chunk.map_err(|err| { - internal_error(format!("failed to read runtime archive bytes: {err}")) - })?; - tokio::select! { - _ = cancellation.cancelled() => return Err(runtime_install_canceled()), - result = file.write_all(&chunk) => result - } - .map_err(|err| internal_error(format!("failed to write runtime archive: {err}")))?; - downloaded_bytes += chunk.len() as u64; - progress.download_progress(downloaded_bytes, total_bytes); + { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(internal_error(format!( + "failed to remove runtime directory {}: {err}", + path.display() + ))), } - file.flush() - .await - .map_err(|err| internal_error(format!("failed to flush runtime archive: {err}")))?; - Ok(()) } -async fn verify_archive_checksum( - archive_path: &Path, - expected_sha256: &str, - source_url: &str, - cancellation: &CancellationToken, -) -> Result<(), JSONRPCErrorError> { - let actual_sha256 = compute_sha256_with_cancellation(archive_path, cancellation).await?; - if !actual_sha256.eq_ignore_ascii_case(expected_sha256) { - return Err(invalid_params(format!( - "checksum mismatch for '{source_url}': expected {expected_sha256}, got {actual_sha256}" - ))); +async fn cleanup_directory(executor: &RuntimeExecutor, path: &AbsolutePathBuf, label: &str) { + if let Err(error) = remove_dir_if_exists(executor, path).await { + tracing::warn!( + path = %path.display(), + error = %error.message, + "failed to clean up {label}" + ); } - Ok(()) -} - -#[cfg(test)] -async fn compute_sha256(path: &Path) -> Result { - compute_sha256_with_cancellation(path, &CancellationToken::new()).await -} - -async fn compute_sha256_with_cancellation( - path: &Path, - cancellation: &CancellationToken, -) -> Result { - let mut file = fs::File::open(path) - .await - .map_err(|err| internal_error(format!("failed to open runtime archive: {err}")))?; - let mut digest = Sha256::new(); - let mut buffer = [0_u8; 64 * 1024]; - loop { - let bytes_read = tokio::select! { - _ = cancellation.cancelled() => return Err(runtime_install_canceled()), - bytes_read = file.read(&mut buffer) => bytes_read - } - .map_err(|err| internal_error(format!("failed to read runtime archive: {err}")))?; - if bytes_read == 0 { - break; - } - digest.update(&buffer[..bytes_read]); - } - Ok(format!("{:x}", digest.finalize())) } fn ensure_not_cancelled(cancellation: &CancellationToken) -> Result<(), JSONRPCErrorError> { @@ -539,751 +454,5 @@ fn ensure_not_cancelled(cancellation: &CancellationToken) -> Result<(), JSONRPCE } } -fn runtime_install_canceled() -> JSONRPCErrorError { - internal_error("runtime install canceled") -} - -async fn list_archive_entries( - format: RuntimeArchiveFormat, - archive_path: &Path, -) -> Result, JSONRPCErrorError> { - match format { - RuntimeArchiveFormat::TarXz => list_tar_entries(archive_path).await, - RuntimeArchiveFormat::Zip => list_zip_entries(archive_path).await, - } -} - -async fn extract_archive( - format: RuntimeArchiveFormat, - archive_path: &Path, - extract_dir: &Path, -) -> Result<(), JSONRPCErrorError> { - match format { - RuntimeArchiveFormat::TarXz => extract_tar_archive(archive_path, extract_dir).await, - RuntimeArchiveFormat::Zip => extract_zip_archive(archive_path, extract_dir).await, - } -} - -async fn list_tar_entries(archive_path: &Path) -> Result, JSONRPCErrorError> { - let output = Command::new("tar") - .arg("-tf") - .arg(archive_path) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|err| internal_error(format!("failed to list runtime archive: {err}")))?; - if !output.status.success() { - return Err(invalid_params(format!( - "failed to list runtime archive: {}", - String::from_utf8_lossy(&output.stderr) - ))); - } - Ok(parse_archive_entries(&String::from_utf8_lossy( - &output.stdout, - ))) -} - -async fn extract_tar_archive( - archive_path: &Path, - extract_dir: &Path, -) -> Result<(), JSONRPCErrorError> { - let output = Command::new("tar") - .arg("-xJf") - .arg(archive_path) - .arg("-C") - .arg(extract_dir) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|err| internal_error(format!("failed to extract runtime archive: {err}")))?; - if !output.status.success() { - return Err(invalid_params(format!( - "failed to extract runtime archive: {}", - String::from_utf8_lossy(&output.stderr) - ))); - } - Ok(()) -} - -fn list_zip_entries( - archive_path: &Path, -) -> impl Future, JSONRPCErrorError>> + Send + 'static { - let archive_path = archive_path.to_path_buf(); - async move { - tokio::task::spawn_blocking(move || { - let file = std::fs::File::open(&archive_path).map_err(|err| { - internal_error(format!("failed to open runtime zip archive: {err}")) - })?; - let mut archive = zip::ZipArchive::new(file).map_err(|err| { - invalid_params(format!("failed to read runtime zip archive: {err}")) - })?; - let mut entries = Vec::with_capacity(archive.len()); - for index in 0..archive.len() { - let file = archive.by_index(index).map_err(|err| { - invalid_params(format!("failed to read runtime zip entry: {err}")) - })?; - entries.push(file.name().to_string()); - } - Ok(entries) - }) - .await - .map_err(|err| internal_error(format!("failed to join zip listing task: {err}")))? - } -} - -fn extract_zip_archive( - archive_path: &Path, - extract_dir: &Path, -) -> impl Future> + Send + 'static { - let archive_path = archive_path.to_path_buf(); - let extract_dir = extract_dir.to_path_buf(); - async move { - tokio::task::spawn_blocking(move || { - let file = std::fs::File::open(&archive_path).map_err(|err| { - internal_error(format!("failed to open runtime zip archive: {err}")) - })?; - let mut archive = zip::ZipArchive::new(file).map_err(|err| { - invalid_params(format!("failed to read runtime zip archive: {err}")) - })?; - archive.extract(&extract_dir).map_err(|err| { - invalid_params(format!("failed to extract runtime zip archive: {err}")) - })?; - Ok(()) - }) - .await - .map_err(|err| internal_error(format!("failed to join zip extraction task: {err}")))? - } -} - -fn parse_archive_entries(stdout: &str) -> Vec { - stdout - .lines() - .map(str::trim) - .filter(|entry| !entry.is_empty()) - .map(str::to_string) - .collect() -} - -fn assert_archive_entries_stay_within_directory( - entries: &[String], - extract_dir: &Path, -) -> Result<(), JSONRPCErrorError> { - let resolved_extract_dir = normalize_path(extract_dir); - for entry in entries { - let resolved_entry_path = normalize_path(extract_dir.join(entry)); - if resolved_entry_path != resolved_extract_dir - && !resolved_entry_path.starts_with(&resolved_extract_dir) - { - return Err(invalid_params(format!( - "archive entry '{entry}' would extract outside target" - ))); - } - } - Ok(()) -} - -fn normalize_path(path: impl AsRef) -> PathBuf { - let mut normalized = PathBuf::new(); - for component in path.as_ref().components() { - match component { - std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - normalized.pop(); - } - _ => normalized.push(component.as_os_str()), - } - } - normalized -} - -async fn read_installed_runtime_metadata( - runtime_root: &Path, -) -> Result, JSONRPCErrorError> { - let raw = match fs::read_to_string(runtime_root.join("runtime.json")).await { - Ok(raw) => raw, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), - Err(err) => { - return Err(internal_error(format!( - "failed to read installed runtime metadata: {err}" - ))); - } - }; - serde_json::from_str(&raw) - .map(Some) - .map_err(|err| invalid_params(format!("failed to parse installed runtime metadata: {err}"))) -} - -async fn validate_runtime_root( - runtime_root: &Path, - manifest_bundle_format_version: Option, - target_platform: &str, -) -> Result { - let metadata = read_installed_runtime_metadata(runtime_root) - .await? - .ok_or_else(|| invalid_params("runtime metadata is missing"))?; - let bundle_format_version = manifest_bundle_format_version - .or(metadata.bundle_format_version) - .unwrap_or(1); - let node_root = if bundle_format_version >= 2 { - runtime_root.join("dependencies").join("node") - } else { - runtime_root.to_path_buf() - }; - let node_path = node_root - .join("bin") - .join(node_executable_name(target_platform)); - let node_modules_path = node_root.join("node_modules"); - require_runtime_file(&node_path, "node executable").await?; - require_runtime_directory(&node_modules_path, "node modules directory").await?; - let python_path = - find_python_path(runtime_root, bundle_format_version, target_platform).await?; - let bundled_plugin_marketplace_paths = runtime_contained_paths( - runtime_root, - metadata.bundled_plugins.unwrap_or_default(), - &[], - )?; - let bundled_skill_paths = runtime_contained_paths( - runtime_root, - metadata.bundled_skills.unwrap_or_default(), - &["SKILL.md"], - )?; - - Ok(RuntimeInstallPaths { - bundled_plugin_marketplace_paths, - bundled_skill_paths, - node_modules_path: absolute_path(node_modules_path)?, - node_path: absolute_path(node_path)?, - python_path: absolute_path(python_path)?, - skills_to_remove: metadata.skills_to_remove.unwrap_or_default(), - }) -} - -async fn find_python_path( - runtime_root: &Path, - bundle_format_version: u32, - target_platform: &str, -) -> Result { - let python_root = if bundle_format_version >= 2 { - runtime_root.join("dependencies").join("python") - } else { - runtime_root.join("python") - }; - let executable_name = python_executable_name(target_platform); - let candidates = if target_platform == "win32" { - vec![ - python_root.join(executable_name), - python_root.join("python").join(executable_name), - python_root.join("bin").join(executable_name), - ] - } else { - vec![ - python_root.join("bin").join(executable_name), - python_root.join("bin").join("python"), - ] - }; - for candidate in &candidates { - match fs::metadata(candidate).await { - Ok(metadata) if metadata.is_file() => return Ok(candidate.clone()), - Ok(_) => {} - Err(err) if err.kind() == ErrorKind::NotFound => {} - Err(err) => { - return Err(internal_error(format!( - "failed to inspect runtime python executable {}: {err}", - candidate.display() - ))); - } - } - } - Err(invalid_params(format!( - "runtime python executable is missing under {}", - python_root.display() - ))) -} - -fn runtime_contained_paths( - runtime_root: &Path, - directories: Vec, - suffix: &[&str], -) -> Result, JSONRPCErrorError> { - directories - .into_iter() - .map(|directory| { - let mut path = runtime_root.join(directory); - for segment in suffix { - path.push(segment); - } - let normalized_runtime_root = normalize_path(runtime_root); - let normalized_path = normalize_path(&path); - if normalized_path != normalized_runtime_root - && normalized_path.starts_with(&normalized_runtime_root) - { - absolute_path(path) - } else { - Err(invalid_params( - "runtime-contained path must stay within the runtime root", - )) - } - }) - .collect() -} - -fn absolute_path(path: PathBuf) -> Result { - AbsolutePathBuf::from_absolute_path_checked(path) - .map_err(|err| internal_error(format!("runtime path is not absolute: {err}"))) -} - -fn target_platform() -> &'static str { - if cfg!(target_os = "windows") { - "win32" - } else if cfg!(target_os = "macos") { - "darwin" - } else { - "linux" - } -} - -fn node_executable_name(target_platform: &str) -> &'static str { - if target_platform == "win32" { - "node.exe" - } else { - "node" - } -} - -fn python_executable_name(target_platform: &str) -> &'static str { - if target_platform == "win32" { - "python.exe" - } else { - "python3" - } -} - -async fn path_exists(path: &Path) -> bool { - fs::metadata(path).await.is_ok() -} - -async fn require_runtime_file(path: &Path, label: &str) -> Result<(), JSONRPCErrorError> { - match fs::metadata(path).await { - Ok(metadata) if metadata.is_file() => Ok(()), - Ok(_) => Err(invalid_params(format!( - "runtime {label} is not a file: {}", - path.display() - ))), - Err(err) if err.kind() == ErrorKind::NotFound => Err(invalid_params(format!( - "runtime {label} is missing: {}", - path.display() - ))), - Err(err) => Err(internal_error(format!( - "failed to inspect runtime {label} {}: {err}", - path.display() - ))), - } -} - -async fn require_runtime_directory(path: &Path, label: &str) -> Result<(), JSONRPCErrorError> { - match fs::metadata(path).await { - Ok(metadata) if metadata.is_dir() => Ok(()), - Ok(_) => Err(invalid_params(format!( - "runtime {label} is not a directory: {}", - path.display() - ))), - Err(err) if err.kind() == ErrorKind::NotFound => Err(invalid_params(format!( - "runtime {label} is missing: {}", - path.display() - ))), - Err(err) => Err(internal_error(format!( - "failed to inspect runtime {label} {}: {err}", - path.display() - ))), - } -} - -async fn remove_dir_if_exists(path: &Path) -> Result<(), JSONRPCErrorError> { - match fs::remove_dir_all(path).await { - Ok(()) => Ok(()), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => Err(internal_error(format!( - "failed to remove runtime directory {}: {err}", - path.display() - ))), - } -} - #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn archive_traversal_entries_are_rejected() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let entries = vec![ - "codex-primary-runtime/runtime.json".to_string(), - "../x".to_string(), - ]; - - let error = assert_archive_entries_stay_within_directory(&entries, temp_dir.path()) - .expect_err("entry should be rejected"); - - assert!(error.message.contains("would extract outside target")); - } - - #[tokio::test] - async fn install_from_archive_reuses_current_runtime() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let install_root = temp_dir.path().join("install"); - let runtime_root = install_root.join(PUBLISHED_ARTIFACT_NAME); - create_runtime_root(&runtime_root, "v1").await; - let archive_path = temp_dir.path().join("unused.tar.xz"); - fs::write(&archive_path, b"not used") - .await - .expect("write archive"); - let manifest = manifest_for_archive(&archive_path, "v1").await; - - let response = install_runtime_from_archive(&manifest, &archive_path, &install_root) - .await - .expect("install should succeed"); - - assert_eq!(response.status, RuntimeInstallStatus::AlreadyCurrent); - assert_eq!(response.bundle_version.as_deref(), Some("v1")); - assert_eq!( - response.paths.node_path, - absolute_path( - runtime_root - .join("dependencies") - .join("node") - .join("bin") - .join(node_executable_name(target_platform())) - ) - .expect("absolute path") - ); - } - - #[tokio::test] - async fn install_runtime_reuses_current_runtime_without_downloading_archive() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let install_root = temp_dir.path().join("install"); - let runtime_root = install_root.join(PUBLISHED_ARTIFACT_NAME); - create_runtime_root(&runtime_root, "v1").await; - let archive_path = temp_dir.path().join("unused.tar.xz"); - fs::write(&archive_path, b"not used") - .await - .expect("write archive"); - let mut manifest = manifest_for_archive(&archive_path, "v1").await; - manifest.archive_url = "not a valid archive URL".to_string(); - - let response = install_runtime_with_root_and_control( - RuntimeInstallParams { - environment_id: None, - manifest: Box::new(manifest), - release: "primary".to_string(), - }, - install_root, - /*progress*/ None, - CancellationToken::new(), - ) - .await - .expect("installed runtime should be reused without downloading"); - - assert_eq!(response.status, RuntimeInstallStatus::AlreadyCurrent); - } - - #[tokio::test] - async fn install_from_archive_uses_runtime_metadata_bundle_format_when_manifest_omits_it() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let install_root = temp_dir.path().join("install"); - let runtime_root = install_root.join(PUBLISHED_ARTIFACT_NAME); - create_runtime_root(&runtime_root, "v1").await; - let archive_path = temp_dir.path().join("unused.tar.xz"); - fs::write(&archive_path, b"not used") - .await - .expect("write archive"); - let mut manifest = manifest_for_archive(&archive_path, "v1").await; - manifest.bundle_format_version = None; - - let response = install_runtime_from_archive(&manifest, &archive_path, &install_root) - .await - .expect("install should succeed"); - - assert_eq!( - response.paths.node_modules_path, - absolute_path( - runtime_root - .join("dependencies") - .join("node") - .join("node_modules") - ) - .expect("absolute path") - ); - assert_eq!( - response.paths.python_path, - absolute_path( - runtime_root - .join("dependencies") - .join("python") - .join("bin") - .join(python_executable_name(target_platform())) - ) - .expect("absolute path") - ); - } - - #[tokio::test] - async fn validate_runtime_root_rejects_missing_node_executable() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let runtime_root = temp_dir.path().join(PUBLISHED_ARTIFACT_NAME); - create_runtime_root(&runtime_root, "v1").await; - fs::remove_file( - runtime_root - .join("dependencies") - .join("node") - .join("bin") - .join(node_executable_name(target_platform())), - ) - .await - .expect("remove node"); - - let error = validate_runtime_root(&runtime_root, Some(2), target_platform()) - .await - .expect_err("node executable should be required"); - - assert!(error.message.contains("node executable is missing")); - } - - #[tokio::test] - async fn validate_runtime_root_rejects_missing_node_modules_directory() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let runtime_root = temp_dir.path().join(PUBLISHED_ARTIFACT_NAME); - create_runtime_root(&runtime_root, "v1").await; - fs::remove_dir( - runtime_root - .join("dependencies") - .join("node") - .join("node_modules"), - ) - .await - .expect("remove node_modules"); - - let error = validate_runtime_root(&runtime_root, Some(2), target_platform()) - .await - .expect_err("node_modules directory should be required"); - - assert!(error.message.contains("node modules directory is missing")); - } - - #[tokio::test] - async fn validate_runtime_root_rejects_missing_python_executable() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let runtime_root = temp_dir.path().join(PUBLISHED_ARTIFACT_NAME); - create_runtime_root(&runtime_root, "v1").await; - fs::remove_file( - runtime_root - .join("dependencies") - .join("python") - .join("bin") - .join(python_executable_name(target_platform())), - ) - .await - .expect("remove python"); - - let error = validate_runtime_root(&runtime_root, Some(2), target_platform()) - .await - .expect_err("python executable should be required"); - - assert!(error.message.contains("python executable is missing")); - } - - #[tokio::test] - async fn install_from_archive_rejects_checksum_mismatch() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let archive_path = temp_dir.path().join("archive.tar.xz"); - fs::write(&archive_path, b"archive") - .await - .expect("write archive"); - let manifest = RuntimeInstallManifestParams { - archive_name: None, - archive_sha256: "0".repeat(64), - archive_size_bytes: None, - archive_url: "https://example.com/archive.tar.xz".to_string(), - bundle_format_version: Some(2), - bundle_version: Some("v1".to_string()), - format: Some("tar.xz".to_string()), - runtime_root_directory_name: None, - }; - - let error = install_runtime_from_archive( - &manifest, - &archive_path, - &temp_dir.path().join("install"), - ) - .await - .expect_err("checksum mismatch should fail"); - - assert!(error.message.contains("checksum mismatch")); - } - - #[tokio::test] - async fn install_from_archive_restores_previous_runtime_when_new_runtime_is_invalid() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let install_root = temp_dir.path().join("install"); - let runtime_root = install_root.join(PUBLISHED_ARTIFACT_NAME); - create_runtime_root(&runtime_root, "old").await; - - let payload_root = temp_dir.path().join("payload").join("wrong-root"); - fs::create_dir_all(&payload_root) - .await - .expect("payload root"); - fs::write( - payload_root.join("runtime.json"), - r#"{"bundleFormatVersion":2,"bundleVersion":"new"}"#, - ) - .await - .expect("runtime metadata"); - let archive_path = temp_dir.path().join("invalid.tar.xz"); - create_tar_xz(temp_dir.path().join("payload").as_path(), &archive_path).await; - let manifest = manifest_for_archive(&archive_path, "new").await; - - let error = install_runtime_from_archive(&manifest, &archive_path, &install_root) - .await - .expect_err("invalid runtime should fail"); - - assert!(error.message.contains("runtime metadata is missing")); - let metadata = read_installed_runtime_metadata(&runtime_root) - .await - .expect("read metadata") - .expect("metadata"); - assert_eq!(metadata.bundle_version.as_deref(), Some("old")); - } - - #[tokio::test] - async fn install_from_archive_reports_install_progress() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let payload_root = temp_dir - .path() - .join("payload") - .join(PUBLISHED_ARTIFACT_NAME); - create_runtime_root(&payload_root, "v1").await; - let archive_path = temp_dir.path().join("archive.tar.xz"); - create_tar_xz(temp_dir.path().join("payload").as_path(), &archive_path).await; - let manifest = manifest_for_archive(&archive_path, "v1").await; - let (progress_tx, mut progress_rx) = mpsc::unbounded_channel(); - let progress = - RuntimeInstallProgressReporter::new(manifest.bundle_version.clone(), Some(progress_tx)); - - install_runtime_from_archive_with_control( - &manifest, - &archive_path, - &temp_dir.path().join("install"), - &progress, - &CancellationToken::new(), - ) - .await - .expect("install should succeed"); - - let mut phases = Vec::new(); - while let Ok(notification) = progress_rx.try_recv() { - phases.push(notification.phase); - } - assert_eq!( - phases, - vec![ - RuntimeInstallProgressPhase::Verifying, - RuntimeInstallProgressPhase::Extracting, - RuntimeInstallProgressPhase::Validating, - RuntimeInstallProgressPhase::Installed, - ] - ); - } - - #[tokio::test] - async fn install_from_archive_stops_when_canceled() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let archive_path = temp_dir.path().join("unused.tar.xz"); - fs::write(&archive_path, b"unused") - .await - .expect("write archive"); - let manifest = manifest_for_archive(&archive_path, "v1").await; - let cancellation = CancellationToken::new(); - cancellation.cancel(); - - let error = install_runtime_from_archive_with_control( - &manifest, - &archive_path, - &temp_dir.path().join("install"), - &RuntimeInstallProgressReporter::new(manifest.bundle_version.clone(), None), - &cancellation, - ) - .await - .expect_err("canceled install should fail"); - - assert_eq!(error.message, "runtime install canceled"); - } - - async fn create_runtime_root(runtime_root: &Path, bundle_version: &str) { - let node_bin = runtime_root.join("dependencies").join("node").join("bin"); - let python_bin = runtime_root.join("dependencies").join("python").join("bin"); - fs::create_dir_all(&node_bin).await.expect("node bin"); - fs::create_dir_all( - runtime_root - .join("dependencies") - .join("node") - .join("node_modules"), - ) - .await - .expect("node_modules"); - fs::create_dir_all(&python_bin).await.expect("python bin"); - fs::write( - node_bin.join(node_executable_name(target_platform())), - b"node", - ) - .await - .expect("node"); - fs::write( - python_bin.join(python_executable_name(target_platform())), - b"python", - ) - .await - .expect("python"); - fs::write( - runtime_root.join("runtime.json"), - format!(r#"{{"bundleFormatVersion":2,"bundleVersion":"{bundle_version}"}}"#), - ) - .await - .expect("runtime metadata"); - } - - async fn manifest_for_archive( - archive_path: &Path, - bundle_version: &str, - ) -> RuntimeInstallManifestParams { - RuntimeInstallManifestParams { - archive_name: None, - archive_sha256: compute_sha256(archive_path).await.expect("sha256"), - archive_size_bytes: None, - archive_url: "https://example.com/archive.tar.xz".to_string(), - bundle_format_version: Some(2), - bundle_version: Some(bundle_version.to_string()), - format: Some("tar.xz".to_string()), - runtime_root_directory_name: None, - } - } - - async fn create_tar_xz(payload_dir: &Path, archive_path: &Path) { - let output = Command::new("tar") - .arg("-cJf") - .arg(archive_path) - .arg("-C") - .arg(payload_dir) - .arg(".") - .output() - .await - .expect("tar should run"); - assert!( - output.status.success(), - "tar failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } -} +mod tests; diff --git a/codex-rs/runtime-install/src/installer/tests.rs b/codex-rs/runtime-install/src/installer/tests.rs new file mode 100644 index 0000000000..82e69e8949 --- /dev/null +++ b/codex-rs/runtime-install/src/installer/tests.rs @@ -0,0 +1,332 @@ +use std::path::Path; + +use codex_app_server_protocol::RuntimeInstallManifestParams; +use codex_app_server_protocol::RuntimeInstallParams; +use codex_app_server_protocol::RuntimeInstallProgressPhase; +use codex_app_server_protocol::RuntimeInstallStatus; +use codex_exec_server::Environment; +use pretty_assertions::assert_eq; +use sha2::Digest; +use sha2::Sha256; +use tokio::fs; +use tokio::process::Command; +use tokio_util::sync::CancellationToken; + +use super::*; +use crate::validation::PUBLISHED_ARTIFACT_NAME; +use crate::validation::node_executable_name; +use crate::validation::python_executable_name; + +#[test] +fn archive_traversal_entries_are_rejected() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![ + "codex-primary-runtime/runtime.json".to_string(), + "../x".to_string(), + ]; + + let error = assert_archive_entries_stay_within_directory(&entries, temp_dir.path()) + .expect_err("entry should be rejected"); + + assert!(error.message.contains("would extract outside target")); +} + +#[tokio::test] +async fn install_runtime_reuses_current_runtime_without_downloading_archive() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let executor = test_executor().await; + let install_root = absolute_path(temp_dir.path().join("install")).expect("install root"); + let runtime_root = + absolute_path(install_root.as_path().join(PUBLISHED_ARTIFACT_NAME)).expect("runtime root"); + create_runtime_root(runtime_root.as_path(), "v1").await; + let archive_path = temp_dir.path().join("unused.tar.xz"); + fs::write(&archive_path, b"not used") + .await + .expect("write archive"); + let mut manifest = manifest_for_archive(&archive_path, "v1").await; + manifest.archive_url = "not a valid archive URL".to_string(); + + let response = install_runtime_with_root_and_control( + &executor, + RuntimeInstallParams { + environment_id: None, + manifest: Box::new(manifest), + release: "primary".to_string(), + }, + install_root, + local_platform(), + /*progress*/ None, + CancellationToken::new(), + ) + .await + .expect("installed runtime should be reused without downloading"); + + assert_eq!(response.status, RuntimeInstallStatus::AlreadyCurrent); + assert_eq!(response.bundle_version.as_deref(), Some("v1")); +} + +#[tokio::test] +async fn validate_runtime_root_rejects_missing_node_executable() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let executor = test_executor().await; + let runtime_root = + absolute_path(temp_dir.path().join(PUBLISHED_ARTIFACT_NAME)).expect("runtime root"); + create_runtime_root(runtime_root.as_path(), "v1").await; + fs::remove_file( + runtime_root + .as_path() + .join("dependencies") + .join("node") + .join("bin") + .join(node_executable_name(local_platform().runtime_name())), + ) + .await + .expect("remove node"); + + let error = validate_runtime_root(&executor, &runtime_root, Some(2), local_platform()) + .await + .expect_err("node executable should be required"); + + assert!(error.message.contains("node executable is missing")); +} + +#[tokio::test] +async fn validate_runtime_root_rejects_missing_node_modules_directory() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let executor = test_executor().await; + let runtime_root = + absolute_path(temp_dir.path().join(PUBLISHED_ARTIFACT_NAME)).expect("runtime root"); + create_runtime_root(runtime_root.as_path(), "v1").await; + fs::remove_dir( + runtime_root + .as_path() + .join("dependencies") + .join("node") + .join("node_modules"), + ) + .await + .expect("remove node_modules"); + + let error = validate_runtime_root(&executor, &runtime_root, Some(2), local_platform()) + .await + .expect_err("node_modules directory should be required"); + + assert!(error.message.contains("node modules directory is missing")); +} + +#[tokio::test] +async fn validate_runtime_root_rejects_missing_python_executable() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let executor = test_executor().await; + let runtime_root = + absolute_path(temp_dir.path().join(PUBLISHED_ARTIFACT_NAME)).expect("runtime root"); + create_runtime_root(runtime_root.as_path(), "v1").await; + fs::remove_file( + runtime_root + .as_path() + .join("dependencies") + .join("python") + .join("bin") + .join(python_executable_name(local_platform().runtime_name())), + ) + .await + .expect("remove python"); + + let error = validate_runtime_root(&executor, &runtime_root, Some(2), local_platform()) + .await + .expect_err("python executable should be required"); + + assert!(error.message.contains("python executable is missing")); +} + +#[tokio::test] +async fn install_from_archive_rejects_checksum_mismatch() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let executor = test_executor().await; + let archive_path = absolute_path(temp_dir.path().join("archive.tar.xz")).expect("archive path"); + fs::write(archive_path.as_path(), b"archive") + .await + .expect("write archive"); + let manifest = RuntimeInstallManifestParams { + archive_name: None, + archive_sha256: "0".repeat(64), + archive_size_bytes: None, + archive_url: "https://example.com/archive.tar.xz".to_string(), + bundle_format_version: Some(2), + bundle_version: Some("v1".to_string()), + format: Some("tar.xz".to_string()), + runtime_root_directory_name: None, + }; + let install_root = absolute_path(temp_dir.path().join("install")).expect("install root"); + + let error = install_runtime_from_archive_with_control( + &executor, + &manifest, + &archive_path, + &install_root, + local_platform(), + &RuntimeInstallProgressReporter::new(manifest.bundle_version.clone(), None), + &CancellationToken::new(), + ) + .await + .expect_err("checksum mismatch should fail"); + + assert!(error.message.contains("checksum mismatch")); +} + +#[tokio::test] +async fn install_from_archive_reports_install_progress() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let executor = test_executor().await; + let payload_root = temp_dir + .path() + .join("payload") + .join(PUBLISHED_ARTIFACT_NAME); + create_runtime_root(&payload_root, "v1").await; + let archive_path = absolute_path(temp_dir.path().join("archive.tar.xz")).expect("archive path"); + create_tar_xz(&temp_dir.path().join("payload"), archive_path.as_path()).await; + let manifest = manifest_for_archive(archive_path.as_path(), "v1").await; + let install_root = absolute_path(temp_dir.path().join("install")).expect("install root"); + let (progress_tx, mut progress_rx) = mpsc::unbounded_channel(); + let progress = + RuntimeInstallProgressReporter::new(manifest.bundle_version.clone(), Some(progress_tx)); + + install_runtime_from_archive_with_control( + &executor, + &manifest, + &archive_path, + &install_root, + local_platform(), + &progress, + &CancellationToken::new(), + ) + .await + .expect("install should succeed"); + + let mut phases = Vec::new(); + while let Ok(notification) = progress_rx.try_recv() { + phases.push(notification.phase); + } + assert_eq!( + phases, + vec![ + RuntimeInstallProgressPhase::Verifying, + RuntimeInstallProgressPhase::Extracting, + RuntimeInstallProgressPhase::Validating, + RuntimeInstallProgressPhase::Installed, + ] + ); +} + +#[tokio::test] +async fn install_from_archive_stops_when_canceled() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let executor = test_executor().await; + let archive_path = absolute_path(temp_dir.path().join("unused.tar.xz")).expect("archive path"); + fs::write(archive_path.as_path(), b"unused") + .await + .expect("write archive"); + let manifest = manifest_for_archive(archive_path.as_path(), "v1").await; + let install_root = absolute_path(temp_dir.path().join("install")).expect("install root"); + let cancellation = CancellationToken::new(); + cancellation.cancel(); + + let error = install_runtime_from_archive_with_control( + &executor, + &manifest, + &archive_path, + &install_root, + local_platform(), + &RuntimeInstallProgressReporter::new(manifest.bundle_version.clone(), None), + &cancellation, + ) + .await + .expect_err("canceled install should fail"); + + assert_eq!(error.message, "runtime install canceled"); +} + +async fn test_executor() -> RuntimeExecutor { + RuntimeExecutor::new(&Environment::default_for_tests()) + .await + .expect("test executor") +} + +fn local_platform() -> TargetPlatform { + if cfg!(target_os = "windows") { + TargetPlatform::Windows + } else { + TargetPlatform::Unix + } +} + +async fn create_runtime_root(runtime_root: &Path, bundle_version: &str) { + let node_bin = runtime_root.join("dependencies").join("node").join("bin"); + let python_bin = runtime_root.join("dependencies").join("python").join("bin"); + fs::create_dir_all(&node_bin).await.expect("node bin"); + fs::create_dir_all( + runtime_root + .join("dependencies") + .join("node") + .join("node_modules"), + ) + .await + .expect("node_modules"); + fs::create_dir_all(&python_bin).await.expect("python bin"); + fs::write( + node_bin.join(node_executable_name(local_platform().runtime_name())), + b"node", + ) + .await + .expect("node"); + fs::write( + python_bin.join(python_executable_name(local_platform().runtime_name())), + b"python", + ) + .await + .expect("python"); + fs::write( + runtime_root.join("runtime.json"), + format!(r#"{{"bundleFormatVersion":2,"bundleVersion":"{bundle_version}"}}"#), + ) + .await + .expect("runtime metadata"); +} + +async fn manifest_for_archive( + archive_path: &Path, + bundle_version: &str, +) -> RuntimeInstallManifestParams { + RuntimeInstallManifestParams { + archive_name: None, + archive_sha256: compute_sha256(archive_path).await, + archive_size_bytes: None, + archive_url: "https://example.com/archive.tar.xz".to_string(), + bundle_format_version: Some(2), + bundle_version: Some(bundle_version.to_string()), + format: Some("tar.xz".to_string()), + runtime_root_directory_name: None, + } +} + +async fn compute_sha256(path: &Path) -> String { + let bytes = fs::read(path).await.expect("read archive"); + format!("{:x}", Sha256::digest(bytes)) +} + +async fn create_tar_xz(payload_dir: &Path, archive_path: &Path) { + let output = Command::new("tar") + .arg("-cJf") + .arg(archive_path) + .arg("-C") + .arg(payload_dir) + .arg(".") + .output() + .await + .expect("tar should run"); + assert!( + output.status.success(), + "tar failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/codex-rs/runtime-install/src/lib.rs b/codex-rs/runtime-install/src/lib.rs index 13d9f2b406..9a6cbc7d83 100644 --- a/codex-rs/runtime-install/src/lib.rs +++ b/codex-rs/runtime-install/src/lib.rs @@ -1,8 +1,6 @@ mod errors; -mod helper; +mod executor; mod installer; +mod validation; -pub use helper::CODEX_RUNTIME_INSTALL_HELPER_ARG1; -pub use helper::RuntimeInstallHelperMessage; -pub use helper::RuntimeInstallHelperRequest; -pub use helper::main as run_runtime_install_helper_main; +pub use installer::install_runtime_with_progress; diff --git a/codex-rs/runtime-install/src/validation.rs b/codex-rs/runtime-install/src/validation.rs new file mode 100644 index 0000000000..0a293bcce8 --- /dev/null +++ b/codex-rs/runtime-install/src/validation.rs @@ -0,0 +1,364 @@ +use std::io; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RuntimeInstallManifestParams; +use codex_app_server_protocol::RuntimeInstallPaths; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; + +use crate::errors::internal_error; +use crate::errors::invalid_params; +use crate::executor::RuntimeArchiveFormat; +use crate::executor::RuntimeExecutor; +use crate::executor::TargetPlatform; +use crate::executor::path_uri; + +pub(crate) const PUBLISHED_ARTIFACT_NAME: &str = "codex-primary-runtime"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InstalledRuntimeMetadata { + pub(crate) bundle_format_version: Option, + pub(crate) bundle_version: Option, + bundled_plugins: Option>, + bundled_skills: Option>, + skills_to_remove: Option>, +} + +pub(crate) fn validate_manifest( + manifest: &RuntimeInstallManifestParams, +) -> Result<(), JSONRPCErrorError> { + if manifest.archive_url.trim().is_empty() { + return Err(invalid_params( + "runtime manifest archiveUrl must not be empty", + )); + } + if !is_sha256(&manifest.archive_sha256) { + return Err(invalid_params( + "runtime manifest archiveSha256 must be a 64-character hex digest", + )); + } + if let Some(archive_name) = manifest.archive_name.as_ref() { + validate_path_segment(archive_name, "archiveName")?; + } + if let Some(runtime_root_directory_name) = manifest.runtime_root_directory_name.as_ref() { + validate_path_segment(runtime_root_directory_name, "runtimeRootDirectoryName")?; + } + Ok(()) +} + +fn is_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn validate_path_segment(value: &str, field_name: &str) -> Result<(), JSONRPCErrorError> { + let value = value.trim(); + if value.is_empty() + || value == "." + || value == ".." + || value.contains('/') + || value.contains('\\') + { + return Err(invalid_params(format!( + "runtime manifest {field_name} must be a single path segment" + ))); + } + Ok(()) +} + +pub(crate) fn runtime_root_directory_name( + manifest: &RuntimeInstallManifestParams, +) -> Result { + let runtime_root_directory_name = manifest + .runtime_root_directory_name + .clone() + .unwrap_or_else(|| PUBLISHED_ARTIFACT_NAME.to_string()); + validate_path_segment(&runtime_root_directory_name, "runtimeRootDirectoryName")?; + Ok(runtime_root_directory_name) +} + +pub(crate) fn runtime_archive_format( + manifest: &RuntimeInstallManifestParams, +) -> Result { + if let Some(format) = manifest.format.as_deref() { + match format.to_ascii_lowercase().as_str() { + "tar.xz" => return Ok(RuntimeArchiveFormat::TarXz), + "zip" => return Ok(RuntimeArchiveFormat::Zip), + _ => { + return Err(invalid_params(format!( + "unsupported runtime archive format: {format}" + ))); + } + } + } + if manifest + .archive_name + .as_deref() + .is_some_and(|name| name.to_ascii_lowercase().ends_with(".zip")) + || manifest.archive_url.to_ascii_lowercase().ends_with(".zip") + { + return Ok(RuntimeArchiveFormat::Zip); + } + Ok(RuntimeArchiveFormat::TarXz) +} + +pub(crate) fn default_archive_name(format: RuntimeArchiveFormat) -> &'static str { + match format { + RuntimeArchiveFormat::TarXz => "node-runtime.tar.xz", + RuntimeArchiveFormat::Zip => "node-runtime.zip", + } +} + +pub(crate) fn assert_archive_entries_stay_within_directory( + entries: &[String], + extract_dir: &Path, +) -> Result<(), JSONRPCErrorError> { + let resolved_extract_dir = normalize_path(extract_dir); + for entry in entries { + let resolved_entry_path = normalize_path(extract_dir.join(entry)); + if resolved_entry_path != resolved_extract_dir + && !resolved_entry_path.starts_with(&resolved_extract_dir) + { + return Err(invalid_params(format!( + "archive entry '{entry}' would extract outside target" + ))); + } + } + Ok(()) +} + +fn normalize_path(path: impl AsRef) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.as_ref().components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + _ => normalized.push(component.as_os_str()), + } + } + normalized +} + +pub(crate) async fn read_installed_runtime_metadata( + executor: &RuntimeExecutor, + runtime_root: &AbsolutePathBuf, +) -> Result, JSONRPCErrorError> { + let metadata_path = absolute_path(runtime_root.as_path().join("runtime.json"))?; + let raw = match executor + .filesystem() + .read_file_text(&path_uri(&metadata_path), /*sandbox*/ None) + .await + { + Ok(raw) => raw, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(internal_error(format!( + "failed to read installed runtime metadata: {err}" + ))); + } + }; + serde_json::from_str(&raw) + .map(Some) + .map_err(|err| invalid_params(format!("failed to parse installed runtime metadata: {err}"))) +} + +pub(crate) async fn validate_runtime_root( + executor: &RuntimeExecutor, + runtime_root: &AbsolutePathBuf, + manifest_bundle_format_version: Option, + platform: TargetPlatform, +) -> Result { + let metadata = read_installed_runtime_metadata(executor, runtime_root) + .await? + .ok_or_else(|| invalid_params("runtime metadata is missing"))?; + let bundle_format_version = manifest_bundle_format_version + .or(metadata.bundle_format_version) + .unwrap_or(1); + let node_root = if bundle_format_version >= 2 { + runtime_root.as_path().join("dependencies").join("node") + } else { + runtime_root.as_path().to_path_buf() + }; + let node_path = absolute_path( + node_root + .join("bin") + .join(node_executable_name(platform.runtime_name())), + )?; + let node_modules_path = absolute_path(node_root.join("node_modules"))?; + require_runtime_file(executor, &node_path, "node executable").await?; + require_runtime_directory(executor, &node_modules_path, "node modules directory").await?; + let python_path = + find_python_path(executor, runtime_root, bundle_format_version, platform).await?; + let bundled_plugin_marketplace_paths = runtime_contained_paths( + runtime_root, + metadata.bundled_plugins.unwrap_or_default(), + &[], + )?; + let bundled_skill_paths = runtime_contained_paths( + runtime_root, + metadata.bundled_skills.unwrap_or_default(), + &["SKILL.md"], + )?; + + Ok(RuntimeInstallPaths { + bundled_plugin_marketplace_paths, + bundled_skill_paths, + node_modules_path, + node_path, + python_path, + skills_to_remove: metadata.skills_to_remove.unwrap_or_default(), + }) +} + +async fn find_python_path( + executor: &RuntimeExecutor, + runtime_root: &AbsolutePathBuf, + bundle_format_version: u32, + platform: TargetPlatform, +) -> Result { + let python_root = if bundle_format_version >= 2 { + runtime_root.as_path().join("dependencies").join("python") + } else { + runtime_root.as_path().join("python") + }; + let executable_name = python_executable_name(platform.runtime_name()); + let candidates = if platform == TargetPlatform::Windows { + vec![ + python_root.join(executable_name), + python_root.join("python").join(executable_name), + python_root.join("bin").join(executable_name), + ] + } else { + vec![ + python_root.join("bin").join(executable_name), + python_root.join("bin").join("python"), + ] + }; + for candidate in candidates { + let candidate = absolute_path(candidate)?; + match executor + .filesystem() + .get_metadata(&path_uri(&candidate), /*sandbox*/ None) + .await + { + Ok(metadata) if metadata.is_file => return Ok(candidate), + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => { + return Err(internal_error(format!( + "failed to inspect runtime python executable {}: {err}", + candidate.display() + ))); + } + } + } + Err(invalid_params(format!( + "runtime python executable is missing under {}", + python_root.display() + ))) +} + +fn runtime_contained_paths( + runtime_root: &AbsolutePathBuf, + directories: Vec, + suffix: &[&str], +) -> Result, JSONRPCErrorError> { + directories + .into_iter() + .map(|directory| { + let mut path = runtime_root.as_path().join(directory); + for segment in suffix { + path.push(segment); + } + let normalized_runtime_root = normalize_path(runtime_root.as_path()); + let normalized_path = normalize_path(&path); + if normalized_path != normalized_runtime_root + && normalized_path.starts_with(&normalized_runtime_root) + { + absolute_path(path) + } else { + Err(invalid_params( + "runtime-contained path must stay within the runtime root", + )) + } + }) + .collect() +} + +pub(crate) fn absolute_path(path: PathBuf) -> Result { + AbsolutePathBuf::from_absolute_path_checked(path) + .map_err(|err| internal_error(format!("runtime path is not absolute: {err}"))) +} + +pub(crate) fn node_executable_name(target_platform: &str) -> &'static str { + if target_platform == "win32" { + "node.exe" + } else { + "node" + } +} + +pub(crate) fn python_executable_name(target_platform: &str) -> &'static str { + if target_platform == "win32" { + "python.exe" + } else { + "python3" + } +} + +async fn require_runtime_file( + executor: &RuntimeExecutor, + path: &AbsolutePathBuf, + label: &str, +) -> Result<(), JSONRPCErrorError> { + match executor + .filesystem() + .get_metadata(&path_uri(path), /*sandbox*/ None) + .await + { + Ok(metadata) if metadata.is_file => Ok(()), + Ok(_) => Err(invalid_params(format!( + "runtime {label} is not a file: {}", + path.display() + ))), + Err(err) if err.kind() == io::ErrorKind::NotFound => Err(invalid_params(format!( + "runtime {label} is missing: {}", + path.display() + ))), + Err(err) => Err(internal_error(format!( + "failed to inspect runtime {label} {}: {err}", + path.display() + ))), + } +} + +async fn require_runtime_directory( + executor: &RuntimeExecutor, + path: &AbsolutePathBuf, + label: &str, +) -> Result<(), JSONRPCErrorError> { + match executor + .filesystem() + .get_metadata(&path_uri(path), /*sandbox*/ None) + .await + { + Ok(metadata) if metadata.is_directory => Ok(()), + Ok(_) => Err(invalid_params(format!( + "runtime {label} is not a directory: {}", + path.display() + ))), + Err(err) if err.kind() == io::ErrorKind::NotFound => Err(invalid_params(format!( + "runtime {label} is missing: {}", + path.display() + ))), + Err(err) => Err(internal_error(format!( + "failed to inspect runtime {label} {}: {err}", + path.display() + ))), + } +} diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index fa2bab0111..9568548b08 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -172,6 +172,7 @@ pub(super) fn server_notification_thread_target( | ServerNotification::FsChanged(_) | ServerNotification::WindowsWorldWritableWarning(_) | ServerNotification::WindowsSandboxSetupCompleted(_) + | ServerNotification::RuntimeInstallProgress(_) | ServerNotification::AccountLoginCompleted(_) => None, }; @@ -193,6 +194,8 @@ mod tests { use codex_app_server_protocol::GuardianWarningNotification; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; + use codex_app_server_protocol::RuntimeInstallProgressNotification; + use codex_app_server_protocol::RuntimeInstallProgressPhase; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadSettings; use codex_app_server_protocol::ThreadSettingsUpdatedNotification; @@ -242,6 +245,21 @@ mod tests { assert_eq!(target, ServerNotificationThreadTarget::Global); } + #[test] + fn runtime_install_progress_notifications_are_global() { + let notification = + ServerNotification::RuntimeInstallProgress(RuntimeInstallProgressNotification { + bundle_version: Some("v1".to_string()), + downloaded_bytes: None, + phase: RuntimeInstallProgressPhase::Checking, + total_bytes: None, + }); + + let target = server_notification_thread_target(¬ification); + + assert_eq!(target, ServerNotificationThreadTarget::Global); + } + #[test] fn warning_notifications_route_to_threads_when_thread_id_is_present() { let thread_id = ThreadId::new(); diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index c418160de7..f799180fee 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -220,6 +220,7 @@ impl ChatWidget { | ServerNotification::ThreadRealtimeTranscriptDone(_) | ServerNotification::WindowsWorldWritableWarning(_) | ServerNotification::WindowsSandboxSetupCompleted(_) + | ServerNotification::RuntimeInstallProgress(_) | ServerNotification::AccountLoginCompleted(_) => {} ServerNotification::ContextCompacted(_) => {} }