mirror of
https://github.com/openai/codex.git
synced 2026-09-04 15:08:45 +00:00
refactor(runtime-install): orchestrate through executor primitives
This commit is contained in:
8
codex-rs/Cargo.lock
generated
8
codex-rs/Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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**).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
383
codex-rs/runtime-install/src/executor.rs
Normal file
383
codex-rs/runtime-install/src/executor.rs
Normal 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")
|
||||
}
|
||||
@@ -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
332
codex-rs/runtime-install/src/installer/tests.rs
Normal file
332
codex-rs/runtime-install/src/installer/tests.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
364
codex-rs/runtime-install/src/validation.rs
Normal file
364
codex-rs/runtime-install/src/validation.rs
Normal 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()
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -220,6 +220,7 @@ impl ChatWidget {
|
||||
| ServerNotification::ThreadRealtimeTranscriptDone(_)
|
||||
| ServerNotification::WindowsWorldWritableWarning(_)
|
||||
| ServerNotification::WindowsSandboxSetupCompleted(_)
|
||||
| ServerNotification::RuntimeInstallProgress(_)
|
||||
| ServerNotification::AccountLoginCompleted(_) => {}
|
||||
ServerNotification::ContextCompacted(_) => {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user