refactor(runtime-install): orchestrate through executor primitives

This commit is contained in:
acrognale-oai
2026-05-26 15:47:45 -04:00
parent 2444692c12
commit 2ac7dc97f3
22 changed files with 1357 additions and 1418 deletions

8
codex-rs/Cargo.lock generated
View File

@@ -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]]

View File

@@ -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 `<plugin>@<marketplace>` 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**).

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<RuntimeInstallProgressNotification>,
cancellation: CancellationToken,
) -> Result<RuntimeInstallResponse, JSONRPCErrorError> {
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::<Vec<_>>();
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(())
}

View File

@@ -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 }

View File

@@ -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<Arg0PathEntryGuard> {
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 {

View File

@@ -187,7 +187,6 @@ struct Inner {
http_body_stream_next_id: AtomicU64,
session_id: std::sync::RwLock<Option<String>>,
codex_home: std::sync::RwLock<Option<AbsolutePathBuf>>,
codex_self_exe: std::sync::RwLock<Option<AbsolutePathBuf>>,
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<AbsolutePathBuf> {
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"),
}),

View File

@@ -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<AbsolutePathBuf, JSONRPCErrorError> {
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<AbsolutePathBuf> {

View File

@@ -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.

View File

@@ -154,10 +154,6 @@ async fn complete_websocket_initialize(websocket: &mut WebSocketStream<TcpStream
std::env::current_dir().expect("current directory"),
)
.expect("absolute current directory"),
codex_self_exe: AbsolutePathBuf::from_absolute_path(
std::env::current_exe().expect("current executable"),
)
.expect("absolute current executable"),
})
.expect("initialize response should serialize"),
}),

View File

@@ -59,7 +59,6 @@ pub(crate) struct ExecServerHandler {
background_task_shutdown: CancellationToken,
background_tasks: TaskTracker,
file_system: FileSystemHandler,
codex_self_exe: codex_utils_absolute_path::AbsolutePathBuf,
initialize_requested: AtomicBool,
initialized: AtomicBool,
}
@@ -77,7 +76,6 @@ impl ExecServerHandler {
active_body_stream_ids: Mutex::new(HashSet::new()),
background_task_shutdown: CancellationToken::new(),
background_tasks: TaskTracker::new(),
codex_self_exe: runtime_paths.codex_self_exe.clone(),
file_system: FileSystemHandler::new(runtime_paths),
initialize_requested: AtomicBool::new(false),
initialized: AtomicBool::new(false),
@@ -132,7 +130,6 @@ impl ExecServerHandler {
Ok(InitializeResponse {
session_id,
codex_home: crate::codex_home::default_codex_home()?,
codex_self_exe: self.codex_self_exe.clone(),
})
}

View File

@@ -1015,7 +1015,6 @@ impl JsonRpcPeer {
InitializeResponse {
session_id: "session-1".to_string(),
codex_home: AbsolutePathBuf::try_from(std::env::current_dir()?)?,
codex_self_exe: AbsolutePathBuf::try_from(std::env::current_exe()?)?,
},
)
.await?;

View File

@@ -14,17 +14,14 @@ workspace = true
[dependencies]
codex-app-server-protocol = { workspace = true }
codex-exec-server = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
futures = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls", "stream"] }
codex-utils-path-uri = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = [
"fs",
"io-std",
"io-util",
"macros",
"process",
"rt",
@@ -32,7 +29,9 @@ tokio = { workspace = true, features = [
] }
tokio-util = { workspace = true, features = ["rt"] }
tracing = { workspace = true }
zip = { workspace = true }
uuid = { workspace = true, features = ["v7"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
sha2 = { workspace = true }
tempfile = { workspace = true }

View File

@@ -0,0 +1,383 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_exec_server::Environment;
use codex_exec_server::ExecBackend;
use codex_exec_server::ExecEnvPolicy;
use codex_exec_server::ExecOutputStream;
use codex_exec_server::ExecParams;
use codex_exec_server::ExecProcessEvent;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::ProcessId;
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::errors::internal_error;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RuntimeArchiveFormat {
TarXz,
Zip,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum TargetPlatform {
Unix,
Windows,
}
impl TargetPlatform {
pub(crate) fn runtime_name(self) -> &'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<dyn ExecBackend>,
filesystem: Arc<dyn ExecutorFileSystem>,
cwd: PathUri,
}
impl RuntimeExecutor {
pub(crate) async fn new(environment: &Environment) -> Result<Self, JSONRPCErrorError> {
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<dyn ExecutorFileSystem> {
Arc::clone(&self.filesystem)
}
pub(crate) async fn discover_target(
&self,
cancellation: &CancellationToken,
) -> Result<InstallTarget, JSONRPCErrorError> {
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<String, JSONRPCErrorError> {
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<Vec<String>, 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<String>,
cancellation: &CancellationToken,
operation: &str,
) -> Result<String, JSONRPCErrorError> {
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<InstallTarget, JSONRPCErrorError> {
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")
}

View File

@@ -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<dyn Error + Send + Sync>> {
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<dyn Error + Send + Sync>> {
stdout
.write_all(serde_json::to_string(&message)?.as_bytes())
.await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)
);
}

View File

@@ -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;

View File

@@ -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<u32>,
pub(crate) bundle_version: Option<String>,
bundled_plugins: Option<Vec<String>>,
bundled_skills: Option<Vec<String>>,
skills_to_remove: Option<Vec<String>>,
}
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<String, JSONRPCErrorError> {
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<RuntimeArchiveFormat, JSONRPCErrorError> {
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<Path>) -> 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<Option<InstalledRuntimeMetadata>, 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<u32>,
platform: TargetPlatform,
) -> Result<RuntimeInstallPaths, JSONRPCErrorError> {
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<AbsolutePathBuf, JSONRPCErrorError> {
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<String>,
suffix: &[&str],
) -> Result<Vec<AbsolutePathBuf>, 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, JSONRPCErrorError> {
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()
))),
}
}

View File

@@ -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(&notification);
assert_eq!(target, ServerNotificationThreadTarget::Global);
}
#[test]
fn warning_notifications_route_to_threads_when_thread_id_is_present() {
let thread_id = ThreadId::new();

View File

@@ -220,6 +220,7 @@ impl ChatWidget {
| ServerNotification::ThreadRealtimeTranscriptDone(_)
| ServerNotification::WindowsWorldWritableWarning(_)
| ServerNotification::WindowsSandboxSetupCompleted(_)
| ServerNotification::RuntimeInstallProgress(_)
| ServerNotification::AccountLoginCompleted(_) => {}
ServerNotification::ContextCompacted(_) => {}
}