Files
codex/codex-rs/app-server/src/runtime_install.rs
Dylan Hurd 569df87a39 Refactor runtime install post-processing
Keep runtime archive download and validation in exec-server, but move bundled marketplace and skill materialization into app-server orchestration using the selected environment filesystem. Surface the exec-server codexHome during initialization so remote installs materialize into the remote environment's Codex home.\n\nCo-authored-by: Codex <noreply@openai.com>
2026-05-22 07:29:21 -04:00

448 lines
14 KiB
Rust

use std::ffi::OsStr;
use std::io;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RuntimeInstallPaths;
use codex_app_server_protocol::RuntimeInstallResponse;
use codex_exec_server::CopyOptions;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::Environment;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::RemoveOptions;
use codex_utils_absolute_path::AbsolutePathBuf;
use uuid::Uuid;
use crate::error_code::internal_error;
use crate::error_code::invalid_params;
const PUBLISHED_ARTIFACT_NAME: &str = "codex-primary-runtime";
pub(crate) async fn finalize_runtime_install(
environment: &Environment,
mut response: RuntimeInstallResponse,
) -> Result<RuntimeInstallResponse, JSONRPCErrorError> {
if response.paths.bundled_plugin_marketplace_paths.is_empty()
&& response.paths.bundled_skill_paths.is_empty()
&& response.paths.skills_to_remove.is_empty()
{
return Ok(response);
}
let codex_home = environment.codex_home().await?;
response.paths =
finalize_runtime_paths(environment.get_filesystem(), &codex_home, response.paths).await?;
Ok(response)
}
async fn finalize_runtime_paths(
fs: Arc<dyn ExecutorFileSystem>,
codex_home: &AbsolutePathBuf,
mut paths: RuntimeInstallPaths,
) -> Result<RuntimeInstallPaths, JSONRPCErrorError> {
paths.bundled_plugin_marketplace_paths = materialize_bundled_plugin_marketplaces(
Arc::clone(&fs),
codex_home,
&paths.bundled_plugin_marketplace_paths,
)
.await?;
paths.bundled_skill_paths = sync_primary_runtime_skills(
fs,
codex_home,
&paths.bundled_skill_paths,
&paths.skills_to_remove,
)
.await?;
Ok(paths)
}
async fn materialize_bundled_plugin_marketplaces(
fs: Arc<dyn ExecutorFileSystem>,
codex_home: &AbsolutePathBuf,
marketplace_roots: &[AbsolutePathBuf],
) -> Result<Vec<AbsolutePathBuf>, JSONRPCErrorError> {
if marketplace_roots.is_empty() {
return Ok(Vec::new());
}
let destination_root = absolute_path(
codex_home
.as_path()
.join("plugins")
.join(PUBLISHED_ARTIFACT_NAME)
.join("marketplaces"),
)?;
let mut materialized = Vec::with_capacity(marketplace_roots.len());
for marketplace_root in marketplace_roots {
let marketplace_name = marketplace_root.as_path().file_name().ok_or_else(|| {
invalid_params("bundled plugin marketplace path has no directory name")
})?;
let destination = absolute_path(
destination_root
.as_path()
.join(safe_path_segment(marketplace_name)),
)?;
replace_directory(Arc::clone(&fs), marketplace_root, &destination).await?;
materialized.push(destination);
}
Ok(materialized)
}
async fn sync_primary_runtime_skills(
fs: Arc<dyn ExecutorFileSystem>,
codex_home: &AbsolutePathBuf,
bundled_skill_paths: &[AbsolutePathBuf],
skills_to_remove: &[String],
) -> Result<Vec<AbsolutePathBuf>, JSONRPCErrorError> {
if bundled_skill_paths.is_empty() && skills_to_remove.is_empty() {
return Ok(Vec::new());
}
move_legacy_primary_runtime_skills(Arc::clone(&fs), codex_home, skills_to_remove).await?;
if bundled_skill_paths.is_empty() {
return Ok(Vec::new());
}
let destination_root = absolute_path(
codex_home
.as_path()
.join("skills")
.join(PUBLISHED_ARTIFACT_NAME),
)?;
remove_if_exists(
Arc::clone(&fs),
&destination_root,
RemoveOptions {
recursive: true,
force: true,
},
)
.await?;
create_directory(Arc::clone(&fs), &destination_root).await?;
let mut materialized = Vec::with_capacity(bundled_skill_paths.len());
for bundled_skill_path in bundled_skill_paths {
let skill_root = absolute_path(
bundled_skill_path
.as_path()
.parent()
.ok_or_else(|| {
invalid_params(format!(
"bundled skill path {} has no parent directory",
bundled_skill_path.display()
))
})?
.to_path_buf(),
)?;
let skill_name = skill_root.as_path().file_name().ok_or_else(|| {
invalid_params(format!(
"bundled skill path {} has no skill directory name",
bundled_skill_path.display()
))
})?;
let destination = absolute_path(destination_root.as_path().join(skill_name))?;
replace_directory(Arc::clone(&fs), &skill_root, &destination).await?;
materialized.push(absolute_path(destination.as_path().join("SKILL.md"))?);
}
Ok(materialized)
}
async fn move_legacy_primary_runtime_skills(
fs: Arc<dyn ExecutorFileSystem>,
codex_home: &AbsolutePathBuf,
skills_to_remove: &[String],
) -> Result<(), JSONRPCErrorError> {
if skills_to_remove.is_empty() {
return Ok(());
}
let skills_root = absolute_path(codex_home.as_path().join("skills"))?;
for skill_dir in skills_to_remove {
let skill_root = resolve_legacy_skill_directory(&skills_root, skill_dir)?;
let metadata = match fs.get_metadata(&skill_root, /*sandbox*/ None).await {
Ok(metadata) => metadata,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => {
return Err(internal_error(format!(
"failed to inspect legacy skill directory {}: {err}",
skill_root.display()
)));
}
};
if !metadata.is_directory {
continue;
}
let backup_path = absolute_path(
codex_home
.as_path()
.join(".tmp")
.join("legacy-primary-runtime-skills")
.join(format!(
"{}-{}",
skill_root
.as_path()
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("skill"),
Uuid::new_v4()
)),
)?;
if let Some(parent) = backup_path.as_path().parent() {
create_directory(Arc::clone(&fs), &absolute_path(parent.to_path_buf())?).await?;
}
copy_directory(Arc::clone(&fs), &skill_root, &backup_path).await?;
remove_if_exists(
Arc::clone(&fs),
&skill_root,
RemoveOptions {
recursive: true,
force: true,
},
)
.await?;
tracing::info!(
skill_dir = %skill_dir,
skill_root = %skill_root.display(),
backup_path = %backup_path.display(),
"moved legacy primary runtime skill"
);
}
Ok(())
}
fn resolve_legacy_skill_directory(
skills_root: &AbsolutePathBuf,
skill_dir: &str,
) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
let relative = Path::new(skill_dir);
if relative
.components()
.all(|component| matches!(component, Component::Normal(_)))
{
return absolute_path(skills_root.as_path().join(relative));
}
absolute_path(
skills_root.as_path().join(
relative
.file_name()
.unwrap_or_else(|| OsStr::new(skill_dir.trim_matches(['/', '\\']))),
),
)
}
async fn replace_directory(
fs: Arc<dyn ExecutorFileSystem>,
source: &AbsolutePathBuf,
destination: &AbsolutePathBuf,
) -> Result<(), JSONRPCErrorError> {
remove_if_exists(
Arc::clone(&fs),
destination,
RemoveOptions {
recursive: true,
force: true,
},
)
.await?;
if let Some(parent) = destination.as_path().parent() {
create_directory(Arc::clone(&fs), &absolute_path(parent.to_path_buf())?).await?;
}
copy_directory(fs, source, destination).await
}
async fn copy_directory(
fs: Arc<dyn ExecutorFileSystem>,
source: &AbsolutePathBuf,
destination: &AbsolutePathBuf,
) -> Result<(), JSONRPCErrorError> {
fs.copy(
source,
destination,
CopyOptions { recursive: true },
/*sandbox*/ None,
)
.await
.map_err(|err| {
internal_error(format!(
"failed to copy directory {} to {}: {err}",
source.display(),
destination.display()
))
})
}
async fn create_directory(
fs: Arc<dyn ExecutorFileSystem>,
path: &AbsolutePathBuf,
) -> Result<(), JSONRPCErrorError> {
fs.create_directory(
path,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await
.map_err(|err| {
internal_error(format!(
"failed to create directory {}: {err}",
path.display()
))
})
}
async fn remove_if_exists(
fs: Arc<dyn ExecutorFileSystem>,
path: &AbsolutePathBuf,
options: RemoveOptions,
) -> Result<(), JSONRPCErrorError> {
match fs.remove(path, options, /*sandbox*/ None).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(internal_error(format!(
"failed to remove directory {}: {err}",
path.display()
))),
}
}
fn safe_path_segment(segment: &OsStr) -> String {
let safe = segment
.to_string_lossy()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'-'
}
})
.collect::<String>();
let safe = safe.trim_matches('.').to_string();
if safe.is_empty() || safe == ".." {
"runtime-item".to_string()
} else {
safe
}
}
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}")))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use codex_app_server_protocol::RuntimeInstallPaths;
use codex_exec_server::LocalFileSystem;
use pretty_assertions::assert_eq;
use tokio::fs;
use super::*;
#[tokio::test]
async fn finalize_runtime_paths_materializes_marketplaces_and_skills() {
let codex_home = tempfile::tempdir().expect("codex home");
let runtime = tempfile::tempdir().expect("runtime");
let marketplace_root = runtime.path().join("market");
fs::create_dir_all(marketplace_root.join(".agents/plugins"))
.await
.expect("create marketplace manifest dir");
fs::write(
marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{"name":"debug","plugins":[]}"#,
)
.await
.expect("write marketplace");
let bundled_skill_root = runtime.path().join("skills").join("debug");
fs::create_dir_all(&bundled_skill_root)
.await
.expect("create bundled skill");
fs::write(bundled_skill_root.join("SKILL.md"), "debug")
.await
.expect("write bundled skill");
let legacy_skill_root = codex_home.path().join("skills").join("legacy");
fs::create_dir_all(&legacy_skill_root)
.await
.expect("create legacy skill");
fs::write(legacy_skill_root.join("SKILL.md"), "legacy")
.await
.expect("write legacy skill");
let paths = RuntimeInstallPaths {
bundled_plugin_marketplace_paths: vec![
absolute_path(marketplace_root).expect("absolute marketplace path"),
],
bundled_skill_paths: vec![
absolute_path(bundled_skill_root.join("SKILL.md")).expect("absolute skill path"),
],
node_modules_path: absolute_path(runtime.path().join("node_modules"))
.expect("absolute node modules path"),
node_path: absolute_path(runtime.path().join("node")).expect("absolute node path"),
python_path: absolute_path(runtime.path().join("python"))
.expect("absolute python path"),
skills_to_remove: vec!["legacy".to_string()],
};
let finalized = finalize_runtime_paths(
Arc::new(LocalFileSystem::unsandboxed()),
&absolute_path(codex_home.path().to_path_buf()).expect("absolute codex home"),
paths,
)
.await
.expect("finalize runtime paths");
let expected_marketplace_root = codex_home
.path()
.join("plugins")
.join(PUBLISHED_ARTIFACT_NAME)
.join("marketplaces")
.join("market");
let expected_skill_path = codex_home
.path()
.join("skills")
.join(PUBLISHED_ARTIFACT_NAME)
.join("debug")
.join("SKILL.md");
assert_eq!(
finalized.bundled_plugin_marketplace_paths,
vec![absolute_path(expected_marketplace_root.clone()).expect("absolute path")]
);
assert_eq!(
finalized.bundled_skill_paths,
vec![absolute_path(expected_skill_path.clone()).expect("absolute path")]
);
assert!(
expected_marketplace_root
.join(".agents/plugins/marketplace.json")
.is_file()
);
assert_eq!(
fs::read_to_string(expected_skill_path)
.await
.expect("read materialized skill"),
"debug"
);
assert!(!legacy_skill_root.exists());
assert_eq!(
std::fs::read_dir(
codex_home
.path()
.join(".tmp")
.join("legacy-primary-runtime-skills")
)
.expect("read legacy backups")
.count(),
1
);
}
}