Compare commits

...

2 Commits

Author SHA1 Message Date
Dylan Hurd
82582c7d01 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-12 02:36:22 -07:00
Dylan Hurd
a77dc85abc feat(app-server) install runtime
Co-authored-by: Codex <noreply@openai.com>
2026-05-12 00:52:52 -07:00
30 changed files with 2302 additions and 17 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -2739,6 +2739,7 @@ dependencies = [
"serde",
"serde_json",
"serial_test",
"sha2",
"tempfile",
"test-case",
"thiserror 2.0.18",
@@ -2749,6 +2750,7 @@ dependencies = [
"tracing",
"uuid",
"wiremock",
"zip 2.4.2",
]
[[package]]

View File

@@ -3043,6 +3043,82 @@
}
]
},
"RuntimeInstallManifest": {
"properties": {
"archiveName": {
"type": [
"string",
"null"
]
},
"archiveSha256": {
"type": "string"
},
"archiveSizeBytes": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"archiveUrl": {
"type": "string"
},
"bundleFormatVersion": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"bundleVersion": {
"type": [
"string",
"null"
]
},
"format": {
"type": [
"string",
"null"
]
},
"runtimeRootDirectoryName": {
"type": [
"string",
"null"
]
}
},
"required": [
"archiveSha256",
"archiveUrl"
],
"type": "object"
},
"RuntimeInstallParams": {
"properties": {
"environmentId": {
"type": [
"string",
"null"
]
},
"manifest": {
"$ref": "#/definitions/RuntimeInstallManifest"
},
"release": {
"type": "string"
}
},
"required": [
"manifest",
"release"
],
"type": "object"
},
"SandboxMode": {
"enum": [
"read-only",
@@ -5387,6 +5463,30 @@
"title": "Plugin/installRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"runtime/install"
],
"title": "Runtime/installRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/RuntimeInstallParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Runtime/installRequest",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -1189,6 +1189,30 @@
"title": "Plugin/installRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"runtime/install"
],
"title": "Runtime/installRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/RuntimeInstallParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Runtime/installRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -14300,6 +14324,154 @@
}
]
},
"RuntimeInstallManifest": {
"properties": {
"archiveName": {
"type": [
"string",
"null"
]
},
"archiveSha256": {
"type": "string"
},
"archiveSizeBytes": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"archiveUrl": {
"type": "string"
},
"bundleFormatVersion": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"bundleVersion": {
"type": [
"string",
"null"
]
},
"format": {
"type": [
"string",
"null"
]
},
"runtimeRootDirectoryName": {
"type": [
"string",
"null"
]
}
},
"required": [
"archiveSha256",
"archiveUrl"
],
"type": "object"
},
"RuntimeInstallParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"environmentId": {
"type": [
"string",
"null"
]
},
"manifest": {
"$ref": "#/definitions/v2/RuntimeInstallManifest"
},
"release": {
"type": "string"
}
},
"required": [
"manifest",
"release"
],
"title": "RuntimeInstallParams",
"type": "object"
},
"RuntimeInstallPaths": {
"properties": {
"bundledPluginMarketplacePaths": {
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": "array"
},
"bundledSkillPaths": {
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": "array"
},
"nodeModulesPath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"nodePath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"pythonPath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"skillsToRemove": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"bundledPluginMarketplacePaths",
"bundledSkillPaths",
"nodeModulesPath",
"nodePath",
"pythonPath",
"skillsToRemove"
],
"type": "object"
},
"RuntimeInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"bundleVersion": {
"type": [
"string",
"null"
]
},
"paths": {
"$ref": "#/definitions/v2/RuntimeInstallPaths"
},
"status": {
"$ref": "#/definitions/v2/RuntimeInstallStatus"
}
},
"required": [
"paths",
"status"
],
"title": "RuntimeInstallResponse",
"type": "object"
},
"RuntimeInstallStatus": {
"enum": [
"already-current",
"installed"
],
"type": "string"
},
"SandboxMode": {
"enum": [
"read-only",

View File

@@ -1929,6 +1929,30 @@
"title": "Plugin/installRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"runtime/install"
],
"title": "Runtime/installRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/RuntimeInstallParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Runtime/installRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -10849,6 +10873,154 @@
}
]
},
"RuntimeInstallManifest": {
"properties": {
"archiveName": {
"type": [
"string",
"null"
]
},
"archiveSha256": {
"type": "string"
},
"archiveSizeBytes": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"archiveUrl": {
"type": "string"
},
"bundleFormatVersion": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"bundleVersion": {
"type": [
"string",
"null"
]
},
"format": {
"type": [
"string",
"null"
]
},
"runtimeRootDirectoryName": {
"type": [
"string",
"null"
]
}
},
"required": [
"archiveSha256",
"archiveUrl"
],
"type": "object"
},
"RuntimeInstallParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"environmentId": {
"type": [
"string",
"null"
]
},
"manifest": {
"$ref": "#/definitions/RuntimeInstallManifest"
},
"release": {
"type": "string"
}
},
"required": [
"manifest",
"release"
],
"title": "RuntimeInstallParams",
"type": "object"
},
"RuntimeInstallPaths": {
"properties": {
"bundledPluginMarketplacePaths": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"bundledSkillPaths": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"nodeModulesPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"nodePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"pythonPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"skillsToRemove": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"bundledPluginMarketplacePaths",
"bundledSkillPaths",
"nodeModulesPath",
"nodePath",
"pythonPath",
"skillsToRemove"
],
"type": "object"
},
"RuntimeInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"bundleVersion": {
"type": [
"string",
"null"
]
},
"paths": {
"$ref": "#/definitions/RuntimeInstallPaths"
},
"status": {
"$ref": "#/definitions/RuntimeInstallStatus"
}
},
"required": [
"paths",
"status"
],
"title": "RuntimeInstallResponse",
"type": "object"
},
"RuntimeInstallStatus": {
"enum": [
"already-current",
"installed"
],
"type": "string"
},
"SandboxMode": {
"enum": [
"read-only",

View File

@@ -0,0 +1,80 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"RuntimeInstallManifest": {
"properties": {
"archiveName": {
"type": [
"string",
"null"
]
},
"archiveSha256": {
"type": "string"
},
"archiveSizeBytes": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"archiveUrl": {
"type": "string"
},
"bundleFormatVersion": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"bundleVersion": {
"type": [
"string",
"null"
]
},
"format": {
"type": [
"string",
"null"
]
},
"runtimeRootDirectoryName": {
"type": [
"string",
"null"
]
}
},
"required": [
"archiveSha256",
"archiveUrl"
],
"type": "object"
}
},
"properties": {
"environmentId": {
"type": [
"string",
"null"
]
},
"manifest": {
"$ref": "#/definitions/RuntimeInstallManifest"
},
"release": {
"type": "string"
}
},
"required": [
"manifest",
"release"
],
"title": "RuntimeInstallParams",
"type": "object"
}

View File

@@ -0,0 +1,76 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"RuntimeInstallPaths": {
"properties": {
"bundledPluginMarketplacePaths": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"bundledSkillPaths": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"nodeModulesPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"nodePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"pythonPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"skillsToRemove": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"bundledPluginMarketplacePaths",
"bundledSkillPaths",
"nodeModulesPath",
"nodePath",
"pythonPath",
"skillsToRemove"
],
"type": "object"
},
"RuntimeInstallStatus": {
"enum": [
"already-current",
"installed"
],
"type": "string"
}
},
"properties": {
"bundleVersion": {
"type": [
"string",
"null"
]
},
"paths": {
"$ref": "#/definitions/RuntimeInstallPaths"
},
"status": {
"$ref": "#/definitions/RuntimeInstallStatus"
}
},
"required": [
"paths",
"status"
],
"title": "RuntimeInstallResponse",
"type": "object"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RuntimeInstallManifest = { archiveName: string | null, archiveSha256: string, archiveSizeBytes: bigint | null, archiveUrl: string, bundleFormatVersion: number | null, bundleVersion: string | null, format: string | null, runtimeRootDirectoryName: string | null, };

View File

@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RuntimeInstallManifest } from "./RuntimeInstallManifest";
export type RuntimeInstallParams = { environmentId?: string | null, manifest: RuntimeInstallManifest, release: string, };

View File

@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type RuntimeInstallPaths = { bundledPluginMarketplacePaths: Array<AbsolutePathBuf>, bundledSkillPaths: Array<AbsolutePathBuf>, nodeModulesPath: AbsolutePathBuf, nodePath: AbsolutePathBuf, pythonPath: AbsolutePathBuf, skillsToRemove: Array<string>, };

View File

@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RuntimeInstallPaths } from "./RuntimeInstallPaths";
import type { RuntimeInstallStatus } from "./RuntimeInstallStatus";
export type RuntimeInstallResponse = { bundleVersion: string | null, paths: RuntimeInstallPaths, status: RuntimeInstallStatus, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RuntimeInstallStatus = "already-current" | "installed";

View File

@@ -322,6 +322,11 @@ export type { ReviewDelivery } from "./ReviewDelivery";
export type { ReviewStartParams } from "./ReviewStartParams";
export type { ReviewStartResponse } from "./ReviewStartResponse";
export type { ReviewTarget } from "./ReviewTarget";
export type { RuntimeInstallManifest } from "./RuntimeInstallManifest";
export type { RuntimeInstallParams } from "./RuntimeInstallParams";
export type { RuntimeInstallPaths } from "./RuntimeInstallPaths";
export type { RuntimeInstallResponse } from "./RuntimeInstallResponse";
export type { RuntimeInstallStatus } from "./RuntimeInstallStatus";
export type { SandboxMode } from "./SandboxMode";
export type { SandboxPolicy } from "./SandboxPolicy";
export type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite";

View File

@@ -716,6 +716,11 @@ client_request_definitions! {
serialization: global("config"),
response: v2::PluginInstallResponse,
},
RuntimeInstall => "runtime/install" {
params: v2::RuntimeInstallParams,
serialization: global("runtime-install"),
response: v2::RuntimeInstallResponse,
},
PluginUninstall => "plugin/uninstall" {
params: v2::PluginUninstallParams,
serialization: global("config"),

View File

@@ -21,6 +21,7 @@ mod process;
mod realtime;
mod remote_control;
mod review;
mod runtime;
mod thread;
mod thread_data;
mod turn;
@@ -47,6 +48,7 @@ pub use process::*;
pub use realtime::*;
pub use remote_control::*;
pub use review::*;
pub use runtime::*;
pub use shared::*;
pub use thread::*;
pub use thread_data::*;

View File

@@ -0,0 +1,58 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RuntimeInstallManifest {
pub archive_name: Option<String>,
pub archive_sha256: String,
pub archive_size_bytes: Option<u64>,
pub archive_url: String,
pub bundle_format_version: Option<u32>,
pub bundle_version: Option<String>,
pub format: Option<String>,
pub runtime_root_directory_name: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RuntimeInstallParams {
#[ts(optional = nullable)]
pub environment_id: Option<String>,
pub manifest: Box<RuntimeInstallManifest>,
pub release: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(export_to = "v2/")]
pub enum RuntimeInstallStatus {
AlreadyCurrent,
Installed,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RuntimeInstallPaths {
pub bundled_plugin_marketplace_paths: Vec<AbsolutePathBuf>,
pub bundled_skill_paths: Vec<AbsolutePathBuf>,
pub node_modules_path: AbsolutePathBuf,
pub node_path: AbsolutePathBuf,
pub python_path: AbsolutePathBuf,
pub skills_to_remove: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RuntimeInstallResponse {
pub bundle_version: Option<String>,
pub paths: RuntimeInstallPaths,
pub status: RuntimeInstallStatus,
}

View File

@@ -92,6 +92,7 @@ mod models;
mod outgoing_message;
mod request_processors;
mod request_serialization;
mod runtime_install;
mod server_request_error;
mod skills_watcher;
mod thread_state;

View File

@@ -163,6 +163,7 @@ pub(crate) struct MessageProcessor {
command_exec_processor: CommandExecRequestProcessor,
process_exec_processor: ProcessExecRequestProcessor,
config_processor: ConfigRequestProcessor,
environment_manager: Arc<EnvironmentManager>,
environment_processor: EnvironmentRequestProcessor,
external_agent_config_processor: ExternalAgentConfigRequestProcessor,
feedback_processor: FeedbackRequestProcessor,
@@ -173,6 +174,7 @@ pub(crate) struct MessageProcessor {
mcp_processor: McpRequestProcessor,
plugin_processor: PluginRequestProcessor,
search_processor: SearchRequestProcessor,
thread_manager: Arc<ThreadManager>,
thread_goal_processor: ThreadGoalRequestProcessor,
thread_processor: ThreadRequestProcessor,
turn_processor: TurnRequestProcessor,
@@ -476,6 +478,7 @@ impl MessageProcessor {
command_exec_processor,
process_exec_processor,
config_processor,
environment_manager: thread_manager.environment_manager(),
environment_processor,
external_agent_config_processor,
feedback_processor,
@@ -486,6 +489,7 @@ impl MessageProcessor {
mcp_processor,
plugin_processor,
search_processor,
thread_manager,
thread_goal_processor,
thread_processor,
turn_processor,
@@ -941,6 +945,29 @@ impl MessageProcessor {
.model_provider_capabilities_read()
.await
.map(|response| Some(response.into())),
ClientRequest::RuntimeInstall { params, .. } => {
let mut params = params;
let environment = if let Some(environment_id) = params.environment_id.take() {
self.environment_manager
.get_environment(&environment_id)
.ok_or_else(|| {
invalid_request(format!(
"unknown runtime install environment id `{environment_id}`"
))
})?
} else {
self.environment_manager
.default_environment()
.unwrap_or_else(|| self.environment_manager.local_environment())
};
let response = environment.install_runtime(params).await?;
let response =
crate::runtime_install::finalize_runtime_install(&environment, response)
.await?;
self.thread_manager.plugins_manager().clear_cache();
self.thread_manager.skills_manager().clear_cache();
Ok(Some(response.into()))
}
ClientRequest::ThreadStart { params, .. } => {
self.thread_processor
.thread_start(

View File

@@ -0,0 +1,447 @@
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
);
}
}

View File

@@ -26,6 +26,8 @@ futures = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls", "stream"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
tokio = { workspace = true, features = [
@@ -43,6 +45,7 @@ tokio-util = { workspace = true, features = ["rt"] }
tokio-tungstenite = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
zip = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
@@ -50,6 +53,5 @@ codex-test-binary-support = { workspace = true }
ctor = { workspace = true }
pretty_assertions = { workspace = true }
serial_test = { workspace = true }
tempfile = { workspace = true }
test-case = "3.3.1"
wiremock = { workspace = true }

View File

@@ -8,6 +8,9 @@ use std::time::Duration;
use arc_swap::ArcSwap;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::RuntimeInstallParams;
use codex_app_server_protocol::RuntimeInstallResponse;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::FutureExt;
use futures::future::BoxFuture;
use serde_json::Value;
@@ -69,6 +72,7 @@ use crate::protocol::INITIALIZED_METHOD;
use crate::protocol::InitializeParams;
use crate::protocol::InitializeResponse;
use crate::protocol::ProcessOutputChunk;
use crate::protocol::RUNTIME_INSTALL_METHOD;
use crate::protocol::ReadParams;
use crate::protocol::ReadResponse;
use crate::protocol::TerminateParams;
@@ -175,6 +179,7 @@ struct Inner {
http_body_streams_write_lock: Mutex<()>,
http_body_stream_next_id: AtomicU64,
session_id: std::sync::RwLock<Option<String>>,
codex_home: std::sync::RwLock<Option<AbsolutePathBuf>>,
reader_task: tokio::task::JoinHandle<()>,
}
@@ -306,6 +311,14 @@ impl ExecServerClient {
.unwrap_or_else(std::sync::PoisonError::into_inner);
*session_id = Some(response.session_id.clone());
}
{
let mut codex_home = self
.inner
.codex_home
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*codex_home = Some(response.codex_home.clone());
}
self.notify_initialized().await?;
Ok(response)
})
@@ -397,6 +410,13 @@ impl ExecServerClient {
self.call(FS_COPY_METHOD, &params).await
}
pub async fn runtime_install(
&self,
params: RuntimeInstallParams,
) -> Result<RuntimeInstallResponse, ExecServerError> {
self.call(RUNTIME_INSTALL_METHOD, &params).await
}
pub(crate) async fn register_session(
&self,
process_id: &ProcessId,
@@ -424,6 +444,14 @@ impl ExecServerClient {
.clone()
}
pub fn codex_home(&self) -> Option<AbsolutePathBuf> {
self.inner
.codex_home
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub(crate) async fn connect(
connection: JsonRpcConnection,
options: ExecServerClientConnectOptions,
@@ -471,6 +499,7 @@ impl ExecServerClient {
http_body_streams_write_lock: Mutex::new(()),
http_body_stream_next_id: AtomicU64::new(1),
session_id: std::sync::RwLock::new(None),
codex_home: std::sync::RwLock::new(None),
reader_task,
}
});
@@ -873,6 +902,7 @@ mod tests {
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
#[cfg(unix)]
@@ -945,7 +975,7 @@ mod tests {
program: "sh".to_string(),
args: vec![
"-c".to_string(),
"read _line; printf '%s\\n' '{\"id\":1,\"result\":{\"sessionId\":\"stdio-test\"}}'; 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,
@@ -969,7 +999,7 @@ mod tests {
program: "sh".to_string(),
args: vec![
"-c".to_string(),
"read _line; printf '%s\\n' '{\"id\":1,\"result\":{\"sessionId\":\"stdio-test\"}}'; 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,
@@ -992,7 +1022,7 @@ mod tests {
args: vec![
"-NoProfile".to_string(),
"-Command".to_string(),
"$null = [Console]::In.ReadLine(); [Console]::Out.WriteLine('{\"id\":1,\"result\":{\"sessionId\":\"stdio-test\"}}'); $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,
@@ -1017,7 +1047,7 @@ mod tests {
"read _line; \
echo \"$$\" > {}; \
sleep 60 >/dev/null 2>&1 & echo \"$!\" > {}; \
printf '%s\\n' '{{\"id\":1,\"result\":{{\"sessionId\":\"stdio-test\"}}}}'; \
printf '%s\\n' '{{\"id\":1,\"result\":{{\"sessionId\":\"stdio-test\",\"codexHome\":\"/tmp\"}}}}'; \
read _line; \
wait",
shell_quote(pid_file.as_path()),
@@ -1143,6 +1173,10 @@ mod tests {
id: request.id,
result: serde_json::to_value(InitializeResponse {
session_id: "session-1".to_string(),
codex_home: AbsolutePathBuf::try_from(
std::env::current_dir().expect("current dir"),
)
.expect("absolute current dir"),
})
.expect("initialize response should serialize"),
}),
@@ -1286,6 +1320,10 @@ mod tests {
id: request.id,
result: serde_json::to_value(InitializeResponse {
session_id: "session-1".to_string(),
codex_home: AbsolutePathBuf::try_from(
std::env::current_dir().expect("current dir"),
)
.expect("absolute current dir"),
})
.expect("initialize response should serialize"),
}),
@@ -1372,6 +1410,10 @@ mod tests {
id: request.id,
result: serde_json::to_value(InitializeResponse {
session_id: "session-1".to_string(),
codex_home: AbsolutePathBuf::try_from(
std::env::current_dir().expect("current dir"),
)
.expect("absolute current dir"),
})
.expect("initialize response should serialize"),
}),

View File

@@ -0,0 +1,26 @@
use std::path::PathBuf;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::rpc::internal_error;
pub(crate) fn default_codex_home() -> Result<AbsolutePathBuf, JSONRPCErrorError> {
default_codex_home_path()
.and_then(|path| {
AbsolutePathBuf::from_absolute_path_checked(path)
.map_err(|err| format!("runtime codex home is not absolute: {err}"))
})
.map_err(internal_error)
}
pub(crate) fn default_codex_home_path() -> Result<PathBuf, String> {
if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
return Ok(PathBuf::from(codex_home));
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.ok_or_else(|| "failed to locate home directory".to_string())?;
Ok(home.join(".codex"))
}

View File

@@ -2,6 +2,11 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::sync::RwLock;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RuntimeInstallParams;
use codex_app_server_protocol::RuntimeInstallResponse;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::ExecServerError;
use crate::ExecServerRuntimePaths;
use crate::ExecutorFileSystem;
@@ -9,6 +14,7 @@ use crate::HttpClient;
use crate::client::LazyRemoteExecServerClient;
use crate::client::http_client::ReqwestHttpClient;
use crate::client_api::ExecServerTransportParams;
use crate::codex_home::default_codex_home_path;
use crate::environment_provider::DefaultEnvironmentProvider;
use crate::environment_provider::EnvironmentDefault;
use crate::environment_provider::EnvironmentProvider;
@@ -20,6 +26,7 @@ use crate::local_process::LocalProcess;
use crate::process::ExecBackend;
use crate::remote_file_system::RemoteFileSystem;
use crate::remote_process::RemoteProcess;
use crate::rpc::internal_error;
pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL";
@@ -112,7 +119,13 @@ impl EnvironmentManager {
local_runtime_paths: ExecServerRuntimePaths,
) -> Result<Self, ExecServerError> {
let provider = environment_provider_from_codex_home(codex_home.as_ref())?;
Self::from_provider(provider.as_ref(), local_runtime_paths).await
let local_codex_home = AbsolutePathBuf::from_absolute_path_checked(codex_home.as_ref())
.map_err(|err| ExecServerError::Protocol(err.to_string()))?;
Self::from_provider_snapshot(
provider.snapshot().await?,
local_runtime_paths,
Some(local_codex_home),
)
}
/// Builds a manager from the legacy environment-variable provider without
@@ -143,12 +156,17 @@ impl EnvironmentManager {
where
P: EnvironmentProvider + ?Sized,
{
Self::from_provider_snapshot(provider.snapshot().await?, local_runtime_paths)
Self::from_provider_snapshot(
provider.snapshot().await?,
local_runtime_paths,
default_local_codex_home(),
)
}
fn from_provider_snapshot(
snapshot: EnvironmentProviderSnapshot,
local_runtime_paths: ExecServerRuntimePaths,
local_codex_home: Option<AbsolutePathBuf>,
) -> Result<Self, ExecServerError> {
let EnvironmentProviderSnapshot {
environments,
@@ -157,7 +175,10 @@ impl EnvironmentManager {
} = snapshot;
let mut environment_map =
HashMap::with_capacity(environments.len() + usize::from(include_local));
let local_environment = Arc::new(Environment::local(local_runtime_paths));
let local_environment = Arc::new(Environment::local_with_codex_home(
local_runtime_paths,
local_codex_home,
));
if include_local {
environment_map.insert(
LOCAL_ENVIRONMENT_ID.to_string(),
@@ -294,7 +315,44 @@ pub struct Environment {
exec_backend: Arc<dyn ExecBackend>,
filesystem: Arc<dyn ExecutorFileSystem>,
http_client: Arc<dyn HttpClient>,
runtime_installer: RuntimeInstaller,
local_runtime_paths: Option<ExecServerRuntimePaths>,
codex_home: Option<AbsolutePathBuf>,
}
#[derive(Clone)]
enum RuntimeInstaller {
Local,
Remote(LazyRemoteExecServerClient),
}
impl RuntimeInstaller {
async fn install_runtime(
&self,
params: RuntimeInstallParams,
) -> Result<RuntimeInstallResponse, JSONRPCErrorError> {
match self {
RuntimeInstaller::Local => crate::runtime_install::install_runtime(params).await,
RuntimeInstaller::Remote(client) => {
let client = client.get().await.map_err(exec_server_error_to_jsonrpc)?;
client
.runtime_install(params)
.await
.map_err(exec_server_error_to_jsonrpc)
}
}
}
}
fn exec_server_error_to_jsonrpc(err: ExecServerError) -> JSONRPCErrorError {
match err {
ExecServerError::Server { code, message } => JSONRPCErrorError {
code,
data: None,
message,
},
_ => internal_error(err.to_string()),
}
}
impl Environment {
@@ -306,7 +364,9 @@ impl Environment {
exec_backend: Arc::new(LocalProcess::default()),
filesystem: Arc::new(LocalFileSystem::unsandboxed()),
http_client: Arc::new(ReqwestHttpClient),
runtime_installer: RuntimeInstaller::Local,
local_runtime_paths: None,
codex_home: default_local_codex_home(),
}
}
}
@@ -356,6 +416,13 @@ impl Environment {
}
pub(crate) fn local(local_runtime_paths: ExecServerRuntimePaths) -> Self {
Self::local_with_codex_home(local_runtime_paths, default_local_codex_home())
}
fn local_with_codex_home(
local_runtime_paths: ExecServerRuntimePaths,
codex_home: Option<AbsolutePathBuf>,
) -> Self {
Self {
exec_server_url: None,
remote_transport: None,
@@ -364,7 +431,9 @@ impl Environment {
local_runtime_paths.clone(),
)),
http_client: Arc::new(ReqwestHttpClient),
runtime_installer: RuntimeInstaller::Local,
local_runtime_paths: Some(local_runtime_paths),
codex_home,
}
}
@@ -393,14 +462,17 @@ impl Environment {
let exec_backend: Arc<dyn ExecBackend> = Arc::new(RemoteProcess::new(client.clone()));
let filesystem: Arc<dyn ExecutorFileSystem> =
Arc::new(RemoteFileSystem::new(client.clone()));
let http_client = client.clone();
Self {
exec_server_url,
remote_transport: Some(remote_transport),
exec_backend,
filesystem,
http_client: Arc::new(client),
http_client: Arc::new(http_client),
runtime_installer: RuntimeInstaller::Remote(client),
local_runtime_paths,
codex_home: None,
}
}
@@ -428,6 +500,36 @@ impl Environment {
pub fn get_filesystem(&self) -> Arc<dyn ExecutorFileSystem> {
Arc::clone(&self.filesystem)
}
pub async fn install_runtime(
&self,
params: RuntimeInstallParams,
) -> Result<RuntimeInstallResponse, JSONRPCErrorError> {
self.runtime_installer.install_runtime(params).await
}
pub async fn codex_home(&self) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
if let Some(codex_home) = self.codex_home.clone() {
return Ok(codex_home);
}
match &self.runtime_installer {
RuntimeInstaller::Local => default_local_codex_home().ok_or_else(|| {
internal_error("failed to locate local codex home for runtime install")
}),
RuntimeInstaller::Remote(client) => {
let client = client.get().await.map_err(exec_server_error_to_jsonrpc)?;
client
.codex_home()
.ok_or_else(|| internal_error("remote exec-server did not report a codex home"))
}
}
}
}
fn default_local_codex_home() -> Option<AbsolutePathBuf> {
default_codex_home_path()
.ok()
.and_then(|path| AbsolutePathBuf::from_absolute_path_checked(path).ok())
}
#[cfg(test)]

View File

@@ -1,6 +1,7 @@
mod client;
mod client_api;
mod client_transport;
mod codex_home;
mod connection;
mod environment;
mod environment_provider;
@@ -17,6 +18,7 @@ mod remote;
mod remote_file_system;
mod remote_process;
mod rpc;
mod runtime_install;
mod runtime_paths;
mod sandboxed_file_system;
mod server;
@@ -82,6 +84,7 @@ pub use protocol::HttpRequestResponse;
pub use protocol::InitializeParams;
pub use protocol::InitializeResponse;
pub use protocol::ProcessOutputChunk;
pub use protocol::RUNTIME_INSTALL_METHOD;
pub use protocol::ReadParams;
pub use protocol::ReadResponse;
pub use protocol::TerminateParams;

View File

@@ -30,6 +30,7 @@ pub const FS_COPY_METHOD: &str = "fs/copy";
pub const HTTP_REQUEST_METHOD: &str = "http/request";
/// JSON-RPC notification method for streamed executor HTTP response bodies.
pub const HTTP_REQUEST_BODY_DELTA_METHOD: &str = "http/request/bodyDelta";
pub const RUNTIME_INSTALL_METHOD: &str = "runtime/install";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
@@ -59,6 +60,7 @@ pub struct InitializeParams {
#[serde(rename_all = "camelCase")]
pub struct InitializeResponse {
pub session_id: String,
pub codex_home: AbsolutePathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View File

@@ -80,7 +80,7 @@ impl RpcNotificationSender {
self.outgoing_tx
.send(RpcServerOutboundMessage::Response { request_id, result })
.await
.map_err(|_| internal_error("RPC connection closed while sending response".into()))
.map_err(|_| internal_error("RPC connection closed while sending response"))
}
pub(crate) async fn notify<P: Serialize>(
@@ -97,7 +97,7 @@ impl RpcNotificationSender {
},
))
.await
.map_err(|_| internal_error("RPC connection closed while sending notification".into()))
.map_err(|_| internal_error("RPC connection closed while sending notification"))
}
}
@@ -421,11 +421,11 @@ pub(crate) fn method_not_found(message: String) -> JSONRPCErrorError {
}
}
pub(crate) fn invalid_params(message: String) -> JSONRPCErrorError {
pub(crate) fn invalid_params(message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: -32602,
data: None,
message,
message: message.into(),
}
}
@@ -437,11 +437,11 @@ pub(crate) fn not_found(message: String) -> JSONRPCErrorError {
}
}
pub(crate) fn internal_error(message: String) -> JSONRPCErrorError {
pub(crate) fn internal_error(message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: -32603,
data: None,
message,
message: message.into(),
}
}

View File

@@ -0,0 +1,908 @@
use std::future::Future;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RuntimeInstallManifest;
use codex_app_server_protocol::RuntimeInstallParams;
use codex_app_server_protocol::RuntimeInstallPaths;
use codex_app_server_protocol::RuntimeInstallResponse;
use codex_app_server_protocol::RuntimeInstallStatus;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::StreamExt;
use serde::Deserialize;
use sha2::Digest;
use sha2::Sha256;
use tokio::fs;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use crate::rpc::internal_error;
use crate::rpc::invalid_params;
const PUBLISHED_ARTIFACT_NAME: &str = "codex-primary-runtime";
const USER_AGENT: &str = "codex-exec-server-runtime-installer";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RuntimeArchiveFormat {
TarXz,
Zip,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InstalledRuntimeMetadata {
bundle_format_version: Option<u32>,
bundle_version: Option<String>,
bundled_plugins: Option<Vec<String>>,
bundled_skills: Option<Vec<String>>,
skills_to_remove: Option<Vec<String>>,
}
pub(crate) async fn install_runtime(
params: RuntimeInstallParams,
) -> Result<RuntimeInstallResponse, JSONRPCErrorError> {
let install_root = default_install_root()?;
install_runtime_with_root(params, install_root).await
}
async fn install_runtime_with_root(
params: RuntimeInstallParams,
install_root: PathBuf,
) -> Result<RuntimeInstallResponse, JSONRPCErrorError> {
validate_manifest(&params.manifest)?;
let archive_format = runtime_archive_format(&params.manifest)?;
let archive_name = params
.manifest
.archive_name
.clone()
.unwrap_or_else(|| default_archive_name(archive_format).to_string());
validate_path_segment(&archive_name, "archiveName")?;
let staging_dir = make_staging_dir(&install_root).await?;
let archive_path = staging_dir.join(archive_name);
let result = async {
download_archive(&params.manifest.archive_url, &archive_path).await?;
install_runtime_from_archive(&params.manifest, &archive_path, &install_root).await
}
.await;
let cleanup_result = fs::remove_dir_all(&staging_dir).await;
if let Err(err) = cleanup_result
&& err.kind() != ErrorKind::NotFound
{
tracing::warn!(
"failed to remove runtime install staging directory {}: {err}",
staging_dir.display()
);
}
result
}
async fn install_runtime_from_archive(
manifest: &RuntimeInstallManifest,
archive_path: &Path,
install_root: &Path,
) -> Result<RuntimeInstallResponse, JSONRPCErrorError> {
let runtime_root_directory_name = runtime_root_directory_name(manifest)?;
let installed_runtime_root = install_root.join(&runtime_root_directory_name);
let target_platform = target_platform();
if let Some(bundle_version) = manifest.bundle_version.as_ref()
&& let Ok(Some(metadata)) = read_installed_runtime_metadata(&installed_runtime_root).await
&& metadata.bundle_version.as_ref() == Some(bundle_version)
&& let Ok(paths) = validate_runtime_root(
&installed_runtime_root,
manifest.bundle_format_version,
target_platform,
)
.await
{
return Ok(RuntimeInstallResponse {
bundle_version: Some(bundle_version.clone()),
paths,
status: RuntimeInstallStatus::AlreadyCurrent,
});
}
fs::create_dir_all(install_root)
.await
.map_err(|err| internal_error(format!("failed to create runtime install root: {err}")))?;
verify_archive_checksum(
archive_path,
&manifest.archive_sha256,
&manifest.archive_url,
)
.await?;
let archive_format = runtime_archive_format(manifest)?;
let staging_dir = make_staging_dir(install_root).await?;
let result = async {
let extract_dir = staging_dir.join("payload");
fs::create_dir_all(&extract_dir).await.map_err(|err| {
internal_error(format!("failed to create runtime extract dir: {err}"))
})?;
let entries = list_archive_entries(archive_format, archive_path).await?;
assert_archive_entries_stay_within_directory(&entries, &extract_dir)?;
extract_archive(archive_format, archive_path, &extract_dir).await?;
let extracted_runtime_root = extract_dir.join(&runtime_root_directory_name);
validate_runtime_root(
&extracted_runtime_root,
manifest.bundle_format_version,
target_platform,
)
.await?;
let previous_runtime_root =
install_root.join(format!("{runtime_root_directory_name}.previous"));
remove_dir_if_exists(&previous_runtime_root).await?;
if path_exists(&installed_runtime_root).await {
fs::rename(&installed_runtime_root, &previous_runtime_root)
.await
.map_err(|err| {
internal_error(format!("failed to move previous runtime aside: {err}"))
})?;
}
let install_result = async {
fs::rename(&extracted_runtime_root, &installed_runtime_root)
.await
.map_err(|err| internal_error(format!("failed to install runtime: {err}")))?;
validate_runtime_root(
&installed_runtime_root,
manifest.bundle_format_version,
target_platform,
)
.await
}
.await;
let paths = match install_result {
Ok(paths) => paths,
Err(error) => {
remove_dir_if_exists(&installed_runtime_root).await?;
if path_exists(&previous_runtime_root).await {
fs::rename(&previous_runtime_root, &installed_runtime_root)
.await
.map_err(|err| {
internal_error(format!("failed to restore previous runtime: {err}"))
})?;
}
return Err(error);
}
};
remove_dir_if_exists(&previous_runtime_root).await?;
Ok(RuntimeInstallResponse {
bundle_version: manifest.bundle_version.clone(),
paths,
status: RuntimeInstallStatus::Installed,
})
}
.await;
let cleanup_result = fs::remove_dir_all(&staging_dir).await;
if let Err(err) = cleanup_result
&& err.kind() != ErrorKind::NotFound
{
tracing::warn!(
"failed to remove runtime install extraction directory {}: {err}",
staging_dir.display()
);
}
result
}
fn default_install_root() -> Result<PathBuf, JSONRPCErrorError> {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.ok_or_else(|| internal_error("failed to locate home directory for runtime install"))?;
Ok(home.join(".cache").join("codex-runtimes"))
}
async fn make_staging_dir(install_root: &Path) -> Result<PathBuf, JSONRPCErrorError> {
fs::create_dir_all(install_root)
.await
.map_err(|err| internal_error(format!("failed to create runtime install root: {err}")))?;
tempfile::Builder::new()
.prefix("codex-runtime-install-")
.tempdir_in(install_root)
.map(tempfile::TempDir::keep)
.map_err(|err| {
internal_error(format!(
"failed to create runtime install staging dir: {err}"
))
})
}
fn validate_manifest(manifest: &RuntimeInstallManifest) -> Result<(), JSONRPCErrorError> {
if manifest.archive_url.trim().is_empty() {
return Err(invalid_params(
"runtime manifest archiveUrl must not be empty",
));
}
if !is_sha256(&manifest.archive_sha256) {
return Err(invalid_params(
"runtime manifest archiveSha256 must be a 64-character hex digest",
));
}
if let Some(archive_name) = manifest.archive_name.as_ref() {
validate_path_segment(archive_name, "archiveName")?;
}
if let Some(runtime_root_directory_name) = manifest.runtime_root_directory_name.as_ref() {
validate_path_segment(runtime_root_directory_name, "runtimeRootDirectoryName")?;
}
Ok(())
}
fn is_sha256(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn validate_path_segment(value: &str, field_name: &str) -> Result<(), JSONRPCErrorError> {
let value = value.trim();
if value.is_empty()
|| value == "."
|| value == ".."
|| value.contains('/')
|| value.contains('\\')
{
return Err(invalid_params(format!(
"runtime manifest {field_name} must be a single path segment"
)));
}
Ok(())
}
fn runtime_root_directory_name(
manifest: &RuntimeInstallManifest,
) -> 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)
}
fn runtime_archive_format(
manifest: &RuntimeInstallManifest,
) -> 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)
}
fn default_archive_name(format: RuntimeArchiveFormat) -> &'static str {
match format {
RuntimeArchiveFormat::TarXz => "node-runtime.tar.xz",
RuntimeArchiveFormat::Zip => "node-runtime.zip",
}
}
async fn download_archive(url: &str, destination: &Path) -> Result<(), JSONRPCErrorError> {
let response = reqwest::Client::new()
.get(url)
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send()
.await
.map_err(|err| internal_error(format!("failed to download runtime archive: {err}")))?;
if !response.status().is_success() {
return Err(internal_error(format!(
"failed to download runtime archive ({} {})",
response.status().as_u16(),
response
.status()
.canonical_reason()
.unwrap_or("unknown status")
)));
}
let mut file = fs::File::create(destination)
.await
.map_err(|err| internal_error(format!("failed to create runtime archive file: {err}")))?;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|err| {
internal_error(format!("failed to read runtime archive bytes: {err}"))
})?;
file.write_all(&chunk)
.await
.map_err(|err| internal_error(format!("failed to write runtime archive: {err}")))?;
}
file.flush()
.await
.map_err(|err| internal_error(format!("failed to flush runtime archive: {err}")))?;
Ok(())
}
async fn verify_archive_checksum(
archive_path: &Path,
expected_sha256: &str,
source_url: &str,
) -> Result<(), JSONRPCErrorError> {
let actual_sha256 = compute_sha256(archive_path).await?;
if !actual_sha256.eq_ignore_ascii_case(expected_sha256) {
return Err(invalid_params(format!(
"checksum mismatch for '{source_url}': expected {expected_sha256}, got {actual_sha256}"
)));
}
Ok(())
}
async fn compute_sha256(path: &Path) -> Result<String, JSONRPCErrorError> {
let mut file = fs::File::open(path)
.await
.map_err(|err| internal_error(format!("failed to open runtime archive: {err}")))?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let bytes_read = file
.read(&mut buffer)
.await
.map_err(|err| internal_error(format!("failed to read runtime archive: {err}")))?;
if bytes_read == 0 {
break;
}
digest.update(&buffer[..bytes_read]);
}
Ok(format!("{:x}", digest.finalize()))
}
async fn list_archive_entries(
format: RuntimeArchiveFormat,
archive_path: &Path,
) -> Result<Vec<String>, JSONRPCErrorError> {
match format {
RuntimeArchiveFormat::TarXz => list_tar_entries(archive_path).await,
RuntimeArchiveFormat::Zip => list_zip_entries(archive_path).await,
}
}
async fn extract_archive(
format: RuntimeArchiveFormat,
archive_path: &Path,
extract_dir: &Path,
) -> Result<(), JSONRPCErrorError> {
match format {
RuntimeArchiveFormat::TarXz => extract_tar_archive(archive_path, extract_dir).await,
RuntimeArchiveFormat::Zip => extract_zip_archive(archive_path, extract_dir).await,
}
}
async fn list_tar_entries(archive_path: &Path) -> Result<Vec<String>, JSONRPCErrorError> {
let output = Command::new("tar")
.arg("-tf")
.arg(archive_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|err| internal_error(format!("failed to list runtime archive: {err}")))?;
if !output.status.success() {
return Err(invalid_params(format!(
"failed to list runtime archive: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(parse_archive_entries(&String::from_utf8_lossy(
&output.stdout,
)))
}
async fn extract_tar_archive(
archive_path: &Path,
extract_dir: &Path,
) -> Result<(), JSONRPCErrorError> {
let output = Command::new("tar")
.arg("-xJf")
.arg(archive_path)
.arg("-C")
.arg(extract_dir)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.await
.map_err(|err| internal_error(format!("failed to extract runtime archive: {err}")))?;
if !output.status.success() {
return Err(invalid_params(format!(
"failed to extract runtime archive: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(())
}
fn list_zip_entries(
archive_path: &Path,
) -> impl Future<Output = Result<Vec<String>, JSONRPCErrorError>> + Send + 'static {
let archive_path = archive_path.to_path_buf();
async move {
tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&archive_path).map_err(|err| {
internal_error(format!("failed to open runtime zip archive: {err}"))
})?;
let mut archive = zip::ZipArchive::new(file).map_err(|err| {
invalid_params(format!("failed to read runtime zip archive: {err}"))
})?;
let mut entries = Vec::with_capacity(archive.len());
for index in 0..archive.len() {
let file = archive.by_index(index).map_err(|err| {
invalid_params(format!("failed to read runtime zip entry: {err}"))
})?;
entries.push(file.name().to_string());
}
Ok(entries)
})
.await
.map_err(|err| internal_error(format!("failed to join zip listing task: {err}")))?
}
}
fn extract_zip_archive(
archive_path: &Path,
extract_dir: &Path,
) -> impl Future<Output = Result<(), JSONRPCErrorError>> + Send + 'static {
let archive_path = archive_path.to_path_buf();
let extract_dir = extract_dir.to_path_buf();
async move {
tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&archive_path).map_err(|err| {
internal_error(format!("failed to open runtime zip archive: {err}"))
})?;
let mut archive = zip::ZipArchive::new(file).map_err(|err| {
invalid_params(format!("failed to read runtime zip archive: {err}"))
})?;
archive.extract(&extract_dir).map_err(|err| {
invalid_params(format!("failed to extract runtime zip archive: {err}"))
})?;
Ok(())
})
.await
.map_err(|err| internal_error(format!("failed to join zip extraction task: {err}")))?
}
}
fn parse_archive_entries(stdout: &str) -> Vec<String> {
stdout
.lines()
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(str::to_string)
.collect()
}
fn assert_archive_entries_stay_within_directory(
entries: &[String],
extract_dir: &Path,
) -> Result<(), JSONRPCErrorError> {
let resolved_extract_dir = normalize_path(extract_dir);
for entry in entries {
let resolved_entry_path = normalize_path(extract_dir.join(entry));
if resolved_entry_path != resolved_extract_dir
&& !resolved_entry_path.starts_with(&resolved_extract_dir)
{
return Err(invalid_params(format!(
"archive entry '{entry}' would extract outside target"
)));
}
}
Ok(())
}
fn normalize_path(path: impl AsRef<Path>) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.as_ref().components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
_ => normalized.push(component.as_os_str()),
}
}
normalized
}
async fn read_installed_runtime_metadata(
runtime_root: &Path,
) -> Result<Option<InstalledRuntimeMetadata>, JSONRPCErrorError> {
let raw = match fs::read_to_string(runtime_root.join("runtime.json")).await {
Ok(raw) => raw,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(internal_error(format!(
"failed to read installed runtime metadata: {err}"
)));
}
};
serde_json::from_str(&raw)
.map(Some)
.map_err(|err| invalid_params(format!("failed to parse installed runtime metadata: {err}")))
}
async fn validate_runtime_root(
runtime_root: &Path,
manifest_bundle_format_version: Option<u32>,
target_platform: &str,
) -> Result<RuntimeInstallPaths, JSONRPCErrorError> {
let metadata = read_installed_runtime_metadata(runtime_root)
.await?
.ok_or_else(|| invalid_params("runtime metadata is missing"))?;
let bundle_format_version = manifest_bundle_format_version
.or(metadata.bundle_format_version)
.unwrap_or(1);
let node_root = if bundle_format_version >= 2 {
runtime_root.join("dependencies").join("node")
} else {
runtime_root.to_path_buf()
};
let node_path = node_root
.join("bin")
.join(node_executable_name(target_platform));
let node_modules_path = node_root.join("node_modules");
let python_path = find_python_path(runtime_root, bundle_format_version, target_platform).await;
let bundled_plugin_marketplace_paths = runtime_contained_paths(
runtime_root,
metadata.bundled_plugins.unwrap_or_default(),
&[],
)?;
let bundled_skill_paths = runtime_contained_paths(
runtime_root,
metadata.bundled_skills.unwrap_or_default(),
&["SKILL.md"],
)?;
Ok(RuntimeInstallPaths {
bundled_plugin_marketplace_paths,
bundled_skill_paths,
node_modules_path: absolute_path(node_modules_path)?,
node_path: absolute_path(node_path)?,
python_path: absolute_path(python_path)?,
skills_to_remove: metadata.skills_to_remove.unwrap_or_default(),
})
}
async fn find_python_path(
runtime_root: &Path,
bundle_format_version: u32,
target_platform: &str,
) -> PathBuf {
let python_root = if bundle_format_version >= 2 {
runtime_root.join("dependencies").join("python")
} else {
runtime_root.join("python")
};
let executable_name = python_executable_name(target_platform);
let candidates = if target_platform == "win32" {
vec![
python_root.join(executable_name),
python_root.join("python").join(executable_name),
python_root.join("bin").join(executable_name),
]
} else {
vec![
python_root.join("bin").join(executable_name),
python_root.join("bin").join("python"),
]
};
for candidate in candidates.iter() {
if path_exists(candidate).await {
return candidate.clone();
}
}
candidates[0].clone()
}
fn runtime_contained_paths(
runtime_root: &Path,
directories: Vec<String>,
suffix: &[&str],
) -> Result<Vec<AbsolutePathBuf>, JSONRPCErrorError> {
directories
.into_iter()
.map(|directory| {
let mut path = runtime_root.join(directory);
for segment in suffix {
path.push(segment);
}
let normalized_runtime_root = normalize_path(runtime_root);
let normalized_path = normalize_path(&path);
if normalized_path != normalized_runtime_root
&& normalized_path.starts_with(&normalized_runtime_root)
{
absolute_path(path)
} else {
Err(invalid_params(
"runtime-contained path must stay within the runtime root",
))
}
})
.collect()
}
fn absolute_path(path: PathBuf) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
AbsolutePathBuf::from_absolute_path_checked(path)
.map_err(|err| internal_error(format!("runtime path is not absolute: {err}")))
}
fn target_platform() -> &'static str {
if cfg!(target_os = "windows") {
"win32"
} else if cfg!(target_os = "macos") {
"darwin"
} else {
"linux"
}
}
fn node_executable_name(target_platform: &str) -> &'static str {
if target_platform == "win32" {
"node.exe"
} else {
"node"
}
}
fn python_executable_name(target_platform: &str) -> &'static str {
if target_platform == "win32" {
"python.exe"
} else {
"python3"
}
}
async fn path_exists(path: &Path) -> bool {
fs::metadata(path).await.is_ok()
}
async fn remove_dir_if_exists(path: &Path) -> Result<(), JSONRPCErrorError> {
match fs::remove_dir_all(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => Err(internal_error(format!(
"failed to remove runtime directory {}: {err}",
path.display()
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn archive_traversal_entries_are_rejected() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let entries = vec![
"codex-primary-runtime/runtime.json".to_string(),
"../x".to_string(),
];
let error = assert_archive_entries_stay_within_directory(&entries, temp_dir.path())
.expect_err("entry should be rejected");
assert!(error.message.contains("would extract outside target"));
}
#[tokio::test]
async fn install_from_archive_reuses_current_runtime() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let install_root = temp_dir.path().join("install");
let runtime_root = install_root.join(PUBLISHED_ARTIFACT_NAME);
create_runtime_root(&runtime_root, "v1").await;
let archive_path = temp_dir.path().join("unused.tar.xz");
fs::write(&archive_path, b"not used")
.await
.expect("write archive");
let manifest = manifest_for_archive(&archive_path, "v1").await;
let response = install_runtime_from_archive(&manifest, &archive_path, &install_root)
.await
.expect("install should succeed");
assert_eq!(response.status, RuntimeInstallStatus::AlreadyCurrent);
assert_eq!(response.bundle_version.as_deref(), Some("v1"));
assert_eq!(
response.paths.node_path,
absolute_path(
runtime_root
.join("dependencies")
.join("node")
.join("bin")
.join(node_executable_name(target_platform()))
)
.expect("absolute path")
);
}
#[tokio::test]
async fn install_from_archive_uses_runtime_metadata_bundle_format_when_manifest_omits_it() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let install_root = temp_dir.path().join("install");
let runtime_root = install_root.join(PUBLISHED_ARTIFACT_NAME);
create_runtime_root(&runtime_root, "v1").await;
let archive_path = temp_dir.path().join("unused.tar.xz");
fs::write(&archive_path, b"not used")
.await
.expect("write archive");
let mut manifest = manifest_for_archive(&archive_path, "v1").await;
manifest.bundle_format_version = None;
let response = install_runtime_from_archive(&manifest, &archive_path, &install_root)
.await
.expect("install should succeed");
assert_eq!(
response.paths.node_modules_path,
absolute_path(
runtime_root
.join("dependencies")
.join("node")
.join("node_modules")
)
.expect("absolute path")
);
assert_eq!(
response.paths.python_path,
absolute_path(
runtime_root
.join("dependencies")
.join("python")
.join("bin")
.join(python_executable_name(target_platform()))
)
.expect("absolute path")
);
}
#[tokio::test]
async fn install_from_archive_rejects_checksum_mismatch() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let archive_path = temp_dir.path().join("archive.tar.xz");
fs::write(&archive_path, b"archive")
.await
.expect("write archive");
let manifest = RuntimeInstallManifest {
archive_name: None,
archive_sha256: "0".repeat(64),
archive_size_bytes: None,
archive_url: "https://example.com/archive.tar.xz".to_string(),
bundle_format_version: Some(2),
bundle_version: Some("v1".to_string()),
format: Some("tar.xz".to_string()),
runtime_root_directory_name: None,
};
let error = install_runtime_from_archive(
&manifest,
&archive_path,
&temp_dir.path().join("install"),
)
.await
.expect_err("checksum mismatch should fail");
assert!(error.message.contains("checksum mismatch"));
}
#[tokio::test]
async fn install_from_archive_restores_previous_runtime_when_new_runtime_is_invalid() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let install_root = temp_dir.path().join("install");
let runtime_root = install_root.join(PUBLISHED_ARTIFACT_NAME);
create_runtime_root(&runtime_root, "old").await;
let payload_root = temp_dir.path().join("payload").join("wrong-root");
fs::create_dir_all(&payload_root)
.await
.expect("payload root");
fs::write(
payload_root.join("runtime.json"),
r#"{"bundleFormatVersion":2,"bundleVersion":"new"}"#,
)
.await
.expect("runtime metadata");
let archive_path = temp_dir.path().join("invalid.tar.xz");
create_tar_xz(temp_dir.path().join("payload").as_path(), &archive_path).await;
let manifest = manifest_for_archive(&archive_path, "new").await;
let error = install_runtime_from_archive(&manifest, &archive_path, &install_root)
.await
.expect_err("invalid runtime should fail");
assert!(error.message.contains("runtime metadata is missing"));
let metadata = read_installed_runtime_metadata(&runtime_root)
.await
.expect("read metadata")
.expect("metadata");
assert_eq!(metadata.bundle_version.as_deref(), Some("old"));
}
async fn create_runtime_root(runtime_root: &Path, bundle_version: &str) {
let node_bin = runtime_root.join("dependencies").join("node").join("bin");
let python_bin = runtime_root.join("dependencies").join("python").join("bin");
fs::create_dir_all(&node_bin).await.expect("node bin");
fs::create_dir_all(
runtime_root
.join("dependencies")
.join("node")
.join("node_modules"),
)
.await
.expect("node_modules");
fs::create_dir_all(&python_bin).await.expect("python bin");
fs::write(
node_bin.join(node_executable_name(target_platform())),
b"node",
)
.await
.expect("node");
fs::write(
python_bin.join(python_executable_name(target_platform())),
b"python",
)
.await
.expect("python");
fs::write(
runtime_root.join("runtime.json"),
format!(r#"{{"bundleFormatVersion":2,"bundleVersion":"{bundle_version}"}}"#),
)
.await
.expect("runtime metadata");
}
async fn manifest_for_archive(
archive_path: &Path,
bundle_version: &str,
) -> RuntimeInstallManifest {
RuntimeInstallManifest {
archive_name: None,
archive_sha256: compute_sha256(archive_path).await.expect("sha256"),
archive_size_bytes: None,
archive_url: "https://example.com/archive.tar.xz".to_string(),
bundle_format_version: Some(2),
bundle_version: Some(bundle_version.to_string()),
format: Some("tar.xz".to_string()),
runtime_root_directory_name: None,
}
}
async fn create_tar_xz(payload_dir: &Path, archive_path: &Path) {
let output = Command::new("tar")
.arg("-cJf")
.arg(archive_path)
.arg("-C")
.arg(payload_dir)
.arg(".")
.output()
.await
.expect("tar should run");
assert!(
output.status.success(),
"tar failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}

View File

@@ -5,6 +5,8 @@ use std::sync::atomic::Ordering;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::RuntimeInstallParams;
use codex_app_server_protocol::RuntimeInstallResponse;
use serde_json::to_value;
use std::collections::HashSet;
use tokio::sync::Mutex;
@@ -123,7 +125,10 @@ impl ExecServerHandler {
.session
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(session);
Ok(InitializeResponse { session_id })
Ok(InitializeResponse {
session_id,
codex_home: crate::codex_home::default_codex_home()?,
})
}
pub(crate) fn initialized(&self) -> Result<(), String> {
@@ -264,6 +269,14 @@ impl ExecServerHandler {
self.file_system.copy(params).await
}
pub(crate) async fn runtime_install(
&self,
params: RuntimeInstallParams,
) -> Result<RuntimeInstallResponse, JSONRPCErrorError> {
self.require_initialized_for("runtime")?;
crate::runtime_install::install_runtime(params).await
}
fn require_initialized_for(
&self,
method_family: &str,

View File

@@ -24,11 +24,13 @@ use crate::protocol::HttpRequestParams;
use crate::protocol::INITIALIZE_METHOD;
use crate::protocol::INITIALIZED_METHOD;
use crate::protocol::InitializeParams;
use crate::protocol::RUNTIME_INSTALL_METHOD;
use crate::protocol::ReadParams;
use crate::protocol::TerminateParams;
use crate::protocol::WriteParams;
use crate::rpc::RpcRouter;
use crate::server::ExecServerHandler;
use codex_app_server_protocol::RuntimeInstallParams;
pub(crate) fn build_router() -> RpcRouter<ExecServerHandler> {
let mut router = RpcRouter::new();
@@ -114,5 +116,11 @@ pub(crate) fn build_router() -> RpcRouter<ExecServerHandler> {
handler.fs_copy(params).await
},
);
router.request(
RUNTIME_INSTALL_METHOD,
|handler: Arc<ExecServerHandler>, params: RuntimeInstallParams| async move {
handler.runtime_install(params).await
},
);
router
}

View File

@@ -17,6 +17,7 @@ use codex_exec_server::HttpRequestResponse;
use codex_exec_server::InitializeParams;
use codex_exec_server::InitializeResponse;
use codex_exec_server::RemoteExecServerConnectArgs;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
@@ -1013,6 +1014,7 @@ impl JsonRpcPeer {
request.id,
InitializeResponse {
session_id: "session-1".to_string(),
codex_home: AbsolutePathBuf::try_from(std::env::current_dir()?)?,
},
)
.await?;