mirror of
https://github.com/openai/codex.git
synced 2026-04-18 03:34:50 +00:00
Compare commits
4 Commits
conv-perms
...
dev/shaqay
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f412b39118 | ||
|
|
d626dc3895 | ||
|
|
7c1e41c8b6 | ||
|
|
68a1d82a41 |
2
codex-rs/Cargo.lock
generated
2
codex-rs/Cargo.lock
generated
@@ -2097,9 +2097,9 @@ dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"clap",
|
||||
"codex-app-server-protocol",
|
||||
"codex-protocol",
|
||||
"codex-sandboxing",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-pty",
|
||||
|
||||
@@ -44,6 +44,7 @@ use codex_core::config::Config;
|
||||
use codex_core::config_loader::CloudRequirementsLoader;
|
||||
use codex_core::config_loader::LoaderOverrides;
|
||||
pub use codex_exec_server::EnvironmentManager;
|
||||
pub use codex_exec_server::ExecServerRuntimePaths;
|
||||
use codex_feedback::CodexFeedback;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
@@ -46,7 +46,7 @@ impl FsApi {
|
||||
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
|
||||
let bytes = self
|
||||
.file_system
|
||||
.read_file(¶ms.path)
|
||||
.read_file(¶ms.path, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsReadFileResponse {
|
||||
@@ -64,7 +64,7 @@ impl FsApi {
|
||||
))
|
||||
})?;
|
||||
self.file_system
|
||||
.write_file(¶ms.path, bytes)
|
||||
.write_file(¶ms.path, bytes, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsWriteFileResponse {})
|
||||
@@ -80,6 +80,7 @@ impl FsApi {
|
||||
CreateDirectoryOptions {
|
||||
recursive: params.recursive.unwrap_or(true),
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
@@ -92,7 +93,7 @@ impl FsApi {
|
||||
) -> Result<FsGetMetadataResponse, JSONRPCErrorError> {
|
||||
let metadata = self
|
||||
.file_system
|
||||
.get_metadata(¶ms.path)
|
||||
.get_metadata(¶ms.path, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsGetMetadataResponse {
|
||||
@@ -109,7 +110,7 @@ impl FsApi {
|
||||
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
|
||||
let entries = self
|
||||
.file_system
|
||||
.read_directory(¶ms.path)
|
||||
.read_directory(¶ms.path, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsReadDirectoryResponse {
|
||||
@@ -135,6 +136,7 @@ impl FsApi {
|
||||
recursive: params.recursive.unwrap_or(true),
|
||||
force: params.force.unwrap_or(true),
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
@@ -152,6 +154,7 @@ impl FsApi {
|
||||
CopyOptions {
|
||||
recursive: params.recursive,
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
|
||||
@@ -44,6 +44,7 @@ use codex_core::check_execpolicy_for_warnings;
|
||||
use codex_core::config_loader::ConfigLoadError;
|
||||
use codex_core::config_loader::TextRange as CoreTextRange;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::ExecServerRuntimePaths;
|
||||
use codex_feedback::CodexFeedback;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_state::log_db;
|
||||
@@ -360,7 +361,12 @@ pub async fn run_main_with_transport(
|
||||
session_source: SessionSource,
|
||||
auth: AppServerWebsocketAuthSettings,
|
||||
) -> IoResult<()> {
|
||||
let environment_manager = Arc::new(EnvironmentManager::from_env());
|
||||
let environment_manager = Arc::new(EnvironmentManager::from_env_with_runtime_paths(Some(
|
||||
ExecServerRuntimePaths::from_optional_paths(
|
||||
arg0_paths.codex_self_exe.clone(),
|
||||
arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
)?,
|
||||
)));
|
||||
let (transport_event_tx, mut transport_event_rx) =
|
||||
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
|
||||
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingEnvelope>(CHANNEL_CAPACITY);
|
||||
|
||||
@@ -32,6 +32,7 @@ use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use core_test_support::assert_regex_match;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
@@ -274,8 +275,15 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> {
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("function_call_output output should be a JSON string");
|
||||
let payload = assert_regex_match(
|
||||
r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#,
|
||||
output,
|
||||
)
|
||||
.get(1)
|
||||
.expect("wall-time wrapped output should include payload")
|
||||
.as_str();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(output)?,
|
||||
serde_json::from_str::<Value>(payload)?,
|
||||
json!([{
|
||||
"type": "text",
|
||||
"text": "accepted"
|
||||
|
||||
@@ -135,6 +135,7 @@ pub async fn maybe_parse_apply_patch_verified(
|
||||
argv: &[String],
|
||||
cwd: &AbsolutePathBuf,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
|
||||
) -> MaybeApplyPatchVerified {
|
||||
// Detect a raw patch body passed directly as the command or as the body of a shell
|
||||
// script. In these cases, report an explicit error rather than applying the patch.
|
||||
@@ -170,7 +171,7 @@ pub async fn maybe_parse_apply_patch_verified(
|
||||
);
|
||||
}
|
||||
Hunk::DeleteFile { .. } => {
|
||||
let content = match fs.read_file_text(&path).await {
|
||||
let content = match fs.read_file_text(&path, sandbox).await {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
return MaybeApplyPatchVerified::CorrectnessError(
|
||||
@@ -192,7 +193,7 @@ pub async fn maybe_parse_apply_patch_verified(
|
||||
let ApplyPatchFileUpdate {
|
||||
unified_diff,
|
||||
content: contents,
|
||||
} = match unified_diff_from_chunks(&path, &chunks, fs).await {
|
||||
} = match unified_diff_from_chunks(&path, &chunks, fs, sandbox).await {
|
||||
Ok(diff) => diff,
|
||||
Err(e) => {
|
||||
return MaybeApplyPatchVerified::CorrectnessError(e);
|
||||
@@ -467,7 +468,8 @@ mod tests {
|
||||
maybe_parse_apply_patch_verified(
|
||||
&args,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
LOCAL_FS.as_ref()
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await,
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
@@ -483,7 +485,8 @@ mod tests {
|
||||
maybe_parse_apply_patch_verified(
|
||||
&args,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
LOCAL_FS.as_ref()
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await,
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
@@ -693,9 +696,10 @@ PATCH"#,
|
||||
};
|
||||
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let diff =
|
||||
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -2,2 +2,2 @@
|
||||
bar
|
||||
-baz
|
||||
@@ -731,9 +735,10 @@ PATCH"#,
|
||||
};
|
||||
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let diff =
|
||||
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -3 +3,2 @@
|
||||
baz
|
||||
+quux
|
||||
@@ -770,6 +775,7 @@ PATCH"#,
|
||||
&argv,
|
||||
&AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -823,6 +829,7 @@ PATCH"#,
|
||||
&argv,
|
||||
&AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await;
|
||||
let action = match result {
|
||||
|
||||
@@ -12,6 +12,7 @@ use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_exec_server::CreateDirectoryOptions;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_exec_server::RemoveOptions;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
pub use parser::Hunk;
|
||||
@@ -184,6 +185,7 @@ pub async fn apply_patch(
|
||||
stdout: &mut impl std::io::Write,
|
||||
stderr: &mut impl std::io::Write,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> Result<(), ApplyPatchError> {
|
||||
let hunks = match parse_patch(patch) {
|
||||
Ok(source) => source.hunks,
|
||||
@@ -207,7 +209,7 @@ pub async fn apply_patch(
|
||||
}
|
||||
};
|
||||
|
||||
apply_hunks(&hunks, cwd, stdout, stderr, fs).await?;
|
||||
apply_hunks(&hunks, cwd, stdout, stderr, fs, sandbox).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -219,9 +221,10 @@ pub async fn apply_hunks(
|
||||
stdout: &mut impl std::io::Write,
|
||||
stderr: &mut impl std::io::Write,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> Result<(), ApplyPatchError> {
|
||||
// Delegate to a helper that applies each hunk to the filesystem.
|
||||
match apply_hunks_to_files(hunks, cwd, fs).await {
|
||||
match apply_hunks_to_files(hunks, cwd, fs, sandbox).await {
|
||||
Ok(affected) => {
|
||||
print_summary(&affected, stdout).map_err(ApplyPatchError::from)?;
|
||||
Ok(())
|
||||
@@ -257,6 +260,7 @@ async fn apply_hunks_to_files(
|
||||
hunks: &[Hunk],
|
||||
cwd: &AbsolutePathBuf,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> anyhow::Result<AffectedPaths> {
|
||||
if hunks.is_empty() {
|
||||
anyhow::bail!("No files were modified.");
|
||||
@@ -271,23 +275,27 @@ async fn apply_hunks_to_files(
|
||||
match hunk {
|
||||
Hunk::AddFile { contents, .. } => {
|
||||
if let Some(parent_abs) = path_abs.parent() {
|
||||
fs.create_directory(&parent_abs, CreateDirectoryOptions { recursive: true })
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to create parent directories for {}",
|
||||
path_abs.display()
|
||||
)
|
||||
})?;
|
||||
fs.create_directory(
|
||||
&parent_abs,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
sandbox,
|
||||
)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to create parent directories for {}",
|
||||
path_abs.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs.write_file(&path_abs, contents.clone().into_bytes())
|
||||
fs.write_file(&path_abs, contents.clone().into_bytes(), sandbox)
|
||||
.await
|
||||
.with_context(|| format!("Failed to write file {}", path_abs.display()))?;
|
||||
added.push(affected_path);
|
||||
}
|
||||
Hunk::DeleteFile { .. } => {
|
||||
let result: io::Result<()> = async {
|
||||
let metadata = fs.get_metadata(&path_abs).await?;
|
||||
let metadata = fs.get_metadata(&path_abs, sandbox).await?;
|
||||
if metadata.is_directory {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
@@ -300,6 +308,7 @@ async fn apply_hunks_to_files(
|
||||
recursive: false,
|
||||
force: false,
|
||||
},
|
||||
sandbox,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -311,13 +320,14 @@ async fn apply_hunks_to_files(
|
||||
move_path, chunks, ..
|
||||
} => {
|
||||
let AppliedPatch { new_contents, .. } =
|
||||
derive_new_contents_from_chunks(&path_abs, chunks, fs).await?;
|
||||
derive_new_contents_from_chunks(&path_abs, chunks, fs, sandbox).await?;
|
||||
if let Some(dest) = move_path {
|
||||
let dest_abs = AbsolutePathBuf::resolve_path_against_base(dest, cwd);
|
||||
if let Some(parent_abs) = dest_abs.parent() {
|
||||
fs.create_directory(
|
||||
&parent_abs,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
sandbox,
|
||||
)
|
||||
.await
|
||||
.with_context(|| {
|
||||
@@ -327,11 +337,11 @@ async fn apply_hunks_to_files(
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs.write_file(&dest_abs, new_contents.into_bytes())
|
||||
fs.write_file(&dest_abs, new_contents.into_bytes(), sandbox)
|
||||
.await
|
||||
.with_context(|| format!("Failed to write file {}", dest_abs.display()))?;
|
||||
let result: io::Result<()> = async {
|
||||
let metadata = fs.get_metadata(&path_abs).await?;
|
||||
let metadata = fs.get_metadata(&path_abs, sandbox).await?;
|
||||
if metadata.is_directory {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
@@ -344,6 +354,7 @@ async fn apply_hunks_to_files(
|
||||
recursive: false,
|
||||
force: false,
|
||||
},
|
||||
sandbox,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -353,7 +364,7 @@ async fn apply_hunks_to_files(
|
||||
})?;
|
||||
modified.push(affected_path);
|
||||
} else {
|
||||
fs.write_file(&path_abs, new_contents.into_bytes())
|
||||
fs.write_file(&path_abs, new_contents.into_bytes(), sandbox)
|
||||
.await
|
||||
.with_context(|| format!("Failed to write file {}", path_abs.display()))?;
|
||||
modified.push(affected_path);
|
||||
@@ -379,8 +390,9 @@ async fn derive_new_contents_from_chunks(
|
||||
path_abs: &AbsolutePathBuf,
|
||||
chunks: &[UpdateFileChunk],
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> std::result::Result<AppliedPatch, ApplyPatchError> {
|
||||
let original_contents = fs.read_file_text(path_abs).await.map_err(|err| {
|
||||
let original_contents = fs.read_file_text(path_abs, sandbox).await.map_err(|err| {
|
||||
ApplyPatchError::IoError(IoError {
|
||||
context: format!("Failed to read file to update {}", path_abs.display()),
|
||||
source: err,
|
||||
@@ -540,8 +552,9 @@ pub async fn unified_diff_from_chunks(
|
||||
path_abs: &AbsolutePathBuf,
|
||||
chunks: &[UpdateFileChunk],
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
|
||||
unified_diff_from_chunks_with_context(path_abs, chunks, /*context*/ 1, fs).await
|
||||
unified_diff_from_chunks_with_context(path_abs, chunks, /*context*/ 1, fs, sandbox).await
|
||||
}
|
||||
|
||||
pub async fn unified_diff_from_chunks_with_context(
|
||||
@@ -549,11 +562,12 @@ pub async fn unified_diff_from_chunks_with_context(
|
||||
chunks: &[UpdateFileChunk],
|
||||
context: usize,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
|
||||
let AppliedPatch {
|
||||
original_contents,
|
||||
new_contents,
|
||||
} = derive_new_contents_from_chunks(path_abs, chunks, fs).await?;
|
||||
} = derive_new_contents_from_chunks(path_abs, chunks, fs, sandbox).await?;
|
||||
let text_diff = TextDiff::from_lines(&original_contents, &new_contents);
|
||||
let unified_diff = text_diff.unified_diff().context_radius(context).to_string();
|
||||
Ok(ApplyPatchFileUpdate {
|
||||
@@ -614,6 +628,7 @@ mod tests {
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -667,9 +682,16 @@ mod tests {
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
|
||||
apply_patch(&patch, &cwd, &mut stdout, &mut stderr, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&cwd,
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(&relative_add).unwrap(), "relative add\n");
|
||||
assert_eq!(fs::read_to_string(&absolute_add).unwrap(), "absolute add\n");
|
||||
@@ -709,6 +731,7 @@ mod tests {
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -744,6 +767,7 @@ mod tests {
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -783,6 +807,7 @@ mod tests {
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -831,6 +856,7 @@ mod tests {
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -888,6 +914,7 @@ mod tests {
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -931,6 +958,7 @@ mod tests {
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -973,6 +1001,7 @@ mod tests {
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1019,9 +1048,14 @@ mod tests {
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, update_file_chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let diff = unified_diff_from_chunks(
|
||||
&path_abs,
|
||||
update_file_chunks,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -1,4 +1,4 @@
|
||||
foo
|
||||
-bar
|
||||
@@ -1061,9 +1095,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let diff =
|
||||
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -1,2 +1,2 @@
|
||||
-foo
|
||||
+FOO
|
||||
@@ -1101,9 +1136,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let diff =
|
||||
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -2,2 +2,2 @@
|
||||
bar
|
||||
-baz
|
||||
@@ -1139,9 +1175,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let diff =
|
||||
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -3 +3,2 @@
|
||||
baz
|
||||
+quux
|
||||
@@ -1188,9 +1225,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let path_abs = path.as_path().abs();
|
||||
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let diff =
|
||||
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let expected_diff = r#"@@ -1,6 +1,7 @@
|
||||
a
|
||||
@@ -1219,6 +1257,7 @@ mod tests {
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1258,6 +1297,7 @@ g
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
|
||||
@@ -71,6 +71,7 @@ pub fn run_main() -> i32 {
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
codex_exec_server::LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)) {
|
||||
Ok(()) => {
|
||||
// Flush to ensure output ordering when used in pipelines.
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
|
||||
use codex_exec_server::CODEX_FS_HELPER_ARG1;
|
||||
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
|
||||
use codex_utils_home_dir::find_codex_home;
|
||||
#[cfg(unix)]
|
||||
@@ -93,6 +94,9 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
|
||||
}
|
||||
|
||||
let argv1 = args.next().unwrap_or_default();
|
||||
if argv1 == CODEX_FS_HELPER_ARG1 {
|
||||
codex_exec_server::run_fs_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 {
|
||||
@@ -116,6 +120,7 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
codex_exec_server::LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)) {
|
||||
Ok(()) => 0,
|
||||
Err(_) => 1,
|
||||
@@ -325,13 +330,13 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<Arg0PathEntryGu
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let batch_script = path.join(format!("{filename}.bat"));
|
||||
let exe = exe.display();
|
||||
std::fs::write(
|
||||
&batch_script,
|
||||
format!(
|
||||
r#"@echo off
|
||||
"{}" {CODEX_CORE_APPLY_PATCH_ARG1} %*
|
||||
"{exe}" {CODEX_CORE_APPLY_PATCH_ARG1} %*
|
||||
"#,
|
||||
exe.display()
|
||||
),
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ enum Subcommand {
|
||||
#[clap(hide = true, name = "stdio-to-uds")]
|
||||
StdioToUds(StdioToUdsCommand),
|
||||
|
||||
/// [EXPERIMENTAL] Run the standalone exec-server binary.
|
||||
/// [EXPERIMENTAL] Run the standalone exec-server service.
|
||||
ExecServer(ExecServerCommand),
|
||||
|
||||
/// Inspect feature flags.
|
||||
@@ -1031,7 +1031,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
|
||||
root_remote_auth_token_env.as_deref(),
|
||||
"exec-server",
|
||||
)?;
|
||||
run_exec_server_command(cmd).await?;
|
||||
run_exec_server_command(cmd, &arg0_paths).await?;
|
||||
}
|
||||
Some(Subcommand::Features(FeaturesCli { sub })) => match sub {
|
||||
FeaturesSubcommand::List => {
|
||||
@@ -1103,8 +1103,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_exec_server_command(cmd: ExecServerCommand) -> anyhow::Result<()> {
|
||||
codex_exec_server::run_main_with_listen_url(&cmd.listen)
|
||||
async fn run_exec_server_command(
|
||||
cmd: ExecServerCommand,
|
||||
arg0_paths: &Arg0DispatchPaths,
|
||||
) -> anyhow::Result<()> {
|
||||
let codex_self_exe = arg0_paths
|
||||
.codex_self_exe
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("Codex executable path is not configured"))?;
|
||||
let runtime_paths = codex_exec_server::ExecServerRuntimePaths::new(
|
||||
codex_self_exe,
|
||||
arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
)?;
|
||||
codex_exec_server::run_main(&cmd.listen, runtime_paths)
|
||||
.await
|
||||
.map_err(anyhow::Error::from_boxed)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_features::FEATURES;
|
||||
use codex_features::Feature;
|
||||
use codex_features::unstable_features_warning_event;
|
||||
@@ -1040,6 +1041,22 @@ impl TurnContext {
|
||||
.map_or_else(|| self.cwd.clone(), |path| self.cwd.join(path))
|
||||
}
|
||||
|
||||
pub(crate) fn file_system_sandbox_context(
|
||||
&self,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
) -> FileSystemSandboxContext {
|
||||
FileSystemSandboxContext {
|
||||
sandbox_policy: self.sandbox_policy.get().clone(),
|
||||
windows_sandbox_level: self.windows_sandbox_level,
|
||||
windows_sandbox_private_desktop: self
|
||||
.config
|
||||
.permissions
|
||||
.windows_sandbox_private_desktop,
|
||||
use_legacy_landlock: self.features.use_legacy_landlock(),
|
||||
additional_permissions,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn compact_prompt(&self) -> &str {
|
||||
self.compact_prompt
|
||||
.as_deref()
|
||||
|
||||
@@ -169,14 +169,14 @@ async fn read_project_docs_with_fs(
|
||||
break;
|
||||
}
|
||||
|
||||
match fs.get_metadata(&p).await {
|
||||
match fs.get_metadata(&p, /*sandbox*/ None).await {
|
||||
Ok(metadata) if !metadata.is_file => continue,
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
|
||||
let mut data = match fs.read_file(&p).await {
|
||||
let mut data = match fs.read_file(&p, /*sandbox*/ None).await {
|
||||
Ok(data) => data,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
@@ -249,7 +249,7 @@ pub async fn discover_project_doc_paths(
|
||||
for ancestor in dir.ancestors() {
|
||||
for marker in &project_root_markers {
|
||||
let marker_path = AbsolutePathBuf::try_from(ancestor.join(marker))?;
|
||||
let marker_exists = match fs.get_metadata(&marker_path).await {
|
||||
let marker_exists = match fs.get_metadata(&marker_path, /*sandbox*/ None).await {
|
||||
Ok(_) => true,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => false,
|
||||
Err(err) => return Err(err),
|
||||
@@ -289,7 +289,7 @@ pub async fn discover_project_doc_paths(
|
||||
for d in search_dirs {
|
||||
for name in &candidate_filenames {
|
||||
let candidate = d.join(name);
|
||||
match fs.get_metadata(&candidate).await {
|
||||
match fs.get_metadata(&candidate, /*sandbox*/ None).await {
|
||||
Ok(md) if md.is_file => {
|
||||
found.push(candidate);
|
||||
break;
|
||||
|
||||
@@ -118,6 +118,63 @@ impl ToolOutput for CallToolResult {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct McpToolOutput {
|
||||
pub result: CallToolResult,
|
||||
pub wall_time: Duration,
|
||||
}
|
||||
|
||||
impl ToolOutput for McpToolOutput {
|
||||
fn log_preview(&self) -> String {
|
||||
let payload = self.response_payload();
|
||||
let preview = payload.body.to_text().unwrap_or_else(|| {
|
||||
serde_json::to_string(&self.result.content)
|
||||
.unwrap_or_else(|err| format!("failed to serialize mcp result: {err}"))
|
||||
});
|
||||
telemetry_preview(&preview)
|
||||
}
|
||||
|
||||
fn success_for_logging(&self) -> bool {
|
||||
self.result.success()
|
||||
}
|
||||
|
||||
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
output: self.response_payload(),
|
||||
}
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
||||
serde_json::to_value(&self.result).unwrap_or_else(|err| {
|
||||
JsonValue::String(format!("failed to serialize mcp result: {err}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl McpToolOutput {
|
||||
fn response_payload(&self) -> FunctionCallOutputPayload {
|
||||
let mut payload = self.result.as_function_call_output_payload();
|
||||
let wall_time_seconds = self.wall_time.as_secs_f64();
|
||||
let header = format!("Wall time: {wall_time_seconds:.4} seconds\nOutput:");
|
||||
|
||||
match &mut payload.body {
|
||||
FunctionCallOutputBody::Text(text) => {
|
||||
if text.is_empty() {
|
||||
*text = header;
|
||||
} else {
|
||||
*text = format!("{header}\n{text}");
|
||||
}
|
||||
}
|
||||
FunctionCallOutputBody::ContentItems(items) => {
|
||||
items.insert(0, FunctionCallOutputContentItem::InputText { text: header });
|
||||
}
|
||||
}
|
||||
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToolSearchOutput {
|
||||
pub tools: Vec<ToolSearchOutputTool>,
|
||||
|
||||
@@ -85,6 +85,145 @@ fn mcp_code_mode_result_serializes_full_call_tool_result() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_output_response_item_includes_wall_time() {
|
||||
let output = McpToolOutput {
|
||||
result: CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
"type": "text",
|
||||
"text": "done",
|
||||
})],
|
||||
structured_content: None,
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
},
|
||||
wall_time: std::time::Duration::from_millis(1250),
|
||||
};
|
||||
|
||||
let response = output.to_response_item(
|
||||
"mcp-call-1",
|
||||
&ToolPayload::Mcp {
|
||||
server: "server".to_string(),
|
||||
tool: "tool".to_string(),
|
||||
raw_arguments: "{}".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
||||
assert_eq!(call_id, "mcp-call-1");
|
||||
assert_eq!(output.success, Some(true));
|
||||
let Some(text) = output.body.to_text() else {
|
||||
panic!("MCP output should serialize as text");
|
||||
};
|
||||
let Some(payload) = text.strip_prefix("Wall time: 1.2500 seconds\nOutput:\n") else {
|
||||
panic!("MCP output should include wall-time header: {text}");
|
||||
};
|
||||
let parsed: serde_json::Value = serde_json::from_str(payload).unwrap_or_else(|err| {
|
||||
panic!("MCP output should serialize JSON content: {err}");
|
||||
});
|
||||
assert_eq!(
|
||||
parsed,
|
||||
json!([{
|
||||
"type": "text",
|
||||
"text": "done",
|
||||
}])
|
||||
);
|
||||
}
|
||||
other => panic!("expected FunctionCallOutput, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_output_response_item_preserves_content_items() {
|
||||
let image_url = "data:image/png;base64,AAA";
|
||||
let output = McpToolOutput {
|
||||
result: CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
"type": "image",
|
||||
"mimeType": "image/png",
|
||||
"data": "AAA",
|
||||
})],
|
||||
structured_content: None,
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
},
|
||||
wall_time: std::time::Duration::from_millis(500),
|
||||
};
|
||||
|
||||
let response = output.to_response_item(
|
||||
"mcp-call-2",
|
||||
&ToolPayload::Mcp {
|
||||
server: "server".to_string(),
|
||||
tool: "tool".to_string(),
|
||||
raw_arguments: "{}".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { output, .. } => {
|
||||
assert_eq!(
|
||||
output.content_items(),
|
||||
Some(
|
||||
vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "Wall time: 0.5000 seconds\nOutput:".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: image_url.to_string(),
|
||||
detail: None,
|
||||
},
|
||||
]
|
||||
.as_slice()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
output.body.to_text().as_deref(),
|
||||
Some("Wall time: 0.5000 seconds\nOutput:")
|
||||
);
|
||||
}
|
||||
other => panic!("expected FunctionCallOutput, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
|
||||
let output = McpToolOutput {
|
||||
result: CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
"type": "text",
|
||||
"text": "ignored",
|
||||
})],
|
||||
structured_content: Some(serde_json::json!({
|
||||
"content": "done",
|
||||
})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
},
|
||||
wall_time: std::time::Duration::from_millis(1250),
|
||||
};
|
||||
|
||||
let result = output.code_mode_result(&ToolPayload::Mcp {
|
||||
server: "server".to_string(),
|
||||
tool: "tool".to_string(),
|
||||
raw_arguments: "{}".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "ignored",
|
||||
}],
|
||||
"structuredContent": {
|
||||
"content": "done",
|
||||
},
|
||||
"isError": false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_tool_calls_can_derive_text_from_content_items() {
|
||||
let payload = ToolPayload::Custom {
|
||||
|
||||
@@ -176,7 +176,16 @@ impl ToolHandler for ApplyPatchHandler {
|
||||
));
|
||||
};
|
||||
let fs = environment.get_filesystem();
|
||||
match codex_apply_patch::maybe_parse_apply_patch_verified(&command, &cwd, fs.as_ref()).await
|
||||
let sandbox = environment
|
||||
.is_remote()
|
||||
.then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None));
|
||||
match codex_apply_patch::maybe_parse_apply_patch_verified(
|
||||
&command,
|
||||
&cwd,
|
||||
fs.as_ref(),
|
||||
sandbox.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
|
||||
let (file_paths, effective_additional_permissions, file_system_sandbox_policy) =
|
||||
@@ -273,7 +282,14 @@ pub(crate) async fn intercept_apply_patch(
|
||||
call_id: &str,
|
||||
tool_name: &str,
|
||||
) -> Result<Option<FunctionToolOutput>, FunctionCallError> {
|
||||
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs).await {
|
||||
let sandbox = turn
|
||||
.environment
|
||||
.as_ref()
|
||||
.filter(|env| env.is_remote())
|
||||
.map(|_| turn.file_system_sandbox_context(/*additional_permissions*/ None));
|
||||
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, sandbox.as_ref())
|
||||
.await
|
||||
{
|
||||
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
|
||||
session
|
||||
.record_model_warning(
|
||||
|
||||
@@ -24,13 +24,17 @@ async fn approval_keys_include_move_destination() {
|
||||
+new content
|
||||
*** End Patch"#;
|
||||
let argv = vec!["apply_patch".to_string(), patch.to_string()];
|
||||
let action =
|
||||
match codex_apply_patch::maybe_parse_apply_patch_verified(&argv, &cwd, LOCAL_FS.as_ref())
|
||||
.await
|
||||
{
|
||||
MaybeApplyPatchVerified::Body(action) => action,
|
||||
other => panic!("expected patch body, got: {other:?}"),
|
||||
};
|
||||
let action = match codex_apply_patch::maybe_parse_apply_patch_verified(
|
||||
&argv,
|
||||
&cwd,
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
MaybeApplyPatchVerified::Body(action) => action,
|
||||
other => panic!("expected patch body, got: {other:?}"),
|
||||
};
|
||||
|
||||
let keys = file_paths_for_action(&action);
|
||||
assert_eq!(keys.len(), 2);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp_tool_call::handle_mcp_tool_call;
|
||||
use crate::tools::context::McpToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
|
||||
pub struct McpHandler;
|
||||
impl ToolHandler for McpHandler {
|
||||
type Output = CallToolResult;
|
||||
type Output = McpToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Mcp
|
||||
@@ -41,7 +42,8 @@ impl ToolHandler for McpHandler {
|
||||
let (server, tool, raw_arguments) = payload;
|
||||
let arguments_str = raw_arguments;
|
||||
|
||||
let output = handle_mcp_tool_call(
|
||||
let started = Instant::now();
|
||||
let result = handle_mcp_tool_call(
|
||||
Arc::clone(&session),
|
||||
&turn,
|
||||
call_id.clone(),
|
||||
@@ -51,6 +53,9 @@ impl ToolHandler for McpHandler {
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(output)
|
||||
Ok(McpToolOutput {
|
||||
result,
|
||||
wall_time: started.elapsed(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,10 +92,13 @@ impl ToolHandler for ViewImageHandler {
|
||||
"view_image is unavailable in this session".to_string(),
|
||||
));
|
||||
};
|
||||
let sandbox = environment
|
||||
.is_remote()
|
||||
.then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None));
|
||||
|
||||
let metadata = environment
|
||||
.get_filesystem()
|
||||
.get_metadata(&abs_path)
|
||||
.get_metadata(&abs_path, sandbox.as_ref())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
@@ -112,7 +115,7 @@ impl ToolHandler for ViewImageHandler {
|
||||
}
|
||||
let file_bytes = environment
|
||||
.get_filesystem()
|
||||
.read_file(&abs_path)
|
||||
.read_file(&abs_path, sandbox.as_ref())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
|
||||
@@ -218,6 +218,9 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
|
||||
if let Some(environment) = ctx.turn.environment.as_ref().filter(|env| env.is_remote()) {
|
||||
let started_at = Instant::now();
|
||||
let fs = environment.get_filesystem();
|
||||
let sandbox = ctx
|
||||
.turn
|
||||
.file_system_sandbox_context(req.additional_permissions.clone());
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let result = codex_apply_patch::apply_patch(
|
||||
@@ -226,6 +229,7 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
fs.as_ref(),
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await;
|
||||
let stdout = String::from_utf8_lossy(&stdout).into_owned();
|
||||
|
||||
@@ -148,7 +148,11 @@ pub async fn test_env() -> Result<TestEnv> {
|
||||
let cwd = remote_aware_cwd_path();
|
||||
environment
|
||||
.get_filesystem()
|
||||
.create_directory(&cwd, CreateDirectoryOptions { recursive: true })
|
||||
.create_directory(
|
||||
&cwd,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
remote_process.process.register_cleanup_path(cwd.as_path());
|
||||
Ok(TestEnv {
|
||||
@@ -170,10 +174,9 @@ struct RemoteExecServerStart {
|
||||
fn start_remote_exec_server(remote_env: &RemoteEnvConfig) -> Result<RemoteExecServerStart> {
|
||||
let container_name = remote_env.container_name.as_str();
|
||||
let instance_id = remote_exec_server_instance_id();
|
||||
let remote_exec_server_path = format!("/tmp/codex-exec-server-{instance_id}");
|
||||
let remote_exec_server_path = format!("/tmp/codex-{instance_id}");
|
||||
let stdout_path = format!("/tmp/codex-exec-server-{instance_id}.stdout");
|
||||
let local_binary = codex_utils_cargo_bin::cargo_bin("codex-exec-server")
|
||||
.context("resolve codex-exec-server binary")?;
|
||||
let local_binary = codex_utils_cargo_bin::cargo_bin("codex").context("resolve codex binary")?;
|
||||
let local_binary = local_binary.to_string_lossy().to_string();
|
||||
let remote_binary = format!("{container_name}:{remote_exec_server_path}");
|
||||
|
||||
@@ -188,7 +191,7 @@ fn start_remote_exec_server(remote_env: &RemoteEnvConfig) -> Result<RemoteExecSe
|
||||
|
||||
let start_script = format!(
|
||||
"rm -f {stdout_path}; \
|
||||
nohup {remote_exec_server_path} --listen ws://0.0.0.0:0 > {stdout_path} 2>&1 & \
|
||||
nohup {remote_exec_server_path} exec-server --listen ws://0.0.0.0:0 > {stdout_path} 2>&1 & \
|
||||
echo $!"
|
||||
);
|
||||
let pid_output =
|
||||
@@ -836,18 +839,26 @@ impl TestCodexHarness {
|
||||
if let Some(parent) = abs_path.parent() {
|
||||
self.test
|
||||
.fs()
|
||||
.create_directory(&parent, CreateDirectoryOptions { recursive: true })
|
||||
.create_directory(
|
||||
&parent,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
self.test
|
||||
.fs()
|
||||
.write_file(&abs_path, contents.as_ref().to_vec())
|
||||
.write_file(&abs_path, contents.as_ref().to_vec(), /*sandbox*/ None)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn read_file_text(&self, rel: impl AsRef<Path>) -> Result<String> {
|
||||
Ok(self.test.fs().read_file_text(&self.path_abs(rel)).await?)
|
||||
Ok(self
|
||||
.test
|
||||
.fs()
|
||||
.read_file_text(&self.path_abs(rel), /*sandbox*/ None)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn create_dir_all(&self, rel: impl AsRef<Path>) -> Result<()> {
|
||||
@@ -856,6 +867,7 @@ impl TestCodexHarness {
|
||||
.create_directory(
|
||||
&self.path_abs(rel),
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -874,13 +886,14 @@ impl TestCodexHarness {
|
||||
recursive: false,
|
||||
force: true,
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn abs_path_exists(&self, path: &AbsolutePathBuf) -> Result<bool> {
|
||||
match self.test.fs().get_metadata(path).await {
|
||||
match self.test.fs().get_metadata(path, /*sandbox*/ None).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
|
||||
Err(err) => Err(err.into()),
|
||||
|
||||
@@ -33,9 +33,14 @@ async fn agents_override_is_preferred_over_agents_md() -> Result<()> {
|
||||
agents_instructions(test_codex().with_workspace_setup(|cwd, fs| async move {
|
||||
let agents_md = cwd.join("AGENTS.md");
|
||||
let override_md = cwd.join("AGENTS.override.md");
|
||||
fs.write_file(&agents_md, b"base doc".to_vec()).await?;
|
||||
fs.write_file(&override_md, b"override doc".to_vec())
|
||||
fs.write_file(&agents_md, b"base doc".to_vec(), /*sandbox*/ None)
|
||||
.await?;
|
||||
fs.write_file(
|
||||
&override_md,
|
||||
b"override doc".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}))
|
||||
.await?;
|
||||
@@ -62,9 +67,14 @@ async fn configured_fallback_is_used_when_agents_candidate_is_directory() -> Res
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
let agents_dir = cwd.join("AGENTS.md");
|
||||
let fallback = cwd.join("WORKFLOW.md");
|
||||
fs.create_directory(&agents_dir, CreateDirectoryOptions { recursive: true })
|
||||
fs.create_directory(
|
||||
&agents_dir,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
fs.write_file(&fallback, b"fallback doc".to_vec(), /*sandbox*/ None)
|
||||
.await?;
|
||||
fs.write_file(&fallback, b"fallback doc".to_vec()).await?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}),
|
||||
)
|
||||
@@ -95,12 +105,22 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> {
|
||||
let git_marker = root.join(".git");
|
||||
let nested_agents = nested.join("AGENTS.md");
|
||||
|
||||
fs.create_directory(&nested, CreateDirectoryOptions { recursive: true })
|
||||
fs.create_directory(
|
||||
&nested,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
fs.write_file(&root_agents, b"root doc".to_vec(), /*sandbox*/ None)
|
||||
.await?;
|
||||
fs.write_file(&root_agents, b"root doc".to_vec()).await?;
|
||||
fs.write_file(&git_marker, b"gitdir: /tmp/mock-git-dir\n".to_vec())
|
||||
fs.write_file(
|
||||
&git_marker,
|
||||
b"gitdir: /tmp/mock-git-dir\n".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
fs.write_file(&nested_agents, b"child doc".to_vec(), /*sandbox*/ None)
|
||||
.await?;
|
||||
fs.write_file(&nested_agents, b"child doc".to_vec()).await?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -27,7 +27,8 @@ async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() {
|
||||
})
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
let agents_md = cwd.join("AGENTS.md");
|
||||
fs.write_file(&agents_md, b"be nice".to_vec()).await?;
|
||||
fs.write_file(&agents_md, b"be nice".to_vec(), /*sandbox*/ None)
|
||||
.await?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
});
|
||||
let test = builder
|
||||
|
||||
@@ -30,9 +30,14 @@ pub static CODEX_ALIASES_TEMP_DIR: Option<TestCodexAliasesGuard> = {
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("");
|
||||
let argv1 = args.next().unwrap_or_default();
|
||||
if argv1 == CODEX_CORE_APPLY_PATCH_ARG1 {
|
||||
let _ = arg0_dispatch();
|
||||
return None;
|
||||
}
|
||||
|
||||
// Helper re-execs inherit this ctor too, but they may run inside a sandbox
|
||||
// where creating another CODEX_HOME tempdir under /tmp is not allowed.
|
||||
if exe_name == CODEX_LINUX_SANDBOX_ARG0 || argv1 == CODEX_CORE_APPLY_PATCH_ARG1 {
|
||||
if exe_name == CODEX_LINUX_SANDBOX_ARG0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,11 @@ async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> {
|
||||
let payload = b"remote-test-env-ok".to_vec();
|
||||
|
||||
file_system
|
||||
.write_file(&file_path_abs, payload.clone())
|
||||
.write_file(&file_path_abs, payload.clone(), /*sandbox*/ None)
|
||||
.await?;
|
||||
let actual = file_system
|
||||
.read_file(&file_path_abs, /*sandbox*/ None)
|
||||
.await?;
|
||||
let actual = file_system.read_file(&file_path_abs).await?;
|
||||
assert_eq!(actual, payload);
|
||||
|
||||
file_system
|
||||
@@ -33,6 +35,7 @@ async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> {
|
||||
recursive: false,
|
||||
force: true,
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_cargo_bin::cargo_bin;
|
||||
use core_test_support::assert_regex_match;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::mount_models_once;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
@@ -51,6 +52,29 @@ use tokio::time::sleep;
|
||||
|
||||
static OPENAI_PNG: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAD0AAAA9CAYAAAAeYmHpAAAE6klEQVR4Aeyau44UVxCGx1fZsmRLlm3Zoe0XcGQ5cUiCCIgJeS9CHgAhMkISQnIuGQgJEkBcxLW+nqnZ6uqqc+nuWRC7q/P3qetf9e+MtOwyX25O4Nep6JPyop++0qev9HrfgZ+F6r2DuB/vHOrt/UIkqdDHYvujOW6fO7h/CNEI+a5jc+pBR8uy0jVFsziYu5HtfSUk+Io34q921hLNctFSX0gwww+S8wce8K1LfCU+cYW4888aov8NxqvQILUPPReLOrm6zyLxa4i+6VZuFbJo8d1MOHZm+7VUtB/aIvhPWc/3SWg49JcwFLlHxuXKjtyloo+YNhuW3VS+WPBuUEMvCFKjEDVgFBQHXrnazpqiSxNZCkQ1kYiozsbm9Oz7l4i2Il7vGccGNWAc3XosDrZe/9P3ZnMmzHNEQw4smf8RQ87XEAMsC7Az0Au+dgXerfH4+sHvEc0SYGic8WBBUGqFH2gN7yDrazy7m2pbRTeRmU3+MjZmr1h6LJgPbGy23SI6GlYT0brQ71IY8Us4PNQCm+zepSbaD2BY9xCaAsD9IIj/IzFmKMSdHHonwdZATbTnYREf6/VZGER98N9yCWIvXQwXDoDdhZJoT8jwLnJXDB9w4Sb3e6nK5ndzlkTLnP3JBu4LKkbrYrU69gCVceV0JvpyuW1xlsUVngzhwMetn/XamtTORF9IO5YnWNiyeF9zCAfqR3fUW+vZZKLtgP+ts8BmQRBREAdRDhH3o8QuRh/YucNFz2BEjxbRN6LGzphfKmvP6v6QhqIQyZ8XNJ0W0X83MR1PEcJBNO2KC2Z1TW/v244scp9FwRViZxIOBF0Lctk7ZVSavdLvRlV1hz/ysUi9sr8CIcB3nvWBwA93ykTz18eAYxQ6N/K2DkPA1lv3iXCwmDUT7YkjIby9siXueIJj9H+pzSqJ9oIuJWTUgSSt4WO7o/9GGg0viR4VinNRUDoIj34xoCd6pxD3aK3zfdbnx5v1J3ZNNEJsE0sBG7N27ReDrJc4sFxz7dI/ZAbOmmiKvHBitQXpAdR6+F7v+/ol/tOouUV01EeMZQF2BoQDn6dP4XNr+j9GZEtEK1/L8pFw7bd3a53tsTa7WD+054jOFmPg1XBKPQgnqFfmFcy32ZRvjmiIIQTYFvyDxQ8nH8WIwwGwlyDjDznnilYyFr6njrlZwsKkBpO59A7OwgdzPEWRm+G+oeb7IfyNuzjEEVLrOVxJsxvxwF8kmCM6I2QYmJunz4u4TrADpfl7mlbRTWQ7VmrBzh3+C9f6Grc3YoGN9dg/SXFthpRsT6vobfXRs2VBlgBHXVMLHjDNbIZv1sZ9+X3hB09cXdH1JKViyG0+W9bWZDa/r2f9zAFR71sTzGpMSWz2iI4YssWjWo3REy1MDGjdwe5e0dFSiAC1JakBvu4/CUS8Eh6dqHdU0Or0ioY3W5ClSqDXAy7/6SRfgw8vt4I+tbvvNtFT2kVDhY5+IGb1rCqYaXNF08vSALsXCPmt0kQNqJT1p5eI1mkIV/BxCY1z85lOzeFbPBQHURkkPTlwTYK9gTVE25l84IbFFN+YJDHjdpn0gq6mrHht0dkcjbM4UL9283O5p77GN+SPW/QwVB4IUYg7Or+Kp7naR6qktP98LNF2UxWo9yObPIT9KYg+hK4i56no4rfnM0qeyFf6AwAAAP//trwR3wAAAAZJREFUAwBZ0sR75itw5gAAAABJRU5ErkJggg==";
|
||||
|
||||
fn assert_wall_time_line(line: &str) {
|
||||
assert_regex_match(r"^Wall time: [0-9]+(?:\.[0-9]+)? seconds$", line);
|
||||
}
|
||||
|
||||
fn split_wall_time_wrapped_output(output: &str) -> &str {
|
||||
let Some((wall_time, rest)) = output.split_once('\n') else {
|
||||
panic!("wall-time output should contain an Output section: {output}");
|
||||
};
|
||||
assert_wall_time_line(wall_time);
|
||||
let Some(output) = rest.strip_prefix("Output:\n") else {
|
||||
panic!("wall-time output should contain Output marker: {output}");
|
||||
};
|
||||
output
|
||||
}
|
||||
|
||||
fn assert_wall_time_header(output: &str) {
|
||||
let Some((wall_time, marker)) = output.split_once('\n') else {
|
||||
panic!("wall-time header should contain an Output marker: {output}");
|
||||
};
|
||||
assert_wall_time_line(wall_time);
|
||||
assert_eq!(marker, "Output:");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
#[serial(mcp_test_value)]
|
||||
async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
@@ -71,7 +95,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
mount_sse_once(
|
||||
let final_mock = mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."),
|
||||
@@ -190,6 +214,17 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
|
||||
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let output_item = final_mock.single_request().function_call_output(call_id);
|
||||
let output_text = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("function_call_output output should be a string");
|
||||
let wrapped_payload = split_wall_time_wrapped_output(output_text);
|
||||
let output_json: Value = serde_json::from_str(wrapped_payload)
|
||||
.expect("wrapped MCP output should preserve structured JSON");
|
||||
assert_eq!(output_json["echo"], "ECHOING: ping");
|
||||
assert_eq!(output_json["env"], expected_env_value);
|
||||
|
||||
server.verify().await;
|
||||
|
||||
Ok(())
|
||||
@@ -362,15 +397,22 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
|
||||
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let output_item = final_mock.single_request().function_call_output(call_id);
|
||||
assert_eq!(output_item["type"], "function_call_output");
|
||||
assert_eq!(output_item["call_id"], call_id);
|
||||
let output = output_item["output"]
|
||||
.as_array()
|
||||
.expect("image MCP output should be content items");
|
||||
assert_eq!(output.len(), 2);
|
||||
assert_wall_time_header(
|
||||
output[0]["text"]
|
||||
.as_str()
|
||||
.expect("first MCP image output item should be wall-time text"),
|
||||
);
|
||||
assert_eq!(
|
||||
output_item,
|
||||
output[1],
|
||||
json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": [{
|
||||
"type": "input_image",
|
||||
"image_url": OPENAI_PNG
|
||||
}]
|
||||
"type": "input_image",
|
||||
"image_url": OPENAI_PNG
|
||||
})
|
||||
);
|
||||
server.verify().await;
|
||||
@@ -533,7 +575,8 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("function_call_output output should be a JSON string");
|
||||
let output_json: Value = serde_json::from_str(output_text)
|
||||
let wrapped_payload = split_wall_time_wrapped_output(output_text);
|
||||
let output_json: Value = serde_json::from_str(wrapped_payload)
|
||||
.expect("function_call_output output should be valid JSON");
|
||||
assert_eq!(
|
||||
output_json,
|
||||
|
||||
@@ -28,6 +28,14 @@ use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
fn assert_wall_time_header(output: &str) {
|
||||
let (wall_time, marker) = output
|
||||
.split_once('\n')
|
||||
.expect("wall-time header should contain an Output marker");
|
||||
assert_regex_match(r"^Wall time: [0-9]+(?:\.[0-9]+)? seconds$", wall_time);
|
||||
assert_eq!(marker, "Output:");
|
||||
}
|
||||
|
||||
// Verifies that a standard tool call (shell_command) exceeding the model formatting
|
||||
// limits is truncated before being sent back to the model.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -400,9 +408,9 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
|
||||
"MCP output should not include line-based truncation header: {output}"
|
||||
);
|
||||
|
||||
let truncated_pattern = r#"(?s)^\{"echo":\s*"ECHOING: long-message-with-newlines-.*tokens truncated.*long-message-with-newlines-.*$"#;
|
||||
let truncated_pattern = r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n\{"echo":\s*"ECHOING: long-message-with-newlines-.*tokens truncated.*long-message-with-newlines-.*$"#;
|
||||
assert_regex_match(truncated_pattern, &output);
|
||||
assert!(output.len() < 2500, "{}", output.len());
|
||||
assert!(output.len() < 2600, "{}", output.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -502,13 +510,18 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
|
||||
// Wait for completion to ensure the outbound request is captured.
|
||||
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
let output_item = final_mock.single_request().function_call_output(call_id);
|
||||
// Expect exactly one array element: the image item; and no trailing summary text.
|
||||
// Expect exactly the wall-time text and image item; no trailing truncation summary.
|
||||
let output = output_item.get("output").expect("output");
|
||||
assert!(output.is_array(), "expected array output");
|
||||
let arr = output.as_array().unwrap();
|
||||
assert_eq!(arr.len(), 1, "no truncation summary should be appended");
|
||||
assert_eq!(arr.len(), 2, "no truncation summary should be appended");
|
||||
assert_wall_time_header(
|
||||
arr[0]["text"]
|
||||
.as_str()
|
||||
.expect("first MCP image output item should be wall-time text"),
|
||||
);
|
||||
assert_eq!(
|
||||
arr[0],
|
||||
arr[1],
|
||||
json!({"type": "input_image", "image_url": openai_png})
|
||||
);
|
||||
|
||||
@@ -758,22 +771,11 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
|
||||
.function_call_output_text(call_id)
|
||||
.context("function_call_output present for rmcp call")?;
|
||||
|
||||
let parsed: Value = serde_json::from_str(&output)?;
|
||||
assert_eq!(
|
||||
output.len(),
|
||||
80031,
|
||||
"parsed MCP output should retain its serialized length"
|
||||
80065,
|
||||
"MCP output should retain its serialized length plus wall-time header"
|
||||
);
|
||||
let expected_echo = format!("ECHOING: {large_msg}");
|
||||
let echo_str = parsed["echo"]
|
||||
.as_str()
|
||||
.context("echo field should be a string in rmcp echo output")?;
|
||||
assert_eq!(
|
||||
echo_str.len(),
|
||||
expected_echo.len(),
|
||||
"echo length should match"
|
||||
);
|
||||
assert_eq!(echo_str, expected_echo);
|
||||
assert!(
|
||||
!output.contains("truncated"),
|
||||
"output should not include truncation markers when limit is raised: {output}"
|
||||
|
||||
@@ -191,7 +191,11 @@ async fn create_workspace_directory(
|
||||
) -> Result<std::path::PathBuf> {
|
||||
let abs_path = test.config.cwd.join(rel_path.as_ref());
|
||||
test.fs()
|
||||
.create_directory(&abs_path, CreateDirectoryOptions { recursive: true })
|
||||
.create_directory(
|
||||
&abs_path,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
Ok(abs_path.into_path_buf())
|
||||
}
|
||||
|
||||
@@ -87,7 +87,11 @@ fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result<Vec<u8>>
|
||||
async fn create_workspace_directory(test: &TestCodex, rel_path: &str) -> anyhow::Result<PathBuf> {
|
||||
let abs_path = test.config.cwd.join(rel_path);
|
||||
test.fs()
|
||||
.create_directory(&abs_path, CreateDirectoryOptions { recursive: true })
|
||||
.create_directory(
|
||||
&abs_path,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
Ok(abs_path.into_path_buf())
|
||||
}
|
||||
@@ -100,10 +104,16 @@ async fn write_workspace_file(
|
||||
let abs_path = test.config.cwd.join(rel_path);
|
||||
if let Some(parent) = abs_path.parent() {
|
||||
test.fs()
|
||||
.create_directory(&parent, CreateDirectoryOptions { recursive: true })
|
||||
.create_directory(
|
||||
&parent,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
test.fs().write_file(&abs_path, contents).await?;
|
||||
test.fs()
|
||||
.write_file(&abs_path, contents, /*sandbox*/ None)
|
||||
.await?;
|
||||
Ok(abs_path.into_path_buf())
|
||||
}
|
||||
|
||||
|
||||
@@ -3,5 +3,9 @@ load("//:defs.bzl", "codex_rust_crate")
|
||||
codex_rust_crate(
|
||||
name = "exec-server",
|
||||
crate_name = "codex_exec_server",
|
||||
extra_binaries = [
|
||||
"//codex-rs/cli:codex",
|
||||
"//codex-rs/linux-sandbox:codex-linux-sandbox",
|
||||
],
|
||||
test_tags = ["no-sandbox"],
|
||||
)
|
||||
|
||||
@@ -7,10 +7,6 @@ license.workspace = true
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[[bin]]
|
||||
name = "codex-exec-server"
|
||||
path = "src/bin/codex-exec-server.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -18,9 +14,9 @@ workspace = true
|
||||
arc-swap = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-sandboxing = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-pty = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
# codex-exec-server
|
||||
|
||||
`codex-exec-server` is a small standalone JSON-RPC server for spawning
|
||||
and controlling subprocesses through `codex-utils-pty`.
|
||||
`codex-exec-server` is the library backing `codex exec-server`, a small
|
||||
JSON-RPC server for spawning and controlling subprocesses through
|
||||
`codex-utils-pty`.
|
||||
|
||||
This PR intentionally lands only the standalone binary, client, wire protocol,
|
||||
and docs. Exec and filesystem methods are stubbed server-side here and are
|
||||
implemented in follow-up PRs.
|
||||
It provides:
|
||||
|
||||
It currently provides:
|
||||
|
||||
- a standalone binary: `codex-exec-server`
|
||||
- a CLI entrypoint: `codex exec-server`
|
||||
- a Rust client: `ExecServerClient`
|
||||
- a small protocol module with shared request/response types
|
||||
|
||||
This crate is intentionally narrow. It is not wired into the main Codex CLI or
|
||||
unified-exec in this PR; it is only the standalone transport layer.
|
||||
This crate owns the transport, protocol, and filesystem/process handlers. The
|
||||
top-level `codex` binary owns hidden helper dispatch for sandboxed
|
||||
filesystem operations and `codex-linux-sandbox`.
|
||||
|
||||
## Transport
|
||||
|
||||
The server speaks the shared `codex-app-server-protocol` message envelope on
|
||||
the wire.
|
||||
|
||||
The standalone binary supports:
|
||||
The CLI entrypoint supports:
|
||||
|
||||
- `ws://IP:PORT` (default)
|
||||
|
||||
@@ -36,7 +34,7 @@ Each connection follows this sequence:
|
||||
1. Send `initialize`.
|
||||
2. Wait for the `initialize` response.
|
||||
3. Send `initialized`.
|
||||
4. Call exec or filesystem RPCs once the follow-up implementation PRs land.
|
||||
4. Call process or filesystem RPCs.
|
||||
|
||||
If the server receives any notification other than `initialized`, it replies
|
||||
with an error using request id `-1`.
|
||||
@@ -72,7 +70,7 @@ Handshake acknowledgement notification sent by the client after a successful
|
||||
Params are currently ignored. Sending any other notification method is treated
|
||||
as an invalid request.
|
||||
|
||||
### `command/exec`
|
||||
### `process/start`
|
||||
|
||||
Starts a new managed process.
|
||||
|
||||
@@ -87,7 +85,6 @@ Request params:
|
||||
"PATH": "/usr/bin:/bin"
|
||||
},
|
||||
"tty": true,
|
||||
"outputBytesCap": 16384,
|
||||
"arg0": null
|
||||
}
|
||||
```
|
||||
@@ -100,31 +97,61 @@ Field definitions:
|
||||
- `env`: environment variables passed to the child process.
|
||||
- `tty`: when `true`, spawn a PTY-backed interactive process; when `false`,
|
||||
spawn a pipe-backed process with closed stdin.
|
||||
- `outputBytesCap`: maximum retained stdout/stderr bytes per stream for the
|
||||
in-memory buffer. Defaults to `codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP`.
|
||||
- `arg0`: optional argv0 override forwarded to `codex-utils-pty`.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"processId": "proc-1",
|
||||
"running": true,
|
||||
"exitCode": null,
|
||||
"stdout": null,
|
||||
"stderr": null
|
||||
"processId": "proc-1"
|
||||
}
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
|
||||
- Reusing an existing `processId` is rejected.
|
||||
- PTY-backed processes accept later writes through `command/exec/write`.
|
||||
- PTY-backed processes accept later writes through `process/write`.
|
||||
- Pipe-backed processes are launched with stdin closed and reject writes.
|
||||
- Output is streamed asynchronously via `command/exec/outputDelta`.
|
||||
- Exit is reported asynchronously via `command/exec/exited`.
|
||||
- Output is streamed asynchronously via `process/output`.
|
||||
- Exit is reported asynchronously via `process/exited`.
|
||||
|
||||
### `command/exec/write`
|
||||
### `process/read`
|
||||
|
||||
Reads buffered output and terminal state for a managed process.
|
||||
|
||||
Request params:
|
||||
|
||||
```json
|
||||
{
|
||||
"processId": "proc-1",
|
||||
"afterSeq": null,
|
||||
"maxBytes": 65536,
|
||||
"waitMs": 1000
|
||||
}
|
||||
```
|
||||
|
||||
Field definitions:
|
||||
|
||||
- `processId`: managed process id returned by `process/start`.
|
||||
- `afterSeq`: optional sequence number cursor; when present, only newer chunks
|
||||
are returned.
|
||||
- `maxBytes`: optional response byte budget.
|
||||
- `waitMs`: optional long-poll timeout in milliseconds.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"chunks": [],
|
||||
"nextSeq": 1,
|
||||
"exited": false,
|
||||
"exitCode": null,
|
||||
"closed": false,
|
||||
"failure": null
|
||||
}
|
||||
```
|
||||
|
||||
### `process/write`
|
||||
|
||||
Writes raw bytes to a running PTY-backed process stdin.
|
||||
|
||||
@@ -143,7 +170,7 @@ Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"accepted": true
|
||||
"status": "accepted"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -152,7 +179,7 @@ Behavior notes:
|
||||
- Writes to an unknown `processId` are rejected.
|
||||
- Writes to a non-PTY process are rejected because stdin is already closed.
|
||||
|
||||
### `command/exec/terminate`
|
||||
### `process/terminate`
|
||||
|
||||
Terminates a running managed process.
|
||||
|
||||
@@ -182,7 +209,7 @@ If the process is already unknown or already removed, the server responds with:
|
||||
|
||||
## Notifications
|
||||
|
||||
### `command/exec/outputDelta`
|
||||
### `process/output`
|
||||
|
||||
Streaming output chunk from a running process.
|
||||
|
||||
@@ -191,6 +218,7 @@ Params:
|
||||
```json
|
||||
{
|
||||
"processId": "proc-1",
|
||||
"seq": 1,
|
||||
"stream": "stdout",
|
||||
"chunk": "aGVsbG8K"
|
||||
}
|
||||
@@ -199,10 +227,11 @@ Params:
|
||||
Fields:
|
||||
|
||||
- `processId`: process identifier
|
||||
- `stream`: `"stdout"` or `"stderr"`
|
||||
- `seq`: per-process output sequence number
|
||||
- `stream`: `"stdout"`, `"stderr"`, or `"pty"`
|
||||
- `chunk`: base64-encoded output bytes
|
||||
|
||||
### `command/exec/exited`
|
||||
### `process/exited`
|
||||
|
||||
Final process exit notification.
|
||||
|
||||
@@ -211,10 +240,43 @@ Params:
|
||||
```json
|
||||
{
|
||||
"processId": "proc-1",
|
||||
"seq": 2,
|
||||
"exitCode": 0
|
||||
}
|
||||
```
|
||||
|
||||
### `process/closed`
|
||||
|
||||
Notification emitted after process output is closed and the process handle is
|
||||
removed.
|
||||
|
||||
Params:
|
||||
|
||||
```json
|
||||
{
|
||||
"processId": "proc-1"
|
||||
}
|
||||
```
|
||||
|
||||
## Filesystem RPCs
|
||||
|
||||
Filesystem methods use absolute paths and return JSON-RPC errors for invalid
|
||||
or unavailable paths:
|
||||
|
||||
- `fs/readFile`
|
||||
- `fs/writeFile`
|
||||
- `fs/createDirectory`
|
||||
- `fs/getMetadata`
|
||||
- `fs/readDirectory`
|
||||
- `fs/remove`
|
||||
- `fs/copy`
|
||||
|
||||
Each filesystem request accepts an optional `sandbox` object. When `sandbox`
|
||||
contains a `ReadOnly` or `WorkspaceWrite` policy, the operation runs in a
|
||||
hidden helper process launched from the top-level `codex` executable and
|
||||
prepared through the shared sandbox transform path. Helper requests and
|
||||
responses are passed over stdin/stdout.
|
||||
|
||||
## Errors
|
||||
|
||||
The server returns JSON-RPC errors with these codes:
|
||||
@@ -231,6 +293,7 @@ Typical error cases:
|
||||
- duplicate `processId`
|
||||
- writes to unknown processes
|
||||
- writes to non-PTY processes
|
||||
- sandbox-denied filesystem operations
|
||||
|
||||
## Rust surface
|
||||
|
||||
@@ -240,10 +303,14 @@ The crate exports:
|
||||
- `ExecServerError`
|
||||
- `ExecServerClientConnectOptions`
|
||||
- `RemoteExecServerConnectArgs`
|
||||
- protocol structs `InitializeParams` and `InitializeResponse`
|
||||
- protocol request/response structs for process and filesystem RPCs
|
||||
- `DEFAULT_LISTEN_URL` and `ExecServerListenUrlParseError`
|
||||
- `run_main_with_listen_url()`
|
||||
- `run_main()` for embedding the websocket server in a binary
|
||||
- `ExecServerRuntimePaths`
|
||||
- `run_main()` for embedding the websocket server
|
||||
|
||||
Callers must pass `ExecServerRuntimePaths` to `run_main()`. The top-level
|
||||
`codex exec-server` command builds these paths from the `codex` arg0 dispatch
|
||||
state.
|
||||
|
||||
## Example session
|
||||
|
||||
@@ -258,23 +325,24 @@ Initialize:
|
||||
Start a process:
|
||||
|
||||
```json
|
||||
{"id":2,"method":"command/exec","params":{"processId":"proc-1","argv":["bash","-lc","printf 'ready\\n'; while IFS= read -r line; do printf 'echo:%s\\n' \"$line\"; done"],"cwd":"/tmp","env":{"PATH":"/usr/bin:/bin"},"tty":true,"outputBytesCap":4096,"arg0":null}}
|
||||
{"id":2,"result":{"processId":"proc-1","running":true,"exitCode":null,"stdout":null,"stderr":null}}
|
||||
{"method":"command/exec/outputDelta","params":{"processId":"proc-1","stream":"stdout","chunk":"cmVhZHkK"}}
|
||||
{"id":2,"method":"process/start","params":{"processId":"proc-1","argv":["bash","-lc","printf 'ready\\n'; while IFS= read -r line; do printf 'echo:%s\\n' \"$line\"; done"],"cwd":"/tmp","env":{"PATH":"/usr/bin:/bin"},"tty":true,"arg0":null}}
|
||||
{"id":2,"result":{"processId":"proc-1"}}
|
||||
{"method":"process/output","params":{"processId":"proc-1","seq":1,"stream":"stdout","chunk":"cmVhZHkK"}}
|
||||
```
|
||||
|
||||
Write to the process:
|
||||
|
||||
```json
|
||||
{"id":3,"method":"command/exec/write","params":{"processId":"proc-1","chunk":"aGVsbG8K"}}
|
||||
{"id":3,"result":{"accepted":true}}
|
||||
{"method":"command/exec/outputDelta","params":{"processId":"proc-1","stream":"stdout","chunk":"ZWNobzpoZWxsbwo="}}
|
||||
{"id":3,"method":"process/write","params":{"processId":"proc-1","chunk":"aGVsbG8K"}}
|
||||
{"id":3,"result":{"status":"accepted"}}
|
||||
{"method":"process/output","params":{"processId":"proc-1","seq":2,"stream":"stdout","chunk":"ZWNobzpoZWxsbwo="}}
|
||||
```
|
||||
|
||||
Terminate it:
|
||||
|
||||
```json
|
||||
{"id":4,"method":"command/exec/terminate","params":{"processId":"proc-1"}}
|
||||
{"id":4,"method":"process/terminate","params":{"processId":"proc-1"}}
|
||||
{"id":4,"result":{"running":true}}
|
||||
{"method":"command/exec/exited","params":{"processId":"proc-1","exitCode":0}}
|
||||
{"method":"process/exited","params":{"processId":"proc-1","seq":3,"exitCode":0}}
|
||||
{"method":"process/closed","params":{"processId":"proc-1"}}
|
||||
```
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ExecServerArgs {
|
||||
/// Transport endpoint URL. Supported values: `ws://IP:PORT` (default).
|
||||
#[arg(
|
||||
long = "listen",
|
||||
value_name = "URL",
|
||||
default_value = codex_exec_server::DEFAULT_LISTEN_URL
|
||||
)]
|
||||
listen: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let args = ExecServerArgs::parse();
|
||||
codex_exec_server::run_main_with_listen_url(&args.listen).await
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use tokio::sync::OnceCell;
|
||||
|
||||
use crate::ExecServerClient;
|
||||
use crate::ExecServerError;
|
||||
use crate::ExecServerRuntimePaths;
|
||||
use crate::RemoteExecServerConnectArgs;
|
||||
use crate::file_system::ExecutorFileSystem;
|
||||
use crate::local_file_system::LocalFileSystem;
|
||||
@@ -21,6 +22,7 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL";
|
||||
#[derive(Debug)]
|
||||
pub struct EnvironmentManager {
|
||||
exec_server_url: Option<String>,
|
||||
local_runtime_paths: Option<ExecServerRuntimePaths>,
|
||||
disabled: bool,
|
||||
current_environment: OnceCell<Option<Arc<Environment>>>,
|
||||
}
|
||||
@@ -34,9 +36,19 @@ impl Default for EnvironmentManager {
|
||||
impl EnvironmentManager {
|
||||
/// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value.
|
||||
pub fn new(exec_server_url: Option<String>) -> Self {
|
||||
Self::new_with_runtime_paths(exec_server_url, /*local_runtime_paths*/ None)
|
||||
}
|
||||
|
||||
/// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value and local
|
||||
/// runtime paths used when creating local filesystem helpers.
|
||||
pub fn new_with_runtime_paths(
|
||||
exec_server_url: Option<String>,
|
||||
local_runtime_paths: Option<ExecServerRuntimePaths>,
|
||||
) -> Self {
|
||||
let (exec_server_url, disabled) = normalize_exec_server_url(exec_server_url);
|
||||
Self {
|
||||
exec_server_url,
|
||||
local_runtime_paths,
|
||||
disabled,
|
||||
current_environment: OnceCell::new(),
|
||||
}
|
||||
@@ -44,7 +56,18 @@ impl EnvironmentManager {
|
||||
|
||||
/// Builds a manager from process environment variables.
|
||||
pub fn from_env() -> Self {
|
||||
Self::new(std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok())
|
||||
Self::from_env_with_runtime_paths(/*local_runtime_paths*/ None)
|
||||
}
|
||||
|
||||
/// Builds a manager from process environment variables and local runtime
|
||||
/// paths used when creating local filesystem helpers.
|
||||
pub fn from_env_with_runtime_paths(
|
||||
local_runtime_paths: Option<ExecServerRuntimePaths>,
|
||||
) -> Self {
|
||||
Self::new_with_runtime_paths(
|
||||
std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(),
|
||||
local_runtime_paths,
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a manager from the currently selected environment, or from the
|
||||
@@ -53,11 +76,13 @@ impl EnvironmentManager {
|
||||
match environment {
|
||||
Some(environment) => Self {
|
||||
exec_server_url: environment.exec_server_url().map(str::to_owned),
|
||||
local_runtime_paths: environment.local_runtime_paths().cloned(),
|
||||
disabled: false,
|
||||
current_environment: OnceCell::new(),
|
||||
},
|
||||
None => Self {
|
||||
exec_server_url: None,
|
||||
local_runtime_paths: None,
|
||||
disabled: true,
|
||||
current_environment: OnceCell::new(),
|
||||
},
|
||||
@@ -82,7 +107,11 @@ impl EnvironmentManager {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(Arc::new(
|
||||
Environment::create(self.exec_server_url.clone()).await?,
|
||||
Environment::create_with_runtime_paths(
|
||||
self.exec_server_url.clone(),
|
||||
self.local_runtime_paths.clone(),
|
||||
)
|
||||
.await?,
|
||||
)))
|
||||
}
|
||||
})
|
||||
@@ -101,6 +130,7 @@ pub struct Environment {
|
||||
exec_server_url: Option<String>,
|
||||
remote_exec_server_client: Option<ExecServerClient>,
|
||||
exec_backend: Arc<dyn ExecBackend>,
|
||||
local_runtime_paths: Option<ExecServerRuntimePaths>,
|
||||
}
|
||||
|
||||
impl Default for Environment {
|
||||
@@ -109,6 +139,7 @@ impl Default for Environment {
|
||||
exec_server_url: None,
|
||||
remote_exec_server_client: None,
|
||||
exec_backend: Arc::new(LocalProcess::default()),
|
||||
local_runtime_paths: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +155,15 @@ impl std::fmt::Debug for Environment {
|
||||
impl Environment {
|
||||
/// Builds an environment from the raw `CODEX_EXEC_SERVER_URL` value.
|
||||
pub async fn create(exec_server_url: Option<String>) -> Result<Self, ExecServerError> {
|
||||
Self::create_with_runtime_paths(exec_server_url, /*local_runtime_paths*/ None).await
|
||||
}
|
||||
|
||||
/// Builds an environment from the raw `CODEX_EXEC_SERVER_URL` value and
|
||||
/// local runtime paths used when creating local filesystem helpers.
|
||||
pub async fn create_with_runtime_paths(
|
||||
exec_server_url: Option<String>,
|
||||
local_runtime_paths: Option<ExecServerRuntimePaths>,
|
||||
) -> Result<Self, ExecServerError> {
|
||||
let (exec_server_url, disabled) = normalize_exec_server_url(exec_server_url);
|
||||
if disabled {
|
||||
return Err(ExecServerError::Protocol(
|
||||
@@ -157,6 +197,7 @@ impl Environment {
|
||||
exec_server_url,
|
||||
remote_exec_server_client,
|
||||
exec_backend,
|
||||
local_runtime_paths,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,6 +210,10 @@ impl Environment {
|
||||
self.exec_server_url.as_deref()
|
||||
}
|
||||
|
||||
pub fn local_runtime_paths(&self) -> Option<&ExecServerRuntimePaths> {
|
||||
self.local_runtime_paths.as_ref()
|
||||
}
|
||||
|
||||
pub fn get_exec_backend(&self) -> Arc<dyn ExecBackend> {
|
||||
Arc::clone(&self.exec_backend)
|
||||
}
|
||||
@@ -176,7 +221,10 @@ impl Environment {
|
||||
pub fn get_filesystem(&self) -> Arc<dyn ExecutorFileSystem> {
|
||||
match self.remote_exec_server_client.clone() {
|
||||
Some(client) => Arc::new(RemoteFileSystem::new(client)),
|
||||
None => Arc::new(LocalFileSystem),
|
||||
None => match self.local_runtime_paths.clone() {
|
||||
Some(runtime_paths) => Arc::new(LocalFileSystem::with_runtime_paths(runtime_paths)),
|
||||
None => Arc::new(LocalFileSystem::unsandboxed()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,6 +242,7 @@ mod tests {
|
||||
|
||||
use super::Environment;
|
||||
use super::EnvironmentManager;
|
||||
use crate::ExecServerRuntimePaths;
|
||||
use crate::ProcessId;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -246,6 +295,31 @@ mod tests {
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn environment_manager_carries_local_runtime_paths() {
|
||||
let runtime_paths = ExecServerRuntimePaths::new(
|
||||
std::env::current_exe().expect("current exe"),
|
||||
/*codex_linux_sandbox_exe*/ None,
|
||||
)
|
||||
.expect("runtime paths");
|
||||
let manager = EnvironmentManager::new_with_runtime_paths(
|
||||
/*exec_server_url*/ None,
|
||||
Some(runtime_paths.clone()),
|
||||
);
|
||||
|
||||
let environment = manager
|
||||
.current()
|
||||
.await
|
||||
.expect("get current environment")
|
||||
.expect("local environment");
|
||||
|
||||
assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths));
|
||||
assert_eq!(
|
||||
EnvironmentManager::from_environment(Some(&environment)).local_runtime_paths,
|
||||
Some(runtime_paths)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_environment_manager_has_no_current_environment() {
|
||||
let manager = EnvironmentManager::new(Some("none".to_string()));
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use tokio::io;
|
||||
@@ -34,86 +36,95 @@ pub struct ReadDirectoryEntry {
|
||||
pub is_file: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileSystemSandboxContext {
|
||||
pub sandbox_policy: SandboxPolicy,
|
||||
pub windows_sandbox_level: WindowsSandboxLevel,
|
||||
#[serde(default)]
|
||||
pub windows_sandbox_private_desktop: bool,
|
||||
#[serde(default)]
|
||||
pub use_legacy_landlock: bool,
|
||||
pub additional_permissions: Option<PermissionProfile>,
|
||||
}
|
||||
|
||||
impl FileSystemSandboxContext {
|
||||
pub fn new(sandbox_policy: SandboxPolicy) -> Self {
|
||||
Self {
|
||||
sandbox_policy,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
windows_sandbox_private_desktop: false,
|
||||
use_legacy_landlock: false,
|
||||
additional_permissions: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_run_in_sandbox(&self) -> bool {
|
||||
matches!(
|
||||
self.sandbox_policy,
|
||||
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub type FileSystemResult<T> = io::Result<T>;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ExecutorFileSystem: Send + Sync {
|
||||
async fn read_file(&self, path: &AbsolutePathBuf) -> FileSystemResult<Vec<u8>>;
|
||||
async fn read_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>>;
|
||||
|
||||
/// Reads a file and decodes it as UTF-8 text.
|
||||
async fn read_file_text(&self, path: &AbsolutePathBuf) -> FileSystemResult<String> {
|
||||
let bytes = self.read_file(path).await?;
|
||||
async fn read_file_text(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<String> {
|
||||
let bytes = self.read_file(path, sandbox).await?;
|
||||
String::from_utf8(bytes).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
|
||||
}
|
||||
|
||||
async fn read_file_with_sandbox_policy(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<Vec<u8>>;
|
||||
|
||||
async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec<u8>) -> FileSystemResult<()>;
|
||||
|
||||
async fn write_file_with_sandbox_policy(
|
||||
async fn write_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
contents: Vec<u8>,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()>;
|
||||
|
||||
async fn create_directory(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
options: CreateDirectoryOptions,
|
||||
) -> FileSystemResult<()>;
|
||||
|
||||
async fn create_directory_with_sandbox_policy(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
create_directory_options: CreateDirectoryOptions,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()>;
|
||||
|
||||
async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult<FileMetadata>;
|
||||
|
||||
async fn get_metadata_with_sandbox_policy(
|
||||
async fn get_metadata(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<FileMetadata>;
|
||||
|
||||
async fn read_directory(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<ReadDirectoryEntry>>;
|
||||
|
||||
async fn read_directory_with_sandbox_policy(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<Vec<ReadDirectoryEntry>>;
|
||||
|
||||
async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()>;
|
||||
|
||||
async fn remove_with_sandbox_policy(
|
||||
async fn remove(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
remove_options: RemoveOptions,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()>;
|
||||
|
||||
async fn copy(
|
||||
&self,
|
||||
source_path: &AbsolutePathBuf,
|
||||
destination_path: &AbsolutePathBuf,
|
||||
options: CopyOptions,
|
||||
) -> FileSystemResult<()>;
|
||||
|
||||
async fn copy_with_sandbox_policy(
|
||||
&self,
|
||||
source_path: &AbsolutePathBuf,
|
||||
destination_path: &AbsolutePathBuf,
|
||||
copy_options: CopyOptions,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()>;
|
||||
}
|
||||
|
||||
299
codex-rs/exec-server/src/fs_helper.rs
Normal file
299
codex-rs/exec-server/src/fs_helper.rs
Normal file
@@ -0,0 +1,299 @@
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::io;
|
||||
|
||||
use crate::CopyOptions;
|
||||
use crate::CreateDirectoryOptions;
|
||||
use crate::ExecutorFileSystem;
|
||||
use crate::RemoveOptions;
|
||||
use crate::local_file_system::DirectFileSystem;
|
||||
use crate::protocol::FS_COPY_METHOD;
|
||||
use crate::protocol::FS_CREATE_DIRECTORY_METHOD;
|
||||
use crate::protocol::FS_GET_METADATA_METHOD;
|
||||
use crate::protocol::FS_READ_DIRECTORY_METHOD;
|
||||
use crate::protocol::FS_READ_FILE_METHOD;
|
||||
use crate::protocol::FS_REMOVE_METHOD;
|
||||
use crate::protocol::FS_WRITE_FILE_METHOD;
|
||||
use crate::protocol::FsCopyParams;
|
||||
use crate::protocol::FsCopyResponse;
|
||||
use crate::protocol::FsCreateDirectoryParams;
|
||||
use crate::protocol::FsCreateDirectoryResponse;
|
||||
use crate::protocol::FsGetMetadataParams;
|
||||
use crate::protocol::FsGetMetadataResponse;
|
||||
use crate::protocol::FsReadDirectoryEntry;
|
||||
use crate::protocol::FsReadDirectoryParams;
|
||||
use crate::protocol::FsReadDirectoryResponse;
|
||||
use crate::protocol::FsReadFileParams;
|
||||
use crate::protocol::FsReadFileResponse;
|
||||
use crate::protocol::FsRemoveParams;
|
||||
use crate::protocol::FsRemoveResponse;
|
||||
use crate::protocol::FsWriteFileParams;
|
||||
use crate::protocol::FsWriteFileResponse;
|
||||
use crate::rpc::internal_error;
|
||||
use crate::rpc::invalid_request;
|
||||
use crate::rpc::not_found;
|
||||
|
||||
pub const CODEX_FS_HELPER_ARG1: &str = "--codex-run-as-fs-helper";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "params")]
|
||||
pub(crate) enum FsHelperRequest {
|
||||
#[serde(rename = "fs/readFile")]
|
||||
ReadFile(FsReadFileParams),
|
||||
#[serde(rename = "fs/writeFile")]
|
||||
WriteFile(FsWriteFileParams),
|
||||
#[serde(rename = "fs/createDirectory")]
|
||||
CreateDirectory(FsCreateDirectoryParams),
|
||||
#[serde(rename = "fs/getMetadata")]
|
||||
GetMetadata(FsGetMetadataParams),
|
||||
#[serde(rename = "fs/readDirectory")]
|
||||
ReadDirectory(FsReadDirectoryParams),
|
||||
#[serde(rename = "fs/remove")]
|
||||
Remove(FsRemoveParams),
|
||||
#[serde(rename = "fs/copy")]
|
||||
Copy(FsCopyParams),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", content = "payload", rename_all = "camelCase")]
|
||||
pub(crate) enum FsHelperResponse {
|
||||
Ok(FsHelperPayload),
|
||||
Error(JSONRPCErrorError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "response")]
|
||||
pub(crate) enum FsHelperPayload {
|
||||
#[serde(rename = "fs/readFile")]
|
||||
ReadFile(FsReadFileResponse),
|
||||
#[serde(rename = "fs/writeFile")]
|
||||
WriteFile(FsWriteFileResponse),
|
||||
#[serde(rename = "fs/createDirectory")]
|
||||
CreateDirectory(FsCreateDirectoryResponse),
|
||||
#[serde(rename = "fs/getMetadata")]
|
||||
GetMetadata(FsGetMetadataResponse),
|
||||
#[serde(rename = "fs/readDirectory")]
|
||||
ReadDirectory(FsReadDirectoryResponse),
|
||||
#[serde(rename = "fs/remove")]
|
||||
Remove(FsRemoveResponse),
|
||||
#[serde(rename = "fs/copy")]
|
||||
Copy(FsCopyResponse),
|
||||
}
|
||||
|
||||
impl FsHelperPayload {
|
||||
fn operation(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadFile(_) => FS_READ_FILE_METHOD,
|
||||
Self::WriteFile(_) => FS_WRITE_FILE_METHOD,
|
||||
Self::CreateDirectory(_) => FS_CREATE_DIRECTORY_METHOD,
|
||||
Self::GetMetadata(_) => FS_GET_METADATA_METHOD,
|
||||
Self::ReadDirectory(_) => FS_READ_DIRECTORY_METHOD,
|
||||
Self::Remove(_) => FS_REMOVE_METHOD,
|
||||
Self::Copy(_) => FS_COPY_METHOD,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn expect_read_file(self) -> Result<FsReadFileResponse, JSONRPCErrorError> {
|
||||
match self {
|
||||
Self::ReadFile(response) => Ok(response),
|
||||
other => Err(unexpected_response(FS_READ_FILE_METHOD, other.operation())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn expect_write_file(self) -> Result<FsWriteFileResponse, JSONRPCErrorError> {
|
||||
match self {
|
||||
Self::WriteFile(response) => Ok(response),
|
||||
other => Err(unexpected_response(FS_WRITE_FILE_METHOD, other.operation())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn expect_create_directory(
|
||||
self,
|
||||
) -> Result<FsCreateDirectoryResponse, JSONRPCErrorError> {
|
||||
match self {
|
||||
Self::CreateDirectory(response) => Ok(response),
|
||||
other => Err(unexpected_response(
|
||||
FS_CREATE_DIRECTORY_METHOD,
|
||||
other.operation(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn expect_get_metadata(self) -> Result<FsGetMetadataResponse, JSONRPCErrorError> {
|
||||
match self {
|
||||
Self::GetMetadata(response) => Ok(response),
|
||||
other => Err(unexpected_response(
|
||||
FS_GET_METADATA_METHOD,
|
||||
other.operation(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn expect_read_directory(
|
||||
self,
|
||||
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
|
||||
match self {
|
||||
Self::ReadDirectory(response) => Ok(response),
|
||||
other => Err(unexpected_response(
|
||||
FS_READ_DIRECTORY_METHOD,
|
||||
other.operation(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn expect_remove(self) -> Result<FsRemoveResponse, JSONRPCErrorError> {
|
||||
match self {
|
||||
Self::Remove(response) => Ok(response),
|
||||
other => Err(unexpected_response(FS_REMOVE_METHOD, other.operation())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn expect_copy(self) -> Result<FsCopyResponse, JSONRPCErrorError> {
|
||||
match self {
|
||||
Self::Copy(response) => Ok(response),
|
||||
other => Err(unexpected_response(FS_COPY_METHOD, other.operation())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unexpected_response(expected: &str, actual: &str) -> JSONRPCErrorError {
|
||||
internal_error(format!(
|
||||
"unexpected fs sandbox helper response: expected {expected}, got {actual}"
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn run_direct_request(
|
||||
request: FsHelperRequest,
|
||||
) -> Result<FsHelperPayload, JSONRPCErrorError> {
|
||||
let file_system = DirectFileSystem;
|
||||
match request {
|
||||
FsHelperRequest::ReadFile(params) => {
|
||||
let data = file_system
|
||||
.read_file(¶ms.path, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsHelperPayload::ReadFile(FsReadFileResponse {
|
||||
data_base64: STANDARD.encode(data),
|
||||
}))
|
||||
}
|
||||
FsHelperRequest::WriteFile(params) => {
|
||||
let bytes = STANDARD.decode(params.data_base64).map_err(|err| {
|
||||
invalid_request(format!(
|
||||
"{FS_WRITE_FILE_METHOD} requires valid base64 dataBase64: {err}"
|
||||
))
|
||||
})?;
|
||||
file_system
|
||||
.write_file(¶ms.path, bytes, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsHelperPayload::WriteFile(FsWriteFileResponse {}))
|
||||
}
|
||||
FsHelperRequest::CreateDirectory(params) => {
|
||||
file_system
|
||||
.create_directory(
|
||||
¶ms.path,
|
||||
CreateDirectoryOptions {
|
||||
recursive: params.recursive.unwrap_or(true),
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsHelperPayload::CreateDirectory(
|
||||
FsCreateDirectoryResponse {},
|
||||
))
|
||||
}
|
||||
FsHelperRequest::GetMetadata(params) => {
|
||||
let metadata = file_system
|
||||
.get_metadata(¶ms.path, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsHelperPayload::GetMetadata(FsGetMetadataResponse {
|
||||
is_directory: metadata.is_directory,
|
||||
is_file: metadata.is_file,
|
||||
created_at_ms: metadata.created_at_ms,
|
||||
modified_at_ms: metadata.modified_at_ms,
|
||||
}))
|
||||
}
|
||||
FsHelperRequest::ReadDirectory(params) => {
|
||||
let entries = file_system
|
||||
.read_directory(¶ms.path, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(map_fs_error)?
|
||||
.into_iter()
|
||||
.map(|entry| FsReadDirectoryEntry {
|
||||
file_name: entry.file_name,
|
||||
is_directory: entry.is_directory,
|
||||
is_file: entry.is_file,
|
||||
})
|
||||
.collect();
|
||||
Ok(FsHelperPayload::ReadDirectory(FsReadDirectoryResponse {
|
||||
entries,
|
||||
}))
|
||||
}
|
||||
FsHelperRequest::Remove(params) => {
|
||||
file_system
|
||||
.remove(
|
||||
¶ms.path,
|
||||
RemoveOptions {
|
||||
recursive: params.recursive.unwrap_or(true),
|
||||
force: params.force.unwrap_or(true),
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsHelperPayload::Remove(FsRemoveResponse {}))
|
||||
}
|
||||
FsHelperRequest::Copy(params) => {
|
||||
file_system
|
||||
.copy(
|
||||
¶ms.source_path,
|
||||
¶ms.destination_path,
|
||||
CopyOptions {
|
||||
recursive: params.recursive,
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsHelperPayload::Copy(FsCopyResponse {}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
|
||||
match err.kind() {
|
||||
io::ErrorKind::NotFound => not_found(err.to_string()),
|
||||
io::ErrorKind::InvalidInput | io::ErrorKind::PermissionDenied => {
|
||||
invalid_request(err.to_string())
|
||||
}
|
||||
_ => internal_error(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn helper_requests_use_fs_method_names() -> serde_json::Result<()> {
|
||||
assert_eq!(
|
||||
serde_json::to_value(FsHelperRequest::WriteFile(FsWriteFileParams {
|
||||
path: std::env::current_dir()
|
||||
.expect("cwd")
|
||||
.join("file")
|
||||
.as_path()
|
||||
.try_into()
|
||||
.expect("absolute path"),
|
||||
data_base64: String::new(),
|
||||
sandbox: None,
|
||||
}))?["operation"],
|
||||
FS_WRITE_FILE_METHOD,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
45
codex-rs/exec-server/src/fs_helper_main.rs
Normal file
45
codex-rs/exec-server/src/fs_helper_main.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use std::error::Error;
|
||||
|
||||
use tokio::io;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::fs_helper::FsHelperRequest;
|
||||
use crate::fs_helper::FsHelperResponse;
|
||||
use crate::fs_helper::run_direct_request;
|
||||
|
||||
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!("fs sandbox helper failed: {err}");
|
||||
1
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
eprintln!("failed to start fs sandbox helper runtime: {err}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
async fn run_main() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let mut input = Vec::new();
|
||||
io::stdin().read_to_end(&mut input).await?;
|
||||
let request: FsHelperRequest = serde_json::from_slice(&input)?;
|
||||
let response = match run_direct_request(request).await {
|
||||
Ok(payload) => FsHelperResponse::Ok(payload),
|
||||
Err(error) => FsHelperResponse::Error(error),
|
||||
};
|
||||
let mut stdout = io::stdout();
|
||||
stdout
|
||||
.write_all(serde_json::to_string(&response)?.as_bytes())
|
||||
.await?;
|
||||
stdout.write_all(b"\n").await?;
|
||||
Ok(())
|
||||
}
|
||||
546
codex-rs/exec-server/src/fs_sandbox.rs
Normal file
546
codex-rs/exec-server/src/fs_sandbox.rs
Normal file
@@ -0,0 +1,546 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::SandboxCommand;
|
||||
use codex_sandboxing::SandboxExecRequest;
|
||||
use codex_sandboxing::SandboxManager;
|
||||
use codex_sandboxing::SandboxTransformRequest;
|
||||
use codex_sandboxing::SandboxablePreference;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::canonicalize_preserving_symlinks;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::ExecServerRuntimePaths;
|
||||
use crate::FileSystemSandboxContext;
|
||||
use crate::fs_helper::CODEX_FS_HELPER_ARG1;
|
||||
use crate::fs_helper::FsHelperPayload;
|
||||
use crate::fs_helper::FsHelperRequest;
|
||||
use crate::fs_helper::FsHelperResponse;
|
||||
use crate::local_file_system::current_sandbox_cwd;
|
||||
use crate::local_file_system::resolve_existing_path;
|
||||
use crate::protocol::FsCopyParams;
|
||||
use crate::protocol::FsCreateDirectoryParams;
|
||||
use crate::protocol::FsGetMetadataParams;
|
||||
use crate::protocol::FsReadDirectoryParams;
|
||||
use crate::protocol::FsReadFileParams;
|
||||
use crate::protocol::FsRemoveParams;
|
||||
use crate::protocol::FsWriteFileParams;
|
||||
use crate::rpc::internal_error;
|
||||
use crate::rpc::invalid_request;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct FileSystemSandboxRunner {
|
||||
runtime_paths: ExecServerRuntimePaths,
|
||||
}
|
||||
|
||||
impl FileSystemSandboxRunner {
|
||||
pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Self {
|
||||
Self { runtime_paths }
|
||||
}
|
||||
|
||||
pub(crate) async fn run(
|
||||
&self,
|
||||
sandbox: &FileSystemSandboxContext,
|
||||
request: FsHelperRequest,
|
||||
) -> Result<FsHelperPayload, JSONRPCErrorError> {
|
||||
let request_sandbox_policy =
|
||||
normalize_sandbox_policy_root_aliases(sandbox.sandbox_policy.clone());
|
||||
let helper_sandbox_policy = normalize_sandbox_policy_root_aliases(
|
||||
sandbox_policy_with_helper_runtime_defaults(&sandbox.sandbox_policy),
|
||||
);
|
||||
let cwd = current_sandbox_cwd().map_err(io_error)?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(cwd.as_path())
|
||||
.map_err(|err| invalid_request(format!("current directory is not absolute: {err}")))?;
|
||||
let request_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
|
||||
&request_sandbox_policy,
|
||||
cwd.as_path(),
|
||||
);
|
||||
let file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
|
||||
&helper_sandbox_policy,
|
||||
cwd.as_path(),
|
||||
);
|
||||
let request = resolve_request_paths(request, &request_file_system_policy, &cwd)?;
|
||||
let network_policy = NetworkSandboxPolicy::Restricted;
|
||||
let command = self.sandbox_exec_request(
|
||||
&helper_sandbox_policy,
|
||||
&file_system_policy,
|
||||
network_policy,
|
||||
&cwd,
|
||||
sandbox,
|
||||
)?;
|
||||
let request_json = serde_json::to_vec(&request).map_err(json_error)?;
|
||||
run_command(command, request_json).await
|
||||
}
|
||||
|
||||
fn sandbox_exec_request(
|
||||
&self,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
file_system_policy: &FileSystemSandboxPolicy,
|
||||
network_policy: NetworkSandboxPolicy,
|
||||
cwd: &AbsolutePathBuf,
|
||||
sandbox_context: &FileSystemSandboxContext,
|
||||
) -> Result<SandboxExecRequest, JSONRPCErrorError> {
|
||||
let helper = &self.runtime_paths.codex_self_exe;
|
||||
let sandbox_manager = SandboxManager::new();
|
||||
let sandbox = sandbox_manager.select_initial(
|
||||
file_system_policy,
|
||||
network_policy,
|
||||
SandboxablePreference::Auto,
|
||||
sandbox_context.windows_sandbox_level,
|
||||
/*has_managed_network_requirements*/ false,
|
||||
);
|
||||
let command = SandboxCommand {
|
||||
program: helper.as_path().as_os_str().to_owned(),
|
||||
args: vec![CODEX_FS_HELPER_ARG1.to_string()],
|
||||
cwd: cwd.clone(),
|
||||
env: HashMap::new(),
|
||||
additional_permissions: Some(
|
||||
self.helper_permissions(sandbox_context.additional_permissions.as_ref()),
|
||||
),
|
||||
};
|
||||
sandbox_manager
|
||||
.transform(SandboxTransformRequest {
|
||||
command,
|
||||
policy: sandbox_policy,
|
||||
file_system_policy,
|
||||
network_policy,
|
||||
sandbox,
|
||||
enforce_managed_network: false,
|
||||
network: None,
|
||||
sandbox_policy_cwd: cwd.as_path(),
|
||||
codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(),
|
||||
use_legacy_landlock: sandbox_context.use_legacy_landlock,
|
||||
windows_sandbox_level: sandbox_context.windows_sandbox_level,
|
||||
windows_sandbox_private_desktop: sandbox_context.windows_sandbox_private_desktop,
|
||||
})
|
||||
.map_err(|err| invalid_request(format!("failed to prepare fs sandbox: {err}")))
|
||||
}
|
||||
|
||||
fn helper_permissions(
|
||||
&self,
|
||||
additional_permissions: Option<&PermissionProfile>,
|
||||
) -> PermissionProfile {
|
||||
PermissionProfile {
|
||||
network: None,
|
||||
file_system: additional_permissions
|
||||
.and_then(|permissions| permissions.file_system.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_request_paths(
|
||||
request: FsHelperRequest,
|
||||
file_system_policy: &FileSystemSandboxPolicy,
|
||||
cwd: &AbsolutePathBuf,
|
||||
) -> Result<FsHelperRequest, JSONRPCErrorError> {
|
||||
match request {
|
||||
FsHelperRequest::ReadFile(FsReadFileParams { path, sandbox }) => {
|
||||
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
|
||||
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Read)?;
|
||||
Ok(FsHelperRequest::ReadFile(FsReadFileParams {
|
||||
path,
|
||||
sandbox,
|
||||
}))
|
||||
}
|
||||
FsHelperRequest::WriteFile(FsWriteFileParams {
|
||||
path,
|
||||
data_base64,
|
||||
sandbox,
|
||||
}) => Ok(FsHelperRequest::WriteFile(FsWriteFileParams {
|
||||
path: {
|
||||
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
|
||||
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Write)?;
|
||||
path
|
||||
},
|
||||
data_base64,
|
||||
sandbox,
|
||||
})),
|
||||
FsHelperRequest::CreateDirectory(FsCreateDirectoryParams {
|
||||
path,
|
||||
recursive,
|
||||
sandbox,
|
||||
}) => Ok(FsHelperRequest::CreateDirectory(FsCreateDirectoryParams {
|
||||
path: {
|
||||
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
|
||||
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Write)?;
|
||||
path
|
||||
},
|
||||
recursive,
|
||||
sandbox,
|
||||
})),
|
||||
FsHelperRequest::GetMetadata(FsGetMetadataParams { path, sandbox }) => {
|
||||
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
|
||||
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Read)?;
|
||||
Ok(FsHelperRequest::GetMetadata(FsGetMetadataParams {
|
||||
path,
|
||||
sandbox,
|
||||
}))
|
||||
}
|
||||
FsHelperRequest::ReadDirectory(FsReadDirectoryParams { path, sandbox }) => {
|
||||
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
|
||||
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Read)?;
|
||||
Ok(FsHelperRequest::ReadDirectory(FsReadDirectoryParams {
|
||||
path,
|
||||
sandbox,
|
||||
}))
|
||||
}
|
||||
FsHelperRequest::Remove(FsRemoveParams {
|
||||
path,
|
||||
recursive,
|
||||
force,
|
||||
sandbox,
|
||||
}) => Ok(FsHelperRequest::Remove(FsRemoveParams {
|
||||
path: {
|
||||
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::Yes)?;
|
||||
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Write)?;
|
||||
path
|
||||
},
|
||||
recursive,
|
||||
force,
|
||||
sandbox,
|
||||
})),
|
||||
FsHelperRequest::Copy(FsCopyParams {
|
||||
source_path,
|
||||
destination_path,
|
||||
recursive,
|
||||
sandbox,
|
||||
}) => Ok(FsHelperRequest::Copy(FsCopyParams {
|
||||
source_path: {
|
||||
let source_path = resolve_sandbox_path(&source_path, PreserveTerminalSymlink::Yes)?;
|
||||
ensure_path_access(
|
||||
file_system_policy,
|
||||
cwd,
|
||||
&source_path,
|
||||
FileSystemAccessMode::Read,
|
||||
)?;
|
||||
source_path
|
||||
},
|
||||
destination_path: {
|
||||
let destination_path =
|
||||
resolve_sandbox_path(&destination_path, PreserveTerminalSymlink::No)?;
|
||||
ensure_path_access(
|
||||
file_system_policy,
|
||||
cwd,
|
||||
&destination_path,
|
||||
FileSystemAccessMode::Write,
|
||||
)?;
|
||||
destination_path
|
||||
},
|
||||
recursive,
|
||||
sandbox,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum PreserveTerminalSymlink {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
fn resolve_sandbox_path(
|
||||
path: &AbsolutePathBuf,
|
||||
preserve_terminal_symlink: PreserveTerminalSymlink,
|
||||
) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
|
||||
if matches!(preserve_terminal_symlink, PreserveTerminalSymlink::Yes)
|
||||
&& std::fs::symlink_metadata(path.as_path())
|
||||
.map(|metadata| metadata.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(normalize_top_level_alias(path.clone()));
|
||||
}
|
||||
|
||||
let resolved = resolve_existing_path(path.as_path()).map_err(io_error)?;
|
||||
absolute_path(resolved)
|
||||
}
|
||||
|
||||
fn normalize_sandbox_policy_root_aliases(sandbox_policy: SandboxPolicy) -> SandboxPolicy {
|
||||
let mut sandbox_policy = sandbox_policy;
|
||||
match &mut sandbox_policy {
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted { readable_roots, .. },
|
||||
..
|
||||
} => {
|
||||
normalize_root_aliases(readable_roots);
|
||||
}
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots,
|
||||
read_only_access,
|
||||
..
|
||||
} => {
|
||||
normalize_root_aliases(writable_roots);
|
||||
if let ReadOnlyAccess::Restricted { readable_roots, .. } = read_only_access {
|
||||
normalize_root_aliases(readable_roots);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
sandbox_policy
|
||||
}
|
||||
|
||||
fn normalize_root_aliases(paths: &mut Vec<AbsolutePathBuf>) {
|
||||
for path in paths {
|
||||
*path = normalize_top_level_alias(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_top_level_alias(path: AbsolutePathBuf) -> AbsolutePathBuf {
|
||||
let raw_path = path.to_path_buf();
|
||||
for ancestor in raw_path.ancestors() {
|
||||
if std::fs::symlink_metadata(ancestor).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(normalized_ancestor) = canonicalize_preserving_symlinks(ancestor) else {
|
||||
continue;
|
||||
};
|
||||
if normalized_ancestor == ancestor {
|
||||
continue;
|
||||
}
|
||||
let Ok(suffix) = raw_path.strip_prefix(ancestor) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(normalized_path) =
|
||||
AbsolutePathBuf::from_absolute_path(normalized_ancestor.join(suffix))
|
||||
{
|
||||
return normalized_path;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn absolute_path(path: PathBuf) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
|
||||
AbsolutePathBuf::from_absolute_path(path.as_path())
|
||||
.map_err(|err| invalid_request(format!("resolved sandbox path is not absolute: {err}")))
|
||||
}
|
||||
|
||||
fn ensure_path_access(
|
||||
file_system_policy: &FileSystemSandboxPolicy,
|
||||
cwd: &AbsolutePathBuf,
|
||||
path: &AbsolutePathBuf,
|
||||
required_access: FileSystemAccessMode,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
let actual_access = file_system_policy.resolve_access_with_cwd(path.as_path(), cwd.as_path());
|
||||
let permitted = match required_access {
|
||||
FileSystemAccessMode::Read => actual_access.can_read(),
|
||||
FileSystemAccessMode::Write => actual_access.can_write(),
|
||||
FileSystemAccessMode::None => true,
|
||||
};
|
||||
if permitted {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(invalid_request(format!(
|
||||
"{} is not permitted by filesystem sandbox",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
async fn run_command(
|
||||
command: SandboxExecRequest,
|
||||
request_json: Vec<u8>,
|
||||
) -> Result<FsHelperPayload, JSONRPCErrorError> {
|
||||
let mut child = spawn_command(command)?;
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| internal_error("failed to open fs sandbox helper stdin".to_string()))?;
|
||||
stdin.write_all(&request_json).await.map_err(io_error)?;
|
||||
stdin.shutdown().await.map_err(io_error)?;
|
||||
drop(stdin);
|
||||
|
||||
let output = child.wait_with_output().await.map_err(io_error)?;
|
||||
if !output.status.success() {
|
||||
return Err(internal_error(format!(
|
||||
"fs sandbox helper failed with status {status}: {stderr}",
|
||||
status = output.status,
|
||||
stderr = String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let response: FsHelperResponse = serde_json::from_slice(&output.stdout).map_err(json_error)?;
|
||||
match response {
|
||||
FsHelperResponse::Ok(payload) => Ok(payload),
|
||||
FsHelperResponse::Error(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_command(
|
||||
SandboxExecRequest {
|
||||
command: argv,
|
||||
cwd,
|
||||
env,
|
||||
arg0,
|
||||
..
|
||||
}: SandboxExecRequest,
|
||||
) -> Result<tokio::process::Child, JSONRPCErrorError> {
|
||||
let Some((program, args)) = argv.split_first() else {
|
||||
return Err(invalid_request("fs sandbox command was empty".to_string()));
|
||||
};
|
||||
let mut command = Command::new(program);
|
||||
#[cfg(unix)]
|
||||
if let Some(arg0) = arg0 {
|
||||
command.arg0(arg0);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = arg0;
|
||||
command.args(args);
|
||||
command.current_dir(cwd.as_path());
|
||||
command.env_clear();
|
||||
command.envs(env);
|
||||
command.stdin(std::process::Stdio::piped());
|
||||
command.stdout(std::process::Stdio::piped());
|
||||
command.stderr(std::process::Stdio::piped());
|
||||
command.spawn().map_err(io_error)
|
||||
}
|
||||
|
||||
fn sandbox_policy_with_helper_runtime_defaults(sandbox_policy: &SandboxPolicy) -> SandboxPolicy {
|
||||
let mut sandbox_policy = sandbox_policy.clone();
|
||||
match &mut sandbox_policy {
|
||||
SandboxPolicy::ReadOnly { access, .. } => enable_platform_defaults(access),
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
read_only_access, ..
|
||||
} => enable_platform_defaults(read_only_access),
|
||||
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {}
|
||||
}
|
||||
sandbox_policy
|
||||
}
|
||||
|
||||
fn enable_platform_defaults(access: &mut ReadOnlyAccess) {
|
||||
if let ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults,
|
||||
..
|
||||
} = access
|
||||
{
|
||||
*include_platform_defaults = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn io_error(err: std::io::Error) -> JSONRPCErrorError {
|
||||
internal_error(err.to_string())
|
||||
}
|
||||
|
||||
fn json_error(err: serde_json::Error) -> JSONRPCErrorError {
|
||||
internal_error(format!(
|
||||
"failed to encode or decode fs sandbox helper message: {err}"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use crate::ExecServerRuntimePaths;
|
||||
|
||||
use super::FileSystemSandboxRunner;
|
||||
use super::sandbox_policy_with_helper_runtime_defaults;
|
||||
|
||||
#[test]
|
||||
fn helper_sandbox_policy_enables_platform_defaults_for_read_only_access() {
|
||||
let sandbox_policy = SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: false,
|
||||
readable_roots: Vec::new(),
|
||||
},
|
||||
network_access: false,
|
||||
};
|
||||
|
||||
let updated = sandbox_policy_with_helper_runtime_defaults(&sandbox_policy);
|
||||
|
||||
assert_eq!(
|
||||
updated,
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: Vec::new(),
|
||||
},
|
||||
network_access: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_sandbox_policy_enables_platform_defaults_for_workspace_read_access() {
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: false,
|
||||
readable_roots: Vec::new(),
|
||||
},
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
let updated = sandbox_policy_with_helper_runtime_defaults(&sandbox_policy);
|
||||
|
||||
assert_eq!(
|
||||
updated,
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: Vec::new(),
|
||||
},
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_permissions_strip_network_grants() {
|
||||
let codex_self_exe = std::env::current_exe().expect("current exe");
|
||||
let runtime_paths = ExecServerRuntimePaths::new(
|
||||
codex_self_exe.clone(),
|
||||
/*codex_linux_sandbox_exe*/ None,
|
||||
)
|
||||
.expect("runtime paths");
|
||||
let runner = FileSystemSandboxRunner::new(runtime_paths);
|
||||
let readable = AbsolutePathBuf::from_absolute_path(
|
||||
codex_self_exe.parent().expect("current exe parent"),
|
||||
)
|
||||
.expect("absolute readable path");
|
||||
let writable = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path())
|
||||
.expect("absolute writable path");
|
||||
|
||||
let permissions = runner.helper_permissions(Some(&PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![readable.clone()]),
|
||||
write: Some(vec![writable.clone()]),
|
||||
}),
|
||||
}));
|
||||
|
||||
assert_eq!(permissions.network, None);
|
||||
assert_eq!(
|
||||
permissions
|
||||
.file_system
|
||||
.as_ref()
|
||||
.and_then(|fs| fs.write.clone()),
|
||||
Some(vec![writable])
|
||||
);
|
||||
assert_eq!(
|
||||
permissions
|
||||
.file_system
|
||||
.as_ref()
|
||||
.and_then(|fs| fs.read.clone()),
|
||||
Some(vec![readable])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ mod client_api;
|
||||
mod connection;
|
||||
mod environment;
|
||||
mod file_system;
|
||||
mod fs_helper;
|
||||
mod fs_helper_main;
|
||||
mod fs_sandbox;
|
||||
mod local_file_system;
|
||||
mod local_process;
|
||||
mod process;
|
||||
@@ -11,6 +14,8 @@ mod protocol;
|
||||
mod remote_file_system;
|
||||
mod remote_process;
|
||||
mod rpc;
|
||||
mod runtime_paths;
|
||||
mod sandboxed_file_system;
|
||||
mod server;
|
||||
|
||||
pub use client::ExecServerClient;
|
||||
@@ -25,9 +30,13 @@ pub use file_system::CreateDirectoryOptions;
|
||||
pub use file_system::ExecutorFileSystem;
|
||||
pub use file_system::FileMetadata;
|
||||
pub use file_system::FileSystemResult;
|
||||
pub use file_system::FileSystemSandboxContext;
|
||||
pub use file_system::ReadDirectoryEntry;
|
||||
pub use file_system::RemoveOptions;
|
||||
pub use fs_helper::CODEX_FS_HELPER_ARG1;
|
||||
pub use fs_helper_main::main as run_fs_helper_main;
|
||||
pub use local_file_system::LOCAL_FS;
|
||||
pub use local_file_system::LocalFileSystem;
|
||||
pub use process::ExecBackend;
|
||||
pub use process::ExecProcess;
|
||||
pub use process::StartedExecProcess;
|
||||
@@ -62,7 +71,7 @@ pub use protocol::TerminateResponse;
|
||||
pub use protocol::WriteParams;
|
||||
pub use protocol::WriteResponse;
|
||||
pub use protocol::WriteStatus;
|
||||
pub use runtime_paths::ExecServerRuntimePaths;
|
||||
pub use server::DEFAULT_LISTEN_URL;
|
||||
pub use server::ExecServerListenUrlParseError;
|
||||
pub use server::run_main;
|
||||
pub use server::run_main_with_listen_url;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use async_trait::async_trait;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -13,23 +10,240 @@ use tokio::io;
|
||||
|
||||
use crate::CopyOptions;
|
||||
use crate::CreateDirectoryOptions;
|
||||
use crate::ExecServerRuntimePaths;
|
||||
use crate::ExecutorFileSystem;
|
||||
use crate::FileMetadata;
|
||||
use crate::FileSystemResult;
|
||||
use crate::FileSystemSandboxContext;
|
||||
use crate::ReadDirectoryEntry;
|
||||
use crate::RemoveOptions;
|
||||
use crate::sandboxed_file_system::SandboxedFileSystem;
|
||||
|
||||
const MAX_READ_FILE_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
pub static LOCAL_FS: LazyLock<Arc<dyn ExecutorFileSystem>> =
|
||||
LazyLock::new(|| -> Arc<dyn ExecutorFileSystem> { Arc::new(LocalFileSystem) });
|
||||
LazyLock::new(|| -> Arc<dyn ExecutorFileSystem> { Arc::new(LocalFileSystem::unsandboxed()) });
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct LocalFileSystem;
|
||||
pub(crate) struct DirectFileSystem;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct UnsandboxedFileSystem {
|
||||
file_system: DirectFileSystem,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LocalFileSystem {
|
||||
unsandboxed: UnsandboxedFileSystem,
|
||||
sandboxed: Option<SandboxedFileSystem>,
|
||||
}
|
||||
|
||||
impl LocalFileSystem {
|
||||
pub fn unsandboxed() -> Self {
|
||||
Self {
|
||||
unsandboxed: UnsandboxedFileSystem::default(),
|
||||
sandboxed: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_runtime_paths(runtime_paths: ExecServerRuntimePaths) -> Self {
|
||||
Self {
|
||||
unsandboxed: UnsandboxedFileSystem::default(),
|
||||
sandboxed: Some(SandboxedFileSystem::new(runtime_paths)),
|
||||
}
|
||||
}
|
||||
|
||||
fn sandboxed(&self) -> io::Result<&SandboxedFileSystem> {
|
||||
self.sandboxed.as_ref().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"sandboxed filesystem operations require configured runtime paths",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn file_system_for<'a>(
|
||||
&'a self,
|
||||
sandbox: Option<&'a FileSystemSandboxContext>,
|
||||
) -> io::Result<(
|
||||
&'a dyn ExecutorFileSystem,
|
||||
Option<&'a FileSystemSandboxContext>,
|
||||
)> {
|
||||
if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) {
|
||||
Ok((self.sandboxed()?, sandbox))
|
||||
} else {
|
||||
Ok((&self.unsandboxed, sandbox))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExecutorFileSystem for LocalFileSystem {
|
||||
async fn read_file(&self, path: &AbsolutePathBuf) -> FileSystemResult<Vec<u8>> {
|
||||
async fn read_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
let (file_system, sandbox) = self.file_system_for(sandbox)?;
|
||||
file_system.read_file(path, sandbox).await
|
||||
}
|
||||
|
||||
async fn write_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
contents: Vec<u8>,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
let (file_system, sandbox) = self.file_system_for(sandbox)?;
|
||||
file_system.write_file(path, contents, sandbox).await
|
||||
}
|
||||
|
||||
async fn create_directory(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
options: CreateDirectoryOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
let (file_system, sandbox) = self.file_system_for(sandbox)?;
|
||||
file_system.create_directory(path, options, sandbox).await
|
||||
}
|
||||
|
||||
async fn get_metadata(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<FileMetadata> {
|
||||
let (file_system, sandbox) = self.file_system_for(sandbox)?;
|
||||
file_system.get_metadata(path, sandbox).await
|
||||
}
|
||||
|
||||
async fn read_directory(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
|
||||
let (file_system, sandbox) = self.file_system_for(sandbox)?;
|
||||
file_system.read_directory(path, sandbox).await
|
||||
}
|
||||
|
||||
async fn remove(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
options: RemoveOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
let (file_system, sandbox) = self.file_system_for(sandbox)?;
|
||||
file_system.remove(path, options, sandbox).await
|
||||
}
|
||||
|
||||
async fn copy(
|
||||
&self,
|
||||
source_path: &AbsolutePathBuf,
|
||||
destination_path: &AbsolutePathBuf,
|
||||
options: CopyOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
let (file_system, sandbox) = self.file_system_for(sandbox)?;
|
||||
file_system
|
||||
.copy(source_path, destination_path, options, sandbox)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExecutorFileSystem for UnsandboxedFileSystem {
|
||||
async fn read_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
reject_platform_sandbox_context(sandbox)?;
|
||||
self.file_system.read_file(path, /*sandbox*/ None).await
|
||||
}
|
||||
|
||||
async fn write_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
contents: Vec<u8>,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
reject_platform_sandbox_context(sandbox)?;
|
||||
self.file_system
|
||||
.write_file(path, contents, /*sandbox*/ None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_directory(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
options: CreateDirectoryOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
reject_platform_sandbox_context(sandbox)?;
|
||||
self.file_system
|
||||
.create_directory(path, options, /*sandbox*/ None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_metadata(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<FileMetadata> {
|
||||
reject_platform_sandbox_context(sandbox)?;
|
||||
self.file_system.get_metadata(path, /*sandbox*/ None).await
|
||||
}
|
||||
|
||||
async fn read_directory(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
|
||||
reject_platform_sandbox_context(sandbox)?;
|
||||
self.file_system
|
||||
.read_directory(path, /*sandbox*/ None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn remove(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
options: RemoveOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
reject_platform_sandbox_context(sandbox)?;
|
||||
self.file_system
|
||||
.remove(path, options, /*sandbox*/ None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn copy(
|
||||
&self,
|
||||
source_path: &AbsolutePathBuf,
|
||||
destination_path: &AbsolutePathBuf,
|
||||
options: CopyOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
reject_platform_sandbox_context(sandbox)?;
|
||||
self.file_system
|
||||
.copy(
|
||||
source_path,
|
||||
destination_path,
|
||||
options,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExecutorFileSystem for DirectFileSystem {
|
||||
async fn read_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
reject_sandbox_context(sandbox)?;
|
||||
let metadata = tokio::fs::metadata(path.as_path()).await?;
|
||||
if metadata.len() > MAX_READ_FILE_BYTES {
|
||||
return Err(io::Error::new(
|
||||
@@ -40,34 +254,23 @@ impl ExecutorFileSystem for LocalFileSystem {
|
||||
tokio::fs::read(path.as_path()).await
|
||||
}
|
||||
|
||||
async fn read_file_with_sandbox_policy(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
enforce_read_access(path, sandbox_policy)?;
|
||||
self.read_file(path).await
|
||||
}
|
||||
|
||||
async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec<u8>) -> FileSystemResult<()> {
|
||||
tokio::fs::write(path.as_path(), contents).await
|
||||
}
|
||||
|
||||
async fn write_file_with_sandbox_policy(
|
||||
async fn write_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
contents: Vec<u8>,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
enforce_write_access(path, sandbox_policy)?;
|
||||
self.write_file(path, contents).await
|
||||
reject_sandbox_context(sandbox)?;
|
||||
tokio::fs::write(path.as_path(), contents).await
|
||||
}
|
||||
|
||||
async fn create_directory(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
options: CreateDirectoryOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
reject_sandbox_context(sandbox)?;
|
||||
if options.recursive {
|
||||
tokio::fs::create_dir_all(path.as_path()).await?;
|
||||
} else {
|
||||
@@ -76,17 +279,12 @@ impl ExecutorFileSystem for LocalFileSystem {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_directory_with_sandbox_policy(
|
||||
async fn get_metadata(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
create_directory_options: CreateDirectoryOptions,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<()> {
|
||||
enforce_write_access(path, sandbox_policy)?;
|
||||
self.create_directory(path, create_directory_options).await
|
||||
}
|
||||
|
||||
async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult<FileMetadata> {
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<FileMetadata> {
|
||||
reject_sandbox_context(sandbox)?;
|
||||
let metadata = tokio::fs::metadata(path.as_path()).await?;
|
||||
Ok(FileMetadata {
|
||||
is_directory: metadata.is_dir(),
|
||||
@@ -96,19 +294,12 @@ impl ExecutorFileSystem for LocalFileSystem {
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_metadata_with_sandbox_policy(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<FileMetadata> {
|
||||
enforce_read_access(path, sandbox_policy)?;
|
||||
self.get_metadata(path).await
|
||||
}
|
||||
|
||||
async fn read_directory(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
|
||||
reject_sandbox_context(sandbox)?;
|
||||
let mut entries = Vec::new();
|
||||
let mut read_dir = tokio::fs::read_dir(path.as_path()).await?;
|
||||
while let Some(entry) = read_dir.next_entry().await? {
|
||||
@@ -122,16 +313,13 @@ impl ExecutorFileSystem for LocalFileSystem {
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn read_directory_with_sandbox_policy(
|
||||
async fn remove(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
|
||||
enforce_read_access(path, sandbox_policy)?;
|
||||
self.read_directory(path).await
|
||||
}
|
||||
|
||||
async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()> {
|
||||
options: RemoveOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
reject_sandbox_context(sandbox)?;
|
||||
match tokio::fs::symlink_metadata(path.as_path()).await {
|
||||
Ok(metadata) => {
|
||||
let file_type = metadata.file_type();
|
||||
@@ -151,22 +339,14 @@ impl ExecutorFileSystem for LocalFileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_with_sandbox_policy(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
remove_options: RemoveOptions,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<()> {
|
||||
enforce_write_access_preserving_leaf(path, sandbox_policy)?;
|
||||
self.remove(path, remove_options).await
|
||||
}
|
||||
|
||||
async fn copy(
|
||||
&self,
|
||||
source_path: &AbsolutePathBuf,
|
||||
destination_path: &AbsolutePathBuf,
|
||||
options: CopyOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
reject_sandbox_context(sandbox)?;
|
||||
let source_path = source_path.to_path_buf();
|
||||
let destination_path = destination_path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || -> FileSystemResult<()> {
|
||||
@@ -211,164 +391,26 @@ impl ExecutorFileSystem for LocalFileSystem {
|
||||
.await
|
||||
.map_err(|err| io::Error::other(format!("filesystem task failed: {err}")))?
|
||||
}
|
||||
|
||||
async fn copy_with_sandbox_policy(
|
||||
&self,
|
||||
source_path: &AbsolutePathBuf,
|
||||
destination_path: &AbsolutePathBuf,
|
||||
copy_options: CopyOptions,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<()> {
|
||||
enforce_copy_source_read_access(source_path, sandbox_policy)?;
|
||||
enforce_write_access(destination_path, sandbox_policy)?;
|
||||
self.copy(source_path, destination_path, copy_options).await
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce_read_access(
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<()> {
|
||||
enforce_access_for_current_dir(
|
||||
path,
|
||||
sandbox_policy,
|
||||
FileSystemSandboxPolicy::can_read_path_with_cwd,
|
||||
"read",
|
||||
AccessPathMode::ResolveAll,
|
||||
)
|
||||
}
|
||||
|
||||
fn enforce_write_access(
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<()> {
|
||||
enforce_access_for_current_dir(
|
||||
path,
|
||||
sandbox_policy,
|
||||
FileSystemSandboxPolicy::can_write_path_with_cwd,
|
||||
"write",
|
||||
AccessPathMode::ResolveAll,
|
||||
)
|
||||
}
|
||||
|
||||
fn enforce_write_access_preserving_leaf(
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<()> {
|
||||
enforce_access_for_current_dir(
|
||||
path,
|
||||
sandbox_policy,
|
||||
FileSystemSandboxPolicy::can_write_path_with_cwd,
|
||||
"write",
|
||||
AccessPathMode::PreserveLeaf,
|
||||
)
|
||||
}
|
||||
|
||||
fn enforce_copy_source_read_access(
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<()> {
|
||||
let path_mode = match std::fs::symlink_metadata(path.as_path()) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => AccessPathMode::PreserveLeaf,
|
||||
_ => AccessPathMode::ResolveAll,
|
||||
};
|
||||
enforce_access_for_current_dir(
|
||||
path,
|
||||
sandbox_policy,
|
||||
FileSystemSandboxPolicy::can_read_path_with_cwd,
|
||||
"read",
|
||||
path_mode,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
fn enforce_read_access_for_cwd(
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
sandbox_cwd: &AbsolutePathBuf,
|
||||
) -> FileSystemResult<()> {
|
||||
enforce_access_for_cwd(
|
||||
path,
|
||||
sandbox_policy,
|
||||
sandbox_cwd,
|
||||
FileSystemSandboxPolicy::can_read_path_with_cwd,
|
||||
"read",
|
||||
AccessPathMode::ResolveAll,
|
||||
)
|
||||
}
|
||||
|
||||
fn enforce_access_for_current_dir(
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
is_allowed: fn(&FileSystemSandboxPolicy, &Path, &Path) -> bool,
|
||||
access_kind: &str,
|
||||
path_mode: AccessPathMode,
|
||||
) -> FileSystemResult<()> {
|
||||
let Some(sandbox_policy) = sandbox_policy else {
|
||||
return Ok(());
|
||||
};
|
||||
let cwd = current_sandbox_cwd()?;
|
||||
enforce_access(
|
||||
path,
|
||||
sandbox_policy,
|
||||
cwd.as_path(),
|
||||
is_allowed,
|
||||
access_kind,
|
||||
path_mode,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
fn enforce_access_for_cwd(
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
sandbox_cwd: &AbsolutePathBuf,
|
||||
is_allowed: fn(&FileSystemSandboxPolicy, &Path, &Path) -> bool,
|
||||
access_kind: &str,
|
||||
path_mode: AccessPathMode,
|
||||
) -> FileSystemResult<()> {
|
||||
let Some(sandbox_policy) = sandbox_policy else {
|
||||
return Ok(());
|
||||
};
|
||||
let cwd = resolve_existing_path(sandbox_cwd.as_path())?;
|
||||
enforce_access(
|
||||
path,
|
||||
sandbox_policy,
|
||||
cwd.as_path(),
|
||||
is_allowed,
|
||||
access_kind,
|
||||
path_mode,
|
||||
)
|
||||
}
|
||||
|
||||
fn enforce_access(
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
sandbox_cwd: &Path,
|
||||
is_allowed: fn(&FileSystemSandboxPolicy, &Path, &Path) -> bool,
|
||||
access_kind: &str,
|
||||
path_mode: AccessPathMode,
|
||||
) -> FileSystemResult<()> {
|
||||
let resolved_path = resolve_path_for_access_check(path.as_path(), path_mode)?;
|
||||
let file_system_policy =
|
||||
canonicalize_file_system_policy_paths(FileSystemSandboxPolicy::from(sandbox_policy))?;
|
||||
if is_allowed(&file_system_policy, resolved_path.as_path(), sandbox_cwd) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
fn reject_sandbox_context(sandbox: Option<&FileSystemSandboxContext>) -> io::Result<()> {
|
||||
if sandbox.is_some() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"fs/{access_kind} is not permitted for path {}",
|
||||
path.as_path().display()
|
||||
),
|
||||
))
|
||||
"direct filesystem operations do not accept sandbox context",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AccessPathMode {
|
||||
ResolveAll,
|
||||
PreserveLeaf,
|
||||
fn reject_platform_sandbox_context(sandbox: Option<&FileSystemSandboxContext>) -> io::Result<()> {
|
||||
if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"sandboxed filesystem operations require configured runtime paths",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {
|
||||
@@ -395,28 +437,11 @@ fn destination_is_same_or_descendant_of_source(
|
||||
destination: &Path,
|
||||
) -> io::Result<bool> {
|
||||
let source = std::fs::canonicalize(source)?;
|
||||
let destination = resolve_path_for_access_check(destination, AccessPathMode::ResolveAll)?;
|
||||
let destination = resolve_existing_path(destination)?;
|
||||
Ok(destination.starts_with(&source))
|
||||
}
|
||||
|
||||
fn resolve_path_for_access_check(path: &Path, path_mode: AccessPathMode) -> io::Result<PathBuf> {
|
||||
match path_mode {
|
||||
AccessPathMode::ResolveAll => resolve_existing_path(path),
|
||||
AccessPathMode::PreserveLeaf => preserve_leaf_path_for_access_check(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn preserve_leaf_path_for_access_check(path: &Path) -> io::Result<PathBuf> {
|
||||
let Some(file_name) = path.file_name() else {
|
||||
return resolve_existing_path(path);
|
||||
};
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("/"));
|
||||
let mut resolved_parent = resolve_existing_path(parent)?;
|
||||
resolved_parent.push(file_name);
|
||||
Ok(resolved_parent)
|
||||
}
|
||||
|
||||
fn resolve_existing_path(path: &Path) -> io::Result<PathBuf> {
|
||||
pub(crate) fn resolve_existing_path(path: &Path) -> io::Result<PathBuf> {
|
||||
let mut unresolved_suffix = Vec::new();
|
||||
let mut existing_path = path;
|
||||
while !existing_path.exists() {
|
||||
@@ -437,33 +462,12 @@ fn resolve_existing_path(path: &Path) -> io::Result<PathBuf> {
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn current_sandbox_cwd() -> io::Result<PathBuf> {
|
||||
pub(crate) fn current_sandbox_cwd() -> io::Result<PathBuf> {
|
||||
let cwd = std::env::current_dir()
|
||||
.map_err(|err| io::Error::other(format!("failed to read current dir: {err}")))?;
|
||||
resolve_existing_path(cwd.as_path())
|
||||
}
|
||||
|
||||
fn canonicalize_file_system_policy_paths(
|
||||
mut file_system_policy: FileSystemSandboxPolicy,
|
||||
) -> io::Result<FileSystemSandboxPolicy> {
|
||||
for entry in &mut file_system_policy.entries {
|
||||
if let FileSystemPath::Path { path } = &mut entry.path {
|
||||
*path = canonicalize_absolute_path(path)?;
|
||||
}
|
||||
}
|
||||
Ok(file_system_policy)
|
||||
}
|
||||
|
||||
fn canonicalize_absolute_path(path: &AbsolutePathBuf) -> io::Result<AbsolutePathBuf> {
|
||||
let resolved = resolve_existing_path(path.as_path())?;
|
||||
AbsolutePathBuf::from_absolute_path(resolved.as_path()).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("path must stay absolute after canonicalization: {err}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn copy_symlink(source: &Path, target: &Path) -> io::Result<()> {
|
||||
let link_target = std::fs::read_link(source)?;
|
||||
#[cfg(unix)]
|
||||
@@ -508,29 +512,11 @@ fn system_time_to_unix_ms(time: SystemTime) -> i64 {
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
fn absolute_path(path: PathBuf) -> AbsolutePathBuf {
|
||||
match AbsolutePathBuf::try_from(path) {
|
||||
Ok(path) => path,
|
||||
Err(err) => panic!("absolute path: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_only_sandbox_policy(readable_roots: Vec<PathBuf>) -> SandboxPolicy {
|
||||
SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: false,
|
||||
readable_roots: readable_roots.into_iter().map(absolute_path).collect(),
|
||||
},
|
||||
network_access: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_path_for_access_check_rejects_symlink_parent_dotdot_escape() -> io::Result<()> {
|
||||
fn resolve_existing_path_handles_symlink_parent_dotdot_escape() -> io::Result<()> {
|
||||
let temp_dir = tempfile::TempDir::new()?;
|
||||
let allowed_dir = temp_dir.path().join("allowed");
|
||||
let outside_dir = temp_dir.path().join("outside");
|
||||
@@ -538,13 +524,12 @@ mod tests {
|
||||
std::fs::create_dir_all(&outside_dir)?;
|
||||
symlink(&outside_dir, allowed_dir.join("link"))?;
|
||||
|
||||
let resolved = resolve_path_for_access_check(
|
||||
let resolved = resolve_existing_path(
|
||||
allowed_dir
|
||||
.join("link")
|
||||
.join("..")
|
||||
.join("secret.txt")
|
||||
.as_path(),
|
||||
AccessPathMode::ResolveAll,
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
@@ -553,29 +538,6 @@ mod tests {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforce_read_access_uses_explicit_sandbox_cwd() -> io::Result<()> {
|
||||
let temp_dir = tempfile::TempDir::new()?;
|
||||
let workspace_dir = temp_dir.path().join("workspace");
|
||||
let other_dir = temp_dir.path().join("other");
|
||||
let note_path = workspace_dir.join("note.txt");
|
||||
std::fs::create_dir_all(&workspace_dir)?;
|
||||
std::fs::create_dir_all(&other_dir)?;
|
||||
std::fs::write(¬e_path, "hello")?;
|
||||
|
||||
let sandbox_policy = read_only_sandbox_policy(vec![]);
|
||||
let sandbox_cwd = absolute_path(workspace_dir);
|
||||
let other_cwd = absolute_path(other_dir);
|
||||
let note_path = absolute_path(note_path);
|
||||
|
||||
enforce_read_access_for_cwd(¬e_path, Some(&sandbox_policy), &sandbox_cwd)?;
|
||||
|
||||
let error = enforce_read_access_for_cwd(¬e_path, Some(&sandbox_policy), &other_cwd)
|
||||
.expect_err("read should be rejected outside provided cwd");
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::FileSystemSandboxContext;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -141,7 +141,7 @@ pub struct TerminateResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsReadFileParams {
|
||||
pub path: AbsolutePathBuf,
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
pub sandbox: Option<FileSystemSandboxContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -155,7 +155,7 @@ pub struct FsReadFileResponse {
|
||||
pub struct FsWriteFileParams {
|
||||
pub path: AbsolutePathBuf,
|
||||
pub data_base64: String,
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
pub sandbox: Option<FileSystemSandboxContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -167,7 +167,7 @@ pub struct FsWriteFileResponse {}
|
||||
pub struct FsCreateDirectoryParams {
|
||||
pub path: AbsolutePathBuf,
|
||||
pub recursive: Option<bool>,
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
pub sandbox: Option<FileSystemSandboxContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -178,7 +178,7 @@ pub struct FsCreateDirectoryResponse {}
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsGetMetadataParams {
|
||||
pub path: AbsolutePathBuf,
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
pub sandbox: Option<FileSystemSandboxContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -194,7 +194,7 @@ pub struct FsGetMetadataResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsReadDirectoryParams {
|
||||
pub path: AbsolutePathBuf,
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
pub sandbox: Option<FileSystemSandboxContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -217,7 +217,7 @@ pub struct FsRemoveParams {
|
||||
pub path: AbsolutePathBuf,
|
||||
pub recursive: Option<bool>,
|
||||
pub force: Option<bool>,
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
pub sandbox: Option<FileSystemSandboxContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -230,7 +230,7 @@ pub struct FsCopyParams {
|
||||
pub source_path: AbsolutePathBuf,
|
||||
pub destination_path: AbsolutePathBuf,
|
||||
pub recursive: bool,
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
pub sandbox: Option<FileSystemSandboxContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use tokio::io;
|
||||
use tracing::trace;
|
||||
@@ -13,6 +12,7 @@ use crate::ExecServerError;
|
||||
use crate::ExecutorFileSystem;
|
||||
use crate::FileMetadata;
|
||||
use crate::FileSystemResult;
|
||||
use crate::FileSystemSandboxContext;
|
||||
use crate::ReadDirectoryEntry;
|
||||
use crate::RemoveOptions;
|
||||
use crate::protocol::FsCopyParams;
|
||||
@@ -40,13 +40,17 @@ impl RemoteFileSystem {
|
||||
|
||||
#[async_trait]
|
||||
impl ExecutorFileSystem for RemoteFileSystem {
|
||||
async fn read_file(&self, path: &AbsolutePathBuf) -> FileSystemResult<Vec<u8>> {
|
||||
async fn read_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
trace!("remote fs read_file");
|
||||
let response = self
|
||||
.client
|
||||
.fs_read_file(FsReadFileParams {
|
||||
path: path.clone(),
|
||||
sandbox_policy: None,
|
||||
sandbox: sandbox.cloned(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
@@ -58,53 +62,18 @@ impl ExecutorFileSystem for RemoteFileSystem {
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_file_with_sandbox_policy(
|
||||
async fn write_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
trace!("remote fs read_file_with_sandbox_policy");
|
||||
let response = self
|
||||
.client
|
||||
.fs_read_file(FsReadFileParams {
|
||||
path: path.clone(),
|
||||
sandbox_policy: sandbox_policy.cloned(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
STANDARD.decode(response.data_base64).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("remote fs/readFile returned invalid base64 dataBase64: {err}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec<u8>) -> FileSystemResult<()> {
|
||||
contents: Vec<u8>,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
trace!("remote fs write_file");
|
||||
self.client
|
||||
.fs_write_file(FsWriteFileParams {
|
||||
path: path.clone(),
|
||||
data_base64: STANDARD.encode(contents),
|
||||
sandbox_policy: None,
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_file_with_sandbox_policy(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
contents: Vec<u8>,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<()> {
|
||||
trace!("remote fs write_file_with_sandbox_policy");
|
||||
self.client
|
||||
.fs_write_file(FsWriteFileParams {
|
||||
path: path.clone(),
|
||||
data_base64: STANDARD.encode(contents),
|
||||
sandbox_policy: sandbox_policy.cloned(),
|
||||
sandbox: sandbox.cloned(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
@@ -115,66 +84,31 @@ impl ExecutorFileSystem for RemoteFileSystem {
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
options: CreateDirectoryOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
trace!("remote fs create_directory");
|
||||
self.client
|
||||
.fs_create_directory(FsCreateDirectoryParams {
|
||||
path: path.clone(),
|
||||
recursive: Some(options.recursive),
|
||||
sandbox_policy: None,
|
||||
sandbox: sandbox.cloned(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_directory_with_sandbox_policy(
|
||||
async fn get_metadata(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
create_directory_options: CreateDirectoryOptions,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<()> {
|
||||
trace!("remote fs create_directory_with_sandbox_policy");
|
||||
self.client
|
||||
.fs_create_directory(FsCreateDirectoryParams {
|
||||
path: path.clone(),
|
||||
recursive: Some(create_directory_options.recursive),
|
||||
sandbox_policy: sandbox_policy.cloned(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult<FileMetadata> {
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<FileMetadata> {
|
||||
trace!("remote fs get_metadata");
|
||||
let response = self
|
||||
.client
|
||||
.fs_get_metadata(FsGetMetadataParams {
|
||||
path: path.clone(),
|
||||
sandbox_policy: None,
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
Ok(FileMetadata {
|
||||
is_directory: response.is_directory,
|
||||
is_file: response.is_file,
|
||||
created_at_ms: response.created_at_ms,
|
||||
modified_at_ms: response.modified_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_metadata_with_sandbox_policy(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<FileMetadata> {
|
||||
trace!("remote fs get_metadata_with_sandbox_policy");
|
||||
let response = self
|
||||
.client
|
||||
.fs_get_metadata(FsGetMetadataParams {
|
||||
path: path.clone(),
|
||||
sandbox_policy: sandbox_policy.cloned(),
|
||||
sandbox: sandbox.cloned(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
@@ -189,13 +123,14 @@ impl ExecutorFileSystem for RemoteFileSystem {
|
||||
async fn read_directory(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
|
||||
trace!("remote fs read_directory");
|
||||
let response = self
|
||||
.client
|
||||
.fs_read_directory(FsReadDirectoryParams {
|
||||
path: path.clone(),
|
||||
sandbox_policy: None,
|
||||
sandbox: sandbox.cloned(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
@@ -210,58 +145,19 @@ impl ExecutorFileSystem for RemoteFileSystem {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn read_directory_with_sandbox_policy(
|
||||
async fn remove(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
|
||||
trace!("remote fs read_directory_with_sandbox_policy");
|
||||
let response = self
|
||||
.client
|
||||
.fs_read_directory(FsReadDirectoryParams {
|
||||
path: path.clone(),
|
||||
sandbox_policy: sandbox_policy.cloned(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
Ok(response
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| ReadDirectoryEntry {
|
||||
file_name: entry.file_name,
|
||||
is_directory: entry.is_directory,
|
||||
is_file: entry.is_file,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()> {
|
||||
options: RemoveOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
trace!("remote fs remove");
|
||||
self.client
|
||||
.fs_remove(FsRemoveParams {
|
||||
path: path.clone(),
|
||||
recursive: Some(options.recursive),
|
||||
force: Some(options.force),
|
||||
sandbox_policy: None,
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_with_sandbox_policy(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
remove_options: RemoveOptions,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<()> {
|
||||
trace!("remote fs remove_with_sandbox_policy");
|
||||
self.client
|
||||
.fs_remove(FsRemoveParams {
|
||||
path: path.clone(),
|
||||
recursive: Some(remove_options.recursive),
|
||||
force: Some(remove_options.force),
|
||||
sandbox_policy: sandbox_policy.cloned(),
|
||||
sandbox: sandbox.cloned(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
@@ -273,6 +169,7 @@ impl ExecutorFileSystem for RemoteFileSystem {
|
||||
source_path: &AbsolutePathBuf,
|
||||
destination_path: &AbsolutePathBuf,
|
||||
options: CopyOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
trace!("remote fs copy");
|
||||
self.client
|
||||
@@ -280,27 +177,7 @@ impl ExecutorFileSystem for RemoteFileSystem {
|
||||
source_path: source_path.clone(),
|
||||
destination_path: destination_path.clone(),
|
||||
recursive: options.recursive,
|
||||
sandbox_policy: None,
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy_with_sandbox_policy(
|
||||
&self,
|
||||
source_path: &AbsolutePathBuf,
|
||||
destination_path: &AbsolutePathBuf,
|
||||
copy_options: CopyOptions,
|
||||
sandbox_policy: Option<&SandboxPolicy>,
|
||||
) -> FileSystemResult<()> {
|
||||
trace!("remote fs copy_with_sandbox_policy");
|
||||
self.client
|
||||
.fs_copy(FsCopyParams {
|
||||
source_path: source_path.clone(),
|
||||
destination_path: destination_path.clone(),
|
||||
recursive: copy_options.recursive,
|
||||
sandbox_policy: sandbox_policy.cloned(),
|
||||
sandbox: sandbox.cloned(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
|
||||
43
codex-rs/exec-server/src/runtime_paths.rs
Normal file
43
codex-rs/exec-server/src/runtime_paths.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
/// Runtime paths needed by exec-server child processes.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ExecServerRuntimePaths {
|
||||
/// Stable path to the Codex executable used to launch hidden helper modes.
|
||||
pub codex_self_exe: AbsolutePathBuf,
|
||||
/// Path to the Linux sandbox helper alias used when the platform sandbox
|
||||
/// needs to re-enter Codex by argv0.
|
||||
pub codex_linux_sandbox_exe: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
impl ExecServerRuntimePaths {
|
||||
pub fn from_optional_paths(
|
||||
codex_self_exe: Option<PathBuf>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
) -> std::io::Result<Self> {
|
||||
let codex_self_exe = codex_self_exe.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Codex executable path is not configured",
|
||||
)
|
||||
})?;
|
||||
Self::new(codex_self_exe, codex_linux_sandbox_exe)
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
codex_self_exe: PathBuf,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
) -> std::io::Result<Self> {
|
||||
Ok(Self {
|
||||
codex_self_exe: absolute_path(codex_self_exe)?,
|
||||
codex_linux_sandbox_exe: codex_linux_sandbox_exe.map(absolute_path).transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn absolute_path(path: PathBuf) -> std::io::Result<AbsolutePathBuf> {
|
||||
AbsolutePathBuf::from_absolute_path(path.as_path())
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))
|
||||
}
|
||||
239
codex-rs/exec-server/src/sandboxed_file_system.rs
Normal file
239
codex-rs/exec-server/src/sandboxed_file_system.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use tokio::io;
|
||||
|
||||
use crate::CopyOptions;
|
||||
use crate::CreateDirectoryOptions;
|
||||
use crate::ExecServerRuntimePaths;
|
||||
use crate::ExecutorFileSystem;
|
||||
use crate::FileMetadata;
|
||||
use crate::FileSystemResult;
|
||||
use crate::FileSystemSandboxContext;
|
||||
use crate::ReadDirectoryEntry;
|
||||
use crate::RemoveOptions;
|
||||
use crate::fs_helper::FsHelperPayload;
|
||||
use crate::fs_helper::FsHelperRequest;
|
||||
use crate::fs_sandbox::FileSystemSandboxRunner;
|
||||
use crate::protocol::FsCopyParams;
|
||||
use crate::protocol::FsCreateDirectoryParams;
|
||||
use crate::protocol::FsGetMetadataParams;
|
||||
use crate::protocol::FsReadDirectoryParams;
|
||||
use crate::protocol::FsReadFileParams;
|
||||
use crate::protocol::FsRemoveParams;
|
||||
use crate::protocol::FsWriteFileParams;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SandboxedFileSystem {
|
||||
sandbox_runner: FileSystemSandboxRunner,
|
||||
}
|
||||
|
||||
impl SandboxedFileSystem {
|
||||
pub fn new(runtime_paths: ExecServerRuntimePaths) -> Self {
|
||||
Self {
|
||||
sandbox_runner: FileSystemSandboxRunner::new(runtime_paths),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_sandboxed(
|
||||
&self,
|
||||
sandbox: &FileSystemSandboxContext,
|
||||
request: FsHelperRequest,
|
||||
) -> FileSystemResult<FsHelperPayload> {
|
||||
self.sandbox_runner
|
||||
.run(sandbox, request)
|
||||
.await
|
||||
.map_err(map_sandbox_error)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExecutorFileSystem for SandboxedFileSystem {
|
||||
async fn read_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
let sandbox = require_platform_sandbox(sandbox)?;
|
||||
let response = self
|
||||
.run_sandboxed(
|
||||
sandbox,
|
||||
FsHelperRequest::ReadFile(FsReadFileParams {
|
||||
path: path.clone(),
|
||||
sandbox: None,
|
||||
}),
|
||||
)
|
||||
.await?
|
||||
.expect_read_file()
|
||||
.map_err(map_sandbox_error)?;
|
||||
STANDARD.decode(response.data_base64).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("fs/readFile returned invalid base64 dataBase64: {err}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn write_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
contents: Vec<u8>,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
let sandbox = require_platform_sandbox(sandbox)?;
|
||||
self.run_sandboxed(
|
||||
sandbox,
|
||||
FsHelperRequest::WriteFile(FsWriteFileParams {
|
||||
path: path.clone(),
|
||||
data_base64: STANDARD.encode(contents),
|
||||
sandbox: None,
|
||||
}),
|
||||
)
|
||||
.await?
|
||||
.expect_write_file()
|
||||
.map_err(map_sandbox_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_directory(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
options: CreateDirectoryOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
let sandbox = require_platform_sandbox(sandbox)?;
|
||||
self.run_sandboxed(
|
||||
sandbox,
|
||||
FsHelperRequest::CreateDirectory(FsCreateDirectoryParams {
|
||||
path: path.clone(),
|
||||
recursive: Some(options.recursive),
|
||||
sandbox: None,
|
||||
}),
|
||||
)
|
||||
.await?
|
||||
.expect_create_directory()
|
||||
.map_err(map_sandbox_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_metadata(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<FileMetadata> {
|
||||
let sandbox = require_platform_sandbox(sandbox)?;
|
||||
let response = self
|
||||
.run_sandboxed(
|
||||
sandbox,
|
||||
FsHelperRequest::GetMetadata(FsGetMetadataParams {
|
||||
path: path.clone(),
|
||||
sandbox: None,
|
||||
}),
|
||||
)
|
||||
.await?
|
||||
.expect_get_metadata()
|
||||
.map_err(map_sandbox_error)?;
|
||||
Ok(FileMetadata {
|
||||
is_directory: response.is_directory,
|
||||
is_file: response.is_file,
|
||||
created_at_ms: response.created_at_ms,
|
||||
modified_at_ms: response.modified_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_directory(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
|
||||
let sandbox = require_platform_sandbox(sandbox)?;
|
||||
let response = self
|
||||
.run_sandboxed(
|
||||
sandbox,
|
||||
FsHelperRequest::ReadDirectory(FsReadDirectoryParams {
|
||||
path: path.clone(),
|
||||
sandbox: None,
|
||||
}),
|
||||
)
|
||||
.await?
|
||||
.expect_read_directory()
|
||||
.map_err(map_sandbox_error)?;
|
||||
Ok(response
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| ReadDirectoryEntry {
|
||||
file_name: entry.file_name,
|
||||
is_directory: entry.is_directory,
|
||||
is_file: entry.is_file,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn remove(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
remove_options: RemoveOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
let sandbox = require_platform_sandbox(sandbox)?;
|
||||
self.run_sandboxed(
|
||||
sandbox,
|
||||
FsHelperRequest::Remove(FsRemoveParams {
|
||||
path: path.clone(),
|
||||
recursive: Some(remove_options.recursive),
|
||||
force: Some(remove_options.force),
|
||||
sandbox: None,
|
||||
}),
|
||||
)
|
||||
.await?
|
||||
.expect_remove()
|
||||
.map_err(map_sandbox_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy(
|
||||
&self,
|
||||
source_path: &AbsolutePathBuf,
|
||||
destination_path: &AbsolutePathBuf,
|
||||
options: CopyOptions,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
let sandbox = require_platform_sandbox(sandbox)?;
|
||||
self.run_sandboxed(
|
||||
sandbox,
|
||||
FsHelperRequest::Copy(FsCopyParams {
|
||||
source_path: source_path.clone(),
|
||||
destination_path: destination_path.clone(),
|
||||
recursive: options.recursive,
|
||||
sandbox: None,
|
||||
}),
|
||||
)
|
||||
.await?
|
||||
.expect_copy()
|
||||
.map_err(map_sandbox_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn require_platform_sandbox(
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<&FileSystemSandboxContext> {
|
||||
sandbox
|
||||
.filter(|sandbox| sandbox.should_run_in_sandbox())
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"sandboxed filesystem operations require ReadOnly or WorkspaceWrite sandbox policy",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn map_sandbox_error(error: JSONRPCErrorError) -> io::Error {
|
||||
match error.code {
|
||||
-32004 => io::Error::new(io::ErrorKind::NotFound, error.message),
|
||||
-32600 => io::Error::new(io::ErrorKind::InvalidInput, error.message),
|
||||
_ => io::Error::other(error.message),
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,11 @@ pub(crate) use handler::ExecServerHandler;
|
||||
pub use transport::DEFAULT_LISTEN_URL;
|
||||
pub use transport::ExecServerListenUrlParseError;
|
||||
|
||||
pub async fn run_main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
run_main_with_listen_url(DEFAULT_LISTEN_URL).await
|
||||
}
|
||||
use crate::ExecServerRuntimePaths;
|
||||
|
||||
pub async fn run_main_with_listen_url(
|
||||
pub async fn run_main(
|
||||
listen_url: &str,
|
||||
runtime_paths: ExecServerRuntimePaths,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
transport::run_transport(listen_url).await
|
||||
transport::run_transport(listen_url, runtime_paths).await
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ use codex_app_server_protocol::JSONRPCErrorError;
|
||||
|
||||
use crate::CopyOptions;
|
||||
use crate::CreateDirectoryOptions;
|
||||
use crate::ExecServerRuntimePaths;
|
||||
use crate::ExecutorFileSystem;
|
||||
use crate::RemoveOptions;
|
||||
use crate::local_file_system::LocalFileSystem;
|
||||
use crate::protocol::FS_WRITE_FILE_METHOD;
|
||||
use crate::protocol::FsCopyParams;
|
||||
use crate::protocol::FsCopyResponse;
|
||||
use crate::protocol::FsCreateDirectoryParams;
|
||||
@@ -28,19 +30,25 @@ use crate::rpc::internal_error;
|
||||
use crate::rpc::invalid_request;
|
||||
use crate::rpc::not_found;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FileSystemHandler {
|
||||
file_system: LocalFileSystem,
|
||||
}
|
||||
|
||||
impl FileSystemHandler {
|
||||
pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Self {
|
||||
Self {
|
||||
file_system: LocalFileSystem::with_runtime_paths(runtime_paths),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_file(
|
||||
&self,
|
||||
params: FsReadFileParams,
|
||||
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
|
||||
let bytes = self
|
||||
.file_system
|
||||
.read_file_with_sandbox_policy(¶ms.path, params.sandbox_policy.as_ref())
|
||||
.read_file(¶ms.path, params.sandbox.as_ref())
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsReadFileResponse {
|
||||
@@ -54,11 +62,11 @@ impl FileSystemHandler {
|
||||
) -> Result<FsWriteFileResponse, JSONRPCErrorError> {
|
||||
let bytes = STANDARD.decode(params.data_base64).map_err(|err| {
|
||||
invalid_request(format!(
|
||||
"fs/writeFile requires valid base64 dataBase64: {err}"
|
||||
"{FS_WRITE_FILE_METHOD} requires valid base64 dataBase64: {err}"
|
||||
))
|
||||
})?;
|
||||
self.file_system
|
||||
.write_file_with_sandbox_policy(¶ms.path, bytes, params.sandbox_policy.as_ref())
|
||||
.write_file(¶ms.path, bytes, params.sandbox.as_ref())
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsWriteFileResponse {})
|
||||
@@ -68,13 +76,12 @@ impl FileSystemHandler {
|
||||
&self,
|
||||
params: FsCreateDirectoryParams,
|
||||
) -> Result<FsCreateDirectoryResponse, JSONRPCErrorError> {
|
||||
let recursive = params.recursive.unwrap_or(true);
|
||||
self.file_system
|
||||
.create_directory_with_sandbox_policy(
|
||||
.create_directory(
|
||||
¶ms.path,
|
||||
CreateDirectoryOptions {
|
||||
recursive: params.recursive.unwrap_or(true),
|
||||
},
|
||||
params.sandbox_policy.as_ref(),
|
||||
CreateDirectoryOptions { recursive },
|
||||
params.sandbox.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
@@ -87,7 +94,7 @@ impl FileSystemHandler {
|
||||
) -> Result<FsGetMetadataResponse, JSONRPCErrorError> {
|
||||
let metadata = self
|
||||
.file_system
|
||||
.get_metadata_with_sandbox_policy(¶ms.path, params.sandbox_policy.as_ref())
|
||||
.get_metadata(¶ms.path, params.sandbox.as_ref())
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsGetMetadataResponse {
|
||||
@@ -104,33 +111,30 @@ impl FileSystemHandler {
|
||||
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
|
||||
let entries = self
|
||||
.file_system
|
||||
.read_directory_with_sandbox_policy(¶ms.path, params.sandbox_policy.as_ref())
|
||||
.read_directory(¶ms.path, params.sandbox.as_ref())
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
Ok(FsReadDirectoryResponse {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(|entry| FsReadDirectoryEntry {
|
||||
file_name: entry.file_name,
|
||||
is_directory: entry.is_directory,
|
||||
is_file: entry.is_file,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.map_err(map_fs_error)?
|
||||
.into_iter()
|
||||
.map(|entry| FsReadDirectoryEntry {
|
||||
file_name: entry.file_name,
|
||||
is_directory: entry.is_directory,
|
||||
is_file: entry.is_file,
|
||||
})
|
||||
.collect();
|
||||
Ok(FsReadDirectoryResponse { entries })
|
||||
}
|
||||
|
||||
pub(crate) async fn remove(
|
||||
&self,
|
||||
params: FsRemoveParams,
|
||||
) -> Result<FsRemoveResponse, JSONRPCErrorError> {
|
||||
let recursive = params.recursive.unwrap_or(true);
|
||||
let force = params.force.unwrap_or(true);
|
||||
self.file_system
|
||||
.remove_with_sandbox_policy(
|
||||
.remove(
|
||||
¶ms.path,
|
||||
RemoveOptions {
|
||||
recursive: params.recursive.unwrap_or(true),
|
||||
force: params.force.unwrap_or(true),
|
||||
},
|
||||
params.sandbox_policy.as_ref(),
|
||||
RemoveOptions { recursive, force },
|
||||
params.sandbox.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
@@ -142,13 +146,13 @@ impl FileSystemHandler {
|
||||
params: FsCopyParams,
|
||||
) -> Result<FsCopyResponse, JSONRPCErrorError> {
|
||||
self.file_system
|
||||
.copy_with_sandbox_policy(
|
||||
.copy(
|
||||
¶ms.source_path,
|
||||
¶ms.destination_path,
|
||||
CopyOptions {
|
||||
recursive: params.recursive,
|
||||
},
|
||||
params.sandbox_policy.as_ref(),
|
||||
params.sandbox.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
@@ -157,11 +161,68 @@ impl FileSystemHandler {
|
||||
}
|
||||
|
||||
fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
|
||||
if err.kind() == io::ErrorKind::NotFound {
|
||||
not_found(err.to_string())
|
||||
} else if err.kind() == io::ErrorKind::InvalidInput {
|
||||
invalid_request(err.to_string())
|
||||
} else {
|
||||
internal_error(err.to_string())
|
||||
match err.kind() {
|
||||
io::ErrorKind::NotFound => not_found(err.to_string()),
|
||||
io::ErrorKind::InvalidInput | io::ErrorKind::PermissionDenied => {
|
||||
invalid_request(err.to_string())
|
||||
}
|
||||
_ => internal_error(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::protocol::NetworkAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
use crate::FileSystemSandboxContext;
|
||||
use crate::protocol::FsReadFileParams;
|
||||
use crate::protocol::FsWriteFileParams;
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_platform_sandbox_policies_do_not_require_configured_sandbox_helper() {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
let runtime_paths = ExecServerRuntimePaths::new(
|
||||
std::env::current_exe().expect("current exe"),
|
||||
/*codex_linux_sandbox_exe*/ None,
|
||||
)
|
||||
.expect("runtime paths");
|
||||
let handler = FileSystemHandler::new(runtime_paths);
|
||||
|
||||
for (file_name, sandbox_policy) in [
|
||||
("danger.txt", SandboxPolicy::DangerFullAccess),
|
||||
(
|
||||
"external.txt",
|
||||
SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Restricted,
|
||||
},
|
||||
),
|
||||
] {
|
||||
let path =
|
||||
AbsolutePathBuf::from_absolute_path(temp_dir.path().join(file_name).as_path())
|
||||
.expect("absolute path");
|
||||
|
||||
handler
|
||||
.write_file(FsWriteFileParams {
|
||||
path: path.clone(),
|
||||
data_base64: STANDARD.encode("ok"),
|
||||
sandbox: Some(FileSystemSandboxContext::new(sandbox_policy.clone())),
|
||||
})
|
||||
.await
|
||||
.expect("write file");
|
||||
|
||||
let response = handler
|
||||
.read_file(FsReadFileParams {
|
||||
path,
|
||||
sandbox: Some(FileSystemSandboxContext::new(sandbox_policy)),
|
||||
})
|
||||
.await
|
||||
.expect("read file");
|
||||
|
||||
assert_eq!(response.data_base64, STANDARD.encode("ok"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
|
||||
use crate::ExecServerRuntimePaths;
|
||||
use crate::protocol::ExecParams;
|
||||
use crate::protocol::ExecResponse;
|
||||
use crate::protocol::FsCopyParams;
|
||||
@@ -48,12 +49,13 @@ impl ExecServerHandler {
|
||||
pub(crate) fn new(
|
||||
session_registry: Arc<SessionRegistry>,
|
||||
notifications: RpcNotificationSender,
|
||||
runtime_paths: ExecServerRuntimePaths,
|
||||
) -> Self {
|
||||
Self {
|
||||
session_registry,
|
||||
notifications,
|
||||
session: StdMutex::new(None),
|
||||
file_system: FileSystemHandler::default(),
|
||||
file_system: FileSystemHandler::new(runtime_paths),
|
||||
initialize_requested: AtomicBool::new(false),
|
||||
initialized: AtomicBool::new(false),
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::ExecServerHandler;
|
||||
use crate::ExecServerRuntimePaths;
|
||||
use crate::ProcessId;
|
||||
use crate::protocol::ExecParams;
|
||||
use crate::protocol::InitializeParams;
|
||||
@@ -64,12 +65,21 @@ fn windows_command_processor() -> String {
|
||||
std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
|
||||
}
|
||||
|
||||
fn test_runtime_paths() -> ExecServerRuntimePaths {
|
||||
ExecServerRuntimePaths::new(
|
||||
std::env::current_exe().expect("current exe"),
|
||||
/*codex_linux_sandbox_exe*/ None,
|
||||
)
|
||||
.expect("runtime paths")
|
||||
}
|
||||
|
||||
async fn initialized_handler() -> Arc<ExecServerHandler> {
|
||||
let (outgoing_tx, _outgoing_rx) = mpsc::channel(16);
|
||||
let registry = SessionRegistry::new();
|
||||
let handler = Arc::new(ExecServerHandler::new(
|
||||
registry,
|
||||
RpcNotificationSender::new(outgoing_tx),
|
||||
test_runtime_paths(),
|
||||
));
|
||||
let initialize_response = handler
|
||||
.initialize(InitializeParams {
|
||||
@@ -147,6 +157,7 @@ async fn long_poll_read_fails_after_session_resume() {
|
||||
let first_handler = Arc::new(ExecServerHandler::new(
|
||||
Arc::clone(®istry),
|
||||
RpcNotificationSender::new(first_tx),
|
||||
test_runtime_paths(),
|
||||
));
|
||||
let initialize_response = first_handler
|
||||
.initialize(InitializeParams {
|
||||
@@ -187,6 +198,7 @@ async fn long_poll_read_fails_after_session_resume() {
|
||||
let second_handler = Arc::new(ExecServerHandler::new(
|
||||
registry,
|
||||
RpcNotificationSender::new(second_tx),
|
||||
test_runtime_paths(),
|
||||
));
|
||||
second_handler
|
||||
.initialize(InitializeParams {
|
||||
@@ -219,6 +231,7 @@ async fn active_session_resume_is_rejected() {
|
||||
let first_handler = Arc::new(ExecServerHandler::new(
|
||||
Arc::clone(®istry),
|
||||
RpcNotificationSender::new(first_tx),
|
||||
test_runtime_paths(),
|
||||
));
|
||||
let initialize_response = first_handler
|
||||
.initialize(InitializeParams {
|
||||
@@ -232,6 +245,7 @@ async fn active_session_resume_is_rejected() {
|
||||
let second_handler = Arc::new(ExecServerHandler::new(
|
||||
registry,
|
||||
RpcNotificationSender::new(second_tx),
|
||||
test_runtime_paths(),
|
||||
));
|
||||
let err = second_handler
|
||||
.initialize(InitializeParams {
|
||||
@@ -259,6 +273,7 @@ async fn output_and_exit_are_retained_after_notification_receiver_closes() {
|
||||
let handler = Arc::new(ExecServerHandler::new(
|
||||
SessionRegistry::new(),
|
||||
RpcNotificationSender::new(outgoing_tx),
|
||||
test_runtime_paths(),
|
||||
));
|
||||
handler
|
||||
.initialize(InitializeParams {
|
||||
|
||||
@@ -4,6 +4,7 @@ use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ExecServerRuntimePaths;
|
||||
use crate::connection::CHANNEL_CAPACITY;
|
||||
use crate::connection::JsonRpcConnection;
|
||||
use crate::connection::JsonRpcConnectionEvent;
|
||||
@@ -19,28 +20,43 @@ use crate::server::session_registry::SessionRegistry;
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ConnectionProcessor {
|
||||
session_registry: Arc<SessionRegistry>,
|
||||
runtime_paths: ExecServerRuntimePaths,
|
||||
}
|
||||
|
||||
impl ConnectionProcessor {
|
||||
pub(crate) fn new() -> Self {
|
||||
pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Self {
|
||||
Self {
|
||||
session_registry: SessionRegistry::new(),
|
||||
runtime_paths,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_connection(&self, connection: JsonRpcConnection) {
|
||||
run_connection(connection, Arc::clone(&self.session_registry)).await;
|
||||
run_connection(
|
||||
connection,
|
||||
Arc::clone(&self.session_registry),
|
||||
self.runtime_paths.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_connection(connection: JsonRpcConnection, session_registry: Arc<SessionRegistry>) {
|
||||
async fn run_connection(
|
||||
connection: JsonRpcConnection,
|
||||
session_registry: Arc<SessionRegistry>,
|
||||
runtime_paths: ExecServerRuntimePaths,
|
||||
) {
|
||||
let router = Arc::new(build_router());
|
||||
let (json_outgoing_tx, mut incoming_rx, mut disconnected_rx, connection_tasks) =
|
||||
connection.into_parts();
|
||||
let (outgoing_tx, mut outgoing_rx) =
|
||||
mpsc::channel::<RpcServerOutboundMessage>(CHANNEL_CAPACITY);
|
||||
let notifications = RpcNotificationSender::new(outgoing_tx.clone());
|
||||
let handler = Arc::new(ExecServerHandler::new(session_registry, notifications));
|
||||
let handler = Arc::new(ExecServerHandler::new(
|
||||
session_registry,
|
||||
notifications,
|
||||
runtime_paths,
|
||||
));
|
||||
|
||||
let outbound_task = tokio::spawn(async move {
|
||||
while let Some(message) = outgoing_rx.recv().await {
|
||||
@@ -184,6 +200,7 @@ mod tests {
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::run_connection;
|
||||
use crate::ExecServerRuntimePaths;
|
||||
use crate::ProcessId;
|
||||
use crate::connection::JsonRpcConnection;
|
||||
use crate::protocol::EXEC_METHOD;
|
||||
@@ -298,10 +315,18 @@ mod tests {
|
||||
let (server_writer, client_reader) = duplex(1 << 20);
|
||||
let connection =
|
||||
JsonRpcConnection::from_stdio(server_reader, server_writer, label.to_string());
|
||||
let task = tokio::spawn(run_connection(connection, registry));
|
||||
let task = tokio::spawn(run_connection(connection, registry, test_runtime_paths()));
|
||||
(client_writer, BufReader::new(client_reader).lines(), task)
|
||||
}
|
||||
|
||||
fn test_runtime_paths() -> ExecServerRuntimePaths {
|
||||
ExecServerRuntimePaths::new(
|
||||
std::env::current_exe().expect("current exe"),
|
||||
/*codex_linux_sandbox_exe*/ None,
|
||||
)
|
||||
.expect("runtime paths")
|
||||
}
|
||||
|
||||
async fn send_request<P: Serialize>(
|
||||
writer: &mut DuplexStream,
|
||||
id: i64,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use std::io::Write as _;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::accept_async;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ExecServerRuntimePaths;
|
||||
use crate::connection::JsonRpcConnection;
|
||||
use crate::server::processor::ConnectionProcessor;
|
||||
|
||||
@@ -48,19 +49,22 @@ pub(crate) fn parse_listen_url(
|
||||
|
||||
pub(crate) async fn run_transport(
|
||||
listen_url: &str,
|
||||
runtime_paths: ExecServerRuntimePaths,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let bind_address = parse_listen_url(listen_url)?;
|
||||
run_websocket_listener(bind_address).await
|
||||
run_websocket_listener(bind_address, runtime_paths).await
|
||||
}
|
||||
|
||||
async fn run_websocket_listener(
|
||||
bind_address: SocketAddr,
|
||||
runtime_paths: ExecServerRuntimePaths,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let listener = TcpListener::bind(bind_address).await?;
|
||||
let local_addr = listener.local_addr()?;
|
||||
let processor = ConnectionProcessor::new();
|
||||
let processor = ConnectionProcessor::new(runtime_paths);
|
||||
tracing::info!("codex-exec-server listening on ws://{local_addr}");
|
||||
println!("ws://{local_addr}");
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
loop {
|
||||
let (stream, peer_addr) = listener.accept().await?;
|
||||
|
||||
@@ -41,9 +41,9 @@ impl Drop for ExecServerHarness {
|
||||
}
|
||||
|
||||
pub(crate) async fn exec_server() -> anyhow::Result<ExecServerHarness> {
|
||||
let binary = cargo_bin("codex-exec-server")?;
|
||||
let binary = cargo_bin("codex")?;
|
||||
let mut child = Command::new(binary);
|
||||
child.args(["--listen", "ws://127.0.0.1:0"]);
|
||||
child.args(["exec-server", "--listen", "ws://127.0.0.1:0"]);
|
||||
child.stdin(Stdio::null());
|
||||
child.stdout(Stdio::piped());
|
||||
child.stderr(Stdio::inherit());
|
||||
|
||||
@@ -11,7 +11,10 @@ use anyhow::Result;
|
||||
use codex_exec_server::CopyOptions;
|
||||
use codex_exec_server::CreateDirectoryOptions;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::ExecServerRuntimePaths;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_exec_server::LocalFileSystem;
|
||||
use codex_exec_server::ReadDirectoryEntry;
|
||||
use codex_exec_server::RemoveOptions;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
@@ -38,9 +41,15 @@ async fn create_file_system_context(use_remote: bool) -> Result<FileSystemContex
|
||||
_server: Some(server),
|
||||
})
|
||||
} else {
|
||||
let environment = Environment::create(/*exec_server_url*/ None).await?;
|
||||
let codex = codex_utils_cargo_bin::cargo_bin("codex")?;
|
||||
#[cfg(target_os = "linux")]
|
||||
let codex_linux_sandbox_exe =
|
||||
Some(codex_utils_cargo_bin::cargo_bin("codex-linux-sandbox")?);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let codex_linux_sandbox_exe = None;
|
||||
let runtime_paths = ExecServerRuntimePaths::new(codex, codex_linux_sandbox_exe)?;
|
||||
Ok(FileSystemContext {
|
||||
file_system: environment.get_filesystem(),
|
||||
file_system: Arc::new(LocalFileSystem::with_runtime_paths(runtime_paths)),
|
||||
_server: None,
|
||||
})
|
||||
}
|
||||
@@ -58,18 +67,18 @@ fn absolute_path(path: std::path::PathBuf) -> AbsolutePathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_only_sandbox_policy(readable_root: std::path::PathBuf) -> SandboxPolicy {
|
||||
SandboxPolicy::ReadOnly {
|
||||
fn read_only_sandbox(readable_root: std::path::PathBuf) -> FileSystemSandboxContext {
|
||||
FileSystemSandboxContext::new(SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: false,
|
||||
readable_roots: vec![absolute_path(readable_root)],
|
||||
},
|
||||
network_access: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace_write_sandbox_policy(writable_root: std::path::PathBuf) -> SandboxPolicy {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
fn workspace_write_sandbox(writable_root: std::path::PathBuf) -> FileSystemSandboxContext {
|
||||
FileSystemSandboxContext::new(SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![absolute_path(writable_root)],
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: false,
|
||||
@@ -78,6 +87,42 @@ fn workspace_write_sandbox_policy(writable_root: std::path::PathBuf) -> SandboxP
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_sandbox_denied(error: &std::io::Error) {
|
||||
assert!(
|
||||
matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::PermissionDenied
|
||||
),
|
||||
"unexpected sandbox error kind: {error:?}",
|
||||
);
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains("is not permitted")
|
||||
|| message.contains("Operation not permitted")
|
||||
|| message.contains("Permission denied"),
|
||||
"unexpected sandbox error message: {message}",
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_normalized_path_rejected(error: &std::io::Error) {
|
||||
match error.kind() {
|
||||
std::io::ErrorKind::NotFound => assert!(
|
||||
error.to_string().contains("No such file or directory"),
|
||||
"unexpected not-found message: {error}",
|
||||
),
|
||||
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::PermissionDenied => {
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains("is not permitted")
|
||||
|| message.contains("Operation not permitted")
|
||||
|| message.contains("Permission denied"),
|
||||
"unexpected rejection message: {message}",
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected normalized-path error kind: {other:?}: {error:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +138,7 @@ async fn file_system_get_metadata_returns_expected_fields(use_remote: bool) -> R
|
||||
std::fs::write(&file_path, "hello")?;
|
||||
|
||||
let metadata = file_system
|
||||
.get_metadata(&absolute_path(file_path))
|
||||
.get_metadata(&absolute_path(file_path), /*sandbox*/ None)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
assert_eq!(metadata.is_directory, false);
|
||||
@@ -122,6 +167,7 @@ async fn file_system_methods_cover_surface_area(use_remote: bool) -> Result<()>
|
||||
.create_directory(
|
||||
&absolute_path(nested_dir.clone()),
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
@@ -130,6 +176,7 @@ async fn file_system_methods_cover_surface_area(use_remote: bool) -> Result<()>
|
||||
.write_file(
|
||||
&absolute_path(nested_file.clone()),
|
||||
b"hello from trait".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
@@ -137,18 +184,19 @@ async fn file_system_methods_cover_surface_area(use_remote: bool) -> Result<()>
|
||||
.write_file(
|
||||
&absolute_path(source_file.clone()),
|
||||
b"hello from source root".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
|
||||
let nested_file_contents = file_system
|
||||
.read_file(&absolute_path(nested_file.clone()))
|
||||
.read_file(&absolute_path(nested_file.clone()), /*sandbox*/ None)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
assert_eq!(nested_file_contents, b"hello from trait");
|
||||
|
||||
let nested_file_text = file_system
|
||||
.read_file_text(&absolute_path(nested_file.clone()))
|
||||
.read_file_text(&absolute_path(nested_file.clone()), /*sandbox*/ None)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
assert_eq!(nested_file_text, "hello from trait");
|
||||
@@ -158,6 +206,7 @@ async fn file_system_methods_cover_surface_area(use_remote: bool) -> Result<()>
|
||||
&absolute_path(nested_file),
|
||||
&absolute_path(copied_file.clone()),
|
||||
CopyOptions { recursive: false },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
@@ -168,6 +217,7 @@ async fn file_system_methods_cover_surface_area(use_remote: bool) -> Result<()>
|
||||
&absolute_path(source_dir.clone()),
|
||||
&absolute_path(copied_dir.clone()),
|
||||
CopyOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
@@ -177,7 +227,7 @@ async fn file_system_methods_cover_surface_area(use_remote: bool) -> Result<()>
|
||||
);
|
||||
|
||||
let mut entries = file_system
|
||||
.read_directory(&absolute_path(source_dir))
|
||||
.read_directory(&absolute_path(source_dir), /*sandbox*/ None)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
entries.sort_by(|left, right| left.file_name.cmp(&right.file_name));
|
||||
@@ -204,6 +254,7 @@ async fn file_system_methods_cover_surface_area(use_remote: bool) -> Result<()>
|
||||
recursive: true,
|
||||
force: true,
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
@@ -228,6 +279,7 @@ async fn file_system_copy_rejects_directory_without_recursive(use_remote: bool)
|
||||
&absolute_path(source_dir),
|
||||
&absolute_path(tmp.path().join("dest")),
|
||||
CopyOptions { recursive: false },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await;
|
||||
let error = match error {
|
||||
@@ -246,7 +298,7 @@ async fn file_system_copy_rejects_directory_without_recursive(use_remote: bool)
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn file_system_read_with_sandbox_policy_allows_readable_root(use_remote: bool) -> Result<()> {
|
||||
async fn file_system_sandboxed_read_allows_readable_root(use_remote: bool) -> Result<()> {
|
||||
let context = create_file_system_context(use_remote).await?;
|
||||
let file_system = context.file_system;
|
||||
|
||||
@@ -255,10 +307,10 @@ async fn file_system_read_with_sandbox_policy_allows_readable_root(use_remote: b
|
||||
let file_path = allowed_dir.join("note.txt");
|
||||
std::fs::create_dir_all(&allowed_dir)?;
|
||||
std::fs::write(&file_path, "sandboxed hello")?;
|
||||
let sandbox_policy = read_only_sandbox_policy(allowed_dir);
|
||||
let sandbox = read_only_sandbox(allowed_dir);
|
||||
|
||||
let contents = file_system
|
||||
.read_file_with_sandbox_policy(&absolute_path(file_path), Some(&sandbox_policy))
|
||||
.read_file(&absolute_path(file_path), Some(&sandbox))
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
assert_eq!(contents, b"sandboxed hello");
|
||||
@@ -269,9 +321,7 @@ async fn file_system_read_with_sandbox_policy_allows_readable_root(use_remote: b
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn file_system_write_with_sandbox_policy_rejects_unwritable_path(
|
||||
use_remote: bool,
|
||||
) -> Result<()> {
|
||||
async fn file_system_sandboxed_write_rejects_unwritable_path(use_remote: bool) -> Result<()> {
|
||||
let context = create_file_system_context(use_remote).await?;
|
||||
let file_system = context.file_system;
|
||||
|
||||
@@ -280,26 +330,19 @@ async fn file_system_write_with_sandbox_policy_rejects_unwritable_path(
|
||||
let blocked_path = tmp.path().join("blocked.txt");
|
||||
std::fs::create_dir_all(&allowed_dir)?;
|
||||
|
||||
let sandbox_policy = read_only_sandbox_policy(allowed_dir);
|
||||
let sandbox = read_only_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.write_file_with_sandbox_policy(
|
||||
.write_file(
|
||||
&absolute_path(blocked_path.clone()),
|
||||
b"nope".to_vec(),
|
||||
Some(&sandbox_policy),
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => anyhow::bail!("write should be blocked"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"fs/write is not permitted for path {}",
|
||||
blocked_path.display()
|
||||
)
|
||||
);
|
||||
assert_sandbox_denied(&error);
|
||||
assert!(!blocked_path.exists());
|
||||
|
||||
Ok(())
|
||||
@@ -308,9 +351,7 @@ async fn file_system_write_with_sandbox_policy_rejects_unwritable_path(
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn file_system_read_with_sandbox_policy_rejects_symlink_escape(
|
||||
use_remote: bool,
|
||||
) -> Result<()> {
|
||||
async fn file_system_sandboxed_read_rejects_symlink_escape(use_remote: bool) -> Result<()> {
|
||||
let context = create_file_system_context(use_remote).await?;
|
||||
let file_system = context.file_system;
|
||||
|
||||
@@ -323,25 +364,15 @@ async fn file_system_read_with_sandbox_policy_rejects_symlink_escape(
|
||||
symlink(&outside_dir, allowed_dir.join("link"))?;
|
||||
|
||||
let requested_path = allowed_dir.join("link").join("secret.txt");
|
||||
let sandbox_policy = read_only_sandbox_policy(allowed_dir);
|
||||
let sandbox = read_only_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.read_file_with_sandbox_policy(
|
||||
&absolute_path(requested_path.clone()),
|
||||
Some(&sandbox_policy),
|
||||
)
|
||||
.read_file(&absolute_path(requested_path.clone()), Some(&sandbox))
|
||||
.await
|
||||
{
|
||||
Ok(_) => anyhow::bail!("read should be blocked"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"fs/read is not permitted for path {}",
|
||||
requested_path.display()
|
||||
)
|
||||
);
|
||||
assert_sandbox_denied(&error);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -349,7 +380,7 @@ async fn file_system_read_with_sandbox_policy_rejects_symlink_escape(
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn file_system_read_with_sandbox_policy_rejects_symlink_parent_dotdot_escape(
|
||||
async fn file_system_sandboxed_read_rejects_symlink_parent_dotdot_escape(
|
||||
use_remote: bool,
|
||||
) -> Result<()> {
|
||||
let context = create_file_system_context(use_remote).await?;
|
||||
@@ -365,15 +396,17 @@ async fn file_system_read_with_sandbox_policy_rejects_symlink_parent_dotdot_esca
|
||||
symlink(&outside_dir, allowed_dir.join("link"))?;
|
||||
|
||||
let requested_path = absolute_path(allowed_dir.join("link").join("..").join("secret.txt"));
|
||||
let sandbox_policy = read_only_sandbox_policy(allowed_dir);
|
||||
let error = match file_system
|
||||
.read_file_with_sandbox_policy(&requested_path, Some(&sandbox_policy))
|
||||
.await
|
||||
{
|
||||
let sandbox = read_only_sandbox(allowed_dir);
|
||||
let error = match file_system.read_file(&requested_path, Some(&sandbox)).await {
|
||||
Ok(_) => anyhow::bail!("read should fail after path normalization"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
|
||||
// AbsolutePathBuf normalizes `link/../secret.txt` to `allowed/secret.txt`
|
||||
// before the request reaches the filesystem layer. Depending on whether
|
||||
// the platform/runtime resolves that normalized path through a top-level
|
||||
// symlink alias, the request can surface as either "missing file" or an
|
||||
// upfront sandbox rejection.
|
||||
assert_normalized_path_rejected(&error);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -381,9 +414,7 @@ async fn file_system_read_with_sandbox_policy_rejects_symlink_parent_dotdot_esca
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn file_system_write_with_sandbox_policy_rejects_symlink_escape(
|
||||
use_remote: bool,
|
||||
) -> Result<()> {
|
||||
async fn file_system_sandboxed_write_rejects_symlink_escape(use_remote: bool) -> Result<()> {
|
||||
let context = create_file_system_context(use_remote).await?;
|
||||
let file_system = context.file_system;
|
||||
|
||||
@@ -395,26 +426,19 @@ async fn file_system_write_with_sandbox_policy_rejects_symlink_escape(
|
||||
symlink(&outside_dir, allowed_dir.join("link"))?;
|
||||
|
||||
let requested_path = allowed_dir.join("link").join("blocked.txt");
|
||||
let sandbox_policy = workspace_write_sandbox_policy(allowed_dir);
|
||||
let sandbox = workspace_write_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.write_file_with_sandbox_policy(
|
||||
.write_file(
|
||||
&absolute_path(requested_path.clone()),
|
||||
b"nope".to_vec(),
|
||||
Some(&sandbox_policy),
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => anyhow::bail!("write should be blocked"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"fs/write is not permitted for path {}",
|
||||
requested_path.display()
|
||||
)
|
||||
);
|
||||
assert_sandbox_denied(&error);
|
||||
assert!(!outside_dir.join("blocked.txt").exists());
|
||||
|
||||
Ok(())
|
||||
@@ -423,9 +447,7 @@ async fn file_system_write_with_sandbox_policy_rejects_symlink_escape(
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn file_system_create_directory_with_sandbox_policy_rejects_symlink_escape(
|
||||
use_remote: bool,
|
||||
) -> Result<()> {
|
||||
async fn file_system_create_directory_rejects_symlink_escape(use_remote: bool) -> Result<()> {
|
||||
let context = create_file_system_context(use_remote).await?;
|
||||
let file_system = context.file_system;
|
||||
|
||||
@@ -437,26 +459,19 @@ async fn file_system_create_directory_with_sandbox_policy_rejects_symlink_escape
|
||||
symlink(&outside_dir, allowed_dir.join("link"))?;
|
||||
|
||||
let requested_path = allowed_dir.join("link").join("created");
|
||||
let sandbox_policy = workspace_write_sandbox_policy(allowed_dir);
|
||||
let sandbox = workspace_write_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.create_directory_with_sandbox_policy(
|
||||
.create_directory(
|
||||
&absolute_path(requested_path.clone()),
|
||||
CreateDirectoryOptions { recursive: false },
|
||||
Some(&sandbox_policy),
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => anyhow::bail!("create_directory should be blocked"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"fs/write is not permitted for path {}",
|
||||
requested_path.display()
|
||||
)
|
||||
);
|
||||
assert_sandbox_denied(&error);
|
||||
assert!(!outside_dir.join("created").exists());
|
||||
|
||||
Ok(())
|
||||
@@ -465,9 +480,7 @@ async fn file_system_create_directory_with_sandbox_policy_rejects_symlink_escape
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn file_system_get_metadata_with_sandbox_policy_rejects_symlink_escape(
|
||||
use_remote: bool,
|
||||
) -> Result<()> {
|
||||
async fn file_system_get_metadata_rejects_symlink_escape(use_remote: bool) -> Result<()> {
|
||||
let context = create_file_system_context(use_remote).await?;
|
||||
let file_system = context.file_system;
|
||||
|
||||
@@ -480,25 +493,15 @@ async fn file_system_get_metadata_with_sandbox_policy_rejects_symlink_escape(
|
||||
symlink(&outside_dir, allowed_dir.join("link"))?;
|
||||
|
||||
let requested_path = allowed_dir.join("link").join("secret.txt");
|
||||
let sandbox_policy = read_only_sandbox_policy(allowed_dir);
|
||||
let sandbox = read_only_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.get_metadata_with_sandbox_policy(
|
||||
&absolute_path(requested_path.clone()),
|
||||
Some(&sandbox_policy),
|
||||
)
|
||||
.get_metadata(&absolute_path(requested_path.clone()), Some(&sandbox))
|
||||
.await
|
||||
{
|
||||
Ok(_) => anyhow::bail!("get_metadata should be blocked"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"fs/read is not permitted for path {}",
|
||||
requested_path.display()
|
||||
)
|
||||
);
|
||||
assert_sandbox_denied(&error);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -506,9 +509,7 @@ async fn file_system_get_metadata_with_sandbox_policy_rejects_symlink_escape(
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn file_system_read_directory_with_sandbox_policy_rejects_symlink_escape(
|
||||
use_remote: bool,
|
||||
) -> Result<()> {
|
||||
async fn file_system_read_directory_rejects_symlink_escape(use_remote: bool) -> Result<()> {
|
||||
let context = create_file_system_context(use_remote).await?;
|
||||
let file_system = context.file_system;
|
||||
|
||||
@@ -521,25 +522,15 @@ async fn file_system_read_directory_with_sandbox_policy_rejects_symlink_escape(
|
||||
symlink(&outside_dir, allowed_dir.join("link"))?;
|
||||
|
||||
let requested_path = allowed_dir.join("link");
|
||||
let sandbox_policy = read_only_sandbox_policy(allowed_dir);
|
||||
let sandbox = read_only_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.read_directory_with_sandbox_policy(
|
||||
&absolute_path(requested_path.clone()),
|
||||
Some(&sandbox_policy),
|
||||
)
|
||||
.read_directory(&absolute_path(requested_path.clone()), Some(&sandbox))
|
||||
.await
|
||||
{
|
||||
Ok(_) => anyhow::bail!("read_directory should be blocked"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"fs/read is not permitted for path {}",
|
||||
requested_path.display()
|
||||
)
|
||||
);
|
||||
assert_sandbox_denied(&error);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -547,9 +538,7 @@ async fn file_system_read_directory_with_sandbox_policy_rejects_symlink_escape(
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn file_system_copy_with_sandbox_policy_rejects_symlink_escape_destination(
|
||||
use_remote: bool,
|
||||
) -> Result<()> {
|
||||
async fn file_system_copy_rejects_symlink_escape_destination(use_remote: bool) -> Result<()> {
|
||||
let context = create_file_system_context(use_remote).await?;
|
||||
let file_system = context.file_system;
|
||||
|
||||
@@ -562,27 +551,20 @@ async fn file_system_copy_with_sandbox_policy_rejects_symlink_escape_destination
|
||||
symlink(&outside_dir, allowed_dir.join("link"))?;
|
||||
|
||||
let requested_destination = allowed_dir.join("link").join("copied.txt");
|
||||
let sandbox_policy = workspace_write_sandbox_policy(allowed_dir.clone());
|
||||
let sandbox = workspace_write_sandbox(allowed_dir.clone());
|
||||
let error = match file_system
|
||||
.copy_with_sandbox_policy(
|
||||
.copy(
|
||||
&absolute_path(allowed_dir.join("source.txt")),
|
||||
&absolute_path(requested_destination.clone()),
|
||||
CopyOptions { recursive: false },
|
||||
Some(&sandbox_policy),
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => anyhow::bail!("copy should be blocked"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"fs/write is not permitted for path {}",
|
||||
requested_destination.display()
|
||||
)
|
||||
);
|
||||
assert_sandbox_denied(&error);
|
||||
assert!(!outside_dir.join("copied.txt").exists());
|
||||
|
||||
Ok(())
|
||||
@@ -591,9 +573,7 @@ async fn file_system_copy_with_sandbox_policy_rejects_symlink_escape_destination
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn file_system_remove_with_sandbox_policy_removes_symlink_not_target(
|
||||
use_remote: bool,
|
||||
) -> Result<()> {
|
||||
async fn file_system_remove_removes_symlink_not_target(use_remote: bool) -> Result<()> {
|
||||
let context = create_file_system_context(use_remote).await?;
|
||||
let file_system = context.file_system;
|
||||
|
||||
@@ -607,15 +587,15 @@ async fn file_system_remove_with_sandbox_policy_removes_symlink_not_target(
|
||||
let symlink_path = allowed_dir.join("link");
|
||||
symlink(&outside_file, &symlink_path)?;
|
||||
|
||||
let sandbox_policy = workspace_write_sandbox_policy(allowed_dir);
|
||||
let sandbox = workspace_write_sandbox(allowed_dir);
|
||||
file_system
|
||||
.remove_with_sandbox_policy(
|
||||
.remove(
|
||||
&absolute_path(symlink_path.clone()),
|
||||
RemoveOptions {
|
||||
recursive: false,
|
||||
force: false,
|
||||
},
|
||||
Some(&sandbox_policy),
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
@@ -630,9 +610,7 @@ async fn file_system_remove_with_sandbox_policy_removes_symlink_not_target(
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn file_system_copy_with_sandbox_policy_preserves_symlink_source(
|
||||
use_remote: bool,
|
||||
) -> Result<()> {
|
||||
async fn file_system_copy_preserves_symlink_source(use_remote: bool) -> Result<()> {
|
||||
let context = create_file_system_context(use_remote).await?;
|
||||
let file_system = context.file_system;
|
||||
|
||||
@@ -647,13 +625,13 @@ async fn file_system_copy_with_sandbox_policy_preserves_symlink_source(
|
||||
std::fs::write(&outside_file, "outside")?;
|
||||
symlink(&outside_file, &source_symlink)?;
|
||||
|
||||
let sandbox_policy = workspace_write_sandbox_policy(allowed_dir.clone());
|
||||
let sandbox = workspace_write_sandbox(allowed_dir.clone());
|
||||
file_system
|
||||
.copy_with_sandbox_policy(
|
||||
.copy(
|
||||
&absolute_path(source_symlink),
|
||||
&absolute_path(copied_symlink.clone()),
|
||||
CopyOptions { recursive: false },
|
||||
Some(&sandbox_policy),
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
@@ -668,9 +646,7 @@ async fn file_system_copy_with_sandbox_policy_preserves_symlink_source(
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn file_system_remove_with_sandbox_policy_rejects_symlink_escape(
|
||||
use_remote: bool,
|
||||
) -> Result<()> {
|
||||
async fn file_system_remove_rejects_symlink_escape(use_remote: bool) -> Result<()> {
|
||||
let context = create_file_system_context(use_remote).await?;
|
||||
let file_system = context.file_system;
|
||||
|
||||
@@ -684,29 +660,22 @@ async fn file_system_remove_with_sandbox_policy_rejects_symlink_escape(
|
||||
symlink(&outside_dir, allowed_dir.join("link"))?;
|
||||
|
||||
let requested_path = allowed_dir.join("link").join("secret.txt");
|
||||
let sandbox_policy = workspace_write_sandbox_policy(allowed_dir);
|
||||
let sandbox = workspace_write_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.remove_with_sandbox_policy(
|
||||
.remove(
|
||||
&absolute_path(requested_path.clone()),
|
||||
RemoveOptions {
|
||||
recursive: false,
|
||||
force: false,
|
||||
},
|
||||
Some(&sandbox_policy),
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => anyhow::bail!("remove should be blocked"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"fs/write is not permitted for path {}",
|
||||
requested_path.display()
|
||||
)
|
||||
);
|
||||
assert_sandbox_denied(&error);
|
||||
assert_eq!(std::fs::read_to_string(outside_file)?, "outside");
|
||||
|
||||
Ok(())
|
||||
@@ -715,9 +684,7 @@ async fn file_system_remove_with_sandbox_policy_rejects_symlink_escape(
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn file_system_copy_with_sandbox_policy_rejects_symlink_escape_source(
|
||||
use_remote: bool,
|
||||
) -> Result<()> {
|
||||
async fn file_system_copy_rejects_symlink_escape_source(use_remote: bool) -> Result<()> {
|
||||
let context = create_file_system_context(use_remote).await?;
|
||||
let file_system = context.file_system;
|
||||
|
||||
@@ -732,27 +699,20 @@ async fn file_system_copy_with_sandbox_policy_rejects_symlink_escape_source(
|
||||
symlink(&outside_dir, allowed_dir.join("link"))?;
|
||||
|
||||
let requested_source = allowed_dir.join("link").join("secret.txt");
|
||||
let sandbox_policy = workspace_write_sandbox_policy(allowed_dir);
|
||||
let sandbox = workspace_write_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.copy_with_sandbox_policy(
|
||||
.copy(
|
||||
&absolute_path(requested_source.clone()),
|
||||
&absolute_path(requested_destination.clone()),
|
||||
CopyOptions { recursive: false },
|
||||
Some(&sandbox_policy),
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => anyhow::bail!("copy should be blocked"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"fs/read is not permitted for path {}",
|
||||
requested_source.display()
|
||||
)
|
||||
);
|
||||
assert_sandbox_denied(&error);
|
||||
assert!(!requested_destination.exists());
|
||||
|
||||
Ok(())
|
||||
@@ -776,6 +736,7 @@ async fn file_system_copy_rejects_copying_directory_into_descendant(
|
||||
&absolute_path(source_dir.clone()),
|
||||
&absolute_path(source_dir.join("nested").join("copy")),
|
||||
CopyOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await;
|
||||
let error = match error {
|
||||
@@ -810,6 +771,7 @@ async fn file_system_copy_preserves_symlinks_in_recursive_copy(use_remote: bool)
|
||||
&absolute_path(source_dir),
|
||||
&absolute_path(copied_dir.clone()),
|
||||
CopyOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
@@ -855,6 +817,7 @@ async fn file_system_copy_ignores_unknown_special_files_in_recursive_copy(
|
||||
&absolute_path(source_dir),
|
||||
&absolute_path(copied_dir.clone()),
|
||||
CopyOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
@@ -891,6 +854,7 @@ async fn file_system_copy_rejects_standalone_fifo_source(use_remote: bool) -> Re
|
||||
&absolute_path(fifo_path),
|
||||
&absolute_path(tmp.path().join("copied")),
|
||||
CopyOptions { recursive: false },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await;
|
||||
let error = match error {
|
||||
|
||||
@@ -15,6 +15,7 @@ pub use cli::Command;
|
||||
pub use cli::ReviewArgs;
|
||||
use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
|
||||
use codex_app_server_client::EnvironmentManager;
|
||||
use codex_app_server_client::ExecServerRuntimePaths;
|
||||
use codex_app_server_client::InProcessAppServerClient;
|
||||
use codex_app_server_client::InProcessClientStartArgs;
|
||||
use codex_app_server_client::InProcessServerEvent;
|
||||
@@ -469,6 +470,10 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
|
||||
range: None,
|
||||
})
|
||||
.collect();
|
||||
let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths(
|
||||
arg0_paths.codex_self_exe.clone(),
|
||||
arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
)?;
|
||||
let in_process_start_args = InProcessClientStartArgs {
|
||||
arg0_paths,
|
||||
config: std::sync::Arc::new(config.clone()),
|
||||
@@ -476,7 +481,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
|
||||
loader_overrides: run_loader_overrides,
|
||||
cloud_requirements: run_cloud_requirements,
|
||||
feedback: CodexFeedback::new(),
|
||||
environment_manager: std::sync::Arc::new(EnvironmentManager::from_env()),
|
||||
environment_manager: std::sync::Arc::new(EnvironmentManager::from_env_with_runtime_paths(
|
||||
Some(local_runtime_paths),
|
||||
)),
|
||||
config_warnings,
|
||||
session_source: SessionSource::Exec,
|
||||
enable_codex_api_key_env: true,
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::sync::Arc;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_core::config::Config;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::ExecServerRuntimePaths;
|
||||
use codex_login::default_client::set_default_client_residency_requirement;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
|
||||
@@ -58,7 +59,12 @@ pub async fn run_main(
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
cli_config_overrides: CliConfigOverrides,
|
||||
) -> IoResult<()> {
|
||||
let environment_manager = Arc::new(EnvironmentManager::from_env());
|
||||
let environment_manager = Arc::new(EnvironmentManager::from_env_with_runtime_paths(Some(
|
||||
ExecServerRuntimePaths::from_optional_paths(
|
||||
arg0_paths.codex_self_exe.clone(),
|
||||
arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
)?,
|
||||
)));
|
||||
// Parse CLI overrides once and derive the base Config eagerly so later
|
||||
// components do not need to work with raw TOML values.
|
||||
let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| {
|
||||
|
||||
@@ -17,6 +17,7 @@ use serde_json::Value;
|
||||
|
||||
use crate::logging_client_handler::LoggingClientHandler;
|
||||
use crate::rmcp_client::Elicitation;
|
||||
use crate::rmcp_client::ElicitationPauseState;
|
||||
use crate::rmcp_client::ElicitationResponse;
|
||||
use crate::rmcp_client::SendElicitation;
|
||||
|
||||
@@ -26,10 +27,15 @@ const MCP_PROGRESS_TOKEN_META_KEY: &str = "progressToken";
|
||||
pub(crate) struct ElicitationClientService {
|
||||
handler: LoggingClientHandler,
|
||||
send_elicitation: Arc<SendElicitation>,
|
||||
pause_state: ElicitationPauseState,
|
||||
}
|
||||
|
||||
impl ElicitationClientService {
|
||||
pub(crate) fn new(client_info: ClientInfo, send_elicitation: SendElicitation) -> Self {
|
||||
pub(crate) fn new(
|
||||
client_info: ClientInfo,
|
||||
send_elicitation: SendElicitation,
|
||||
pause_state: ElicitationPauseState,
|
||||
) -> Self {
|
||||
let send_elicitation = Arc::new(send_elicitation);
|
||||
Self {
|
||||
handler: LoggingClientHandler::new(
|
||||
@@ -37,6 +43,7 @@ impl ElicitationClientService {
|
||||
clone_send_elicitation(Arc::clone(&send_elicitation)),
|
||||
),
|
||||
send_elicitation,
|
||||
pause_state,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +54,7 @@ impl ElicitationClientService {
|
||||
) -> Result<ElicitationResponse, rmcp::ErrorData> {
|
||||
let RequestContext { id, meta, .. } = context;
|
||||
let request = restore_context_meta(request, meta);
|
||||
let _pause = self.pause_state.enter();
|
||||
(self.send_elicitation)(id, request)
|
||||
.await
|
||||
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
@@ -63,6 +67,7 @@ use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
@@ -410,6 +415,93 @@ struct InitializeContext {
|
||||
client_service: ElicitationClientService,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ElicitationPauseState {
|
||||
active_count: Arc<AtomicUsize>,
|
||||
paused: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl ElicitationPauseState {
|
||||
fn new() -> Self {
|
||||
let (paused, _rx) = watch::channel(false);
|
||||
Self {
|
||||
active_count: Arc::new(AtomicUsize::new(0)),
|
||||
paused,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn enter(&self) -> ElicitationPauseGuard {
|
||||
if self.active_count.fetch_add(1, Ordering::AcqRel) == 0 {
|
||||
self.paused.send_replace(true);
|
||||
}
|
||||
ElicitationPauseGuard {
|
||||
pause_state: self.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> watch::Receiver<bool> {
|
||||
self.paused.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ElicitationPauseGuard {
|
||||
pause_state: ElicitationPauseState,
|
||||
}
|
||||
|
||||
impl Drop for ElicitationPauseGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.pause_state.active_count.fetch_sub(1, Ordering::AcqRel) == 1 {
|
||||
self.pause_state.paused.send_replace(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn active_time_timeout<T, Fut>(
|
||||
duration: Duration,
|
||||
mut pause_state: watch::Receiver<bool>,
|
||||
operation: Fut,
|
||||
) -> std::result::Result<T, ()>
|
||||
where
|
||||
Fut: Future<Output = T>,
|
||||
{
|
||||
let mut remaining = duration;
|
||||
tokio::pin!(operation);
|
||||
|
||||
loop {
|
||||
if *pause_state.borrow_and_update() {
|
||||
tokio::select! {
|
||||
result = &mut operation => return Ok(result),
|
||||
changed = pause_state.changed() => {
|
||||
if changed.is_err() {
|
||||
return time::timeout(remaining, operation).await.map_err(|_| ());
|
||||
}
|
||||
let _paused = *pause_state.borrow_and_update();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let active_start = Instant::now();
|
||||
tokio::select! {
|
||||
result = &mut operation => return Ok(result),
|
||||
_ = time::sleep(remaining) => {
|
||||
return Err(());
|
||||
}
|
||||
changed = pause_state.changed() => {
|
||||
if changed.is_err() {
|
||||
return time::timeout(remaining, operation).await.map_err(|_| ());
|
||||
}
|
||||
if *pause_state.borrow_and_update() {
|
||||
remaining = remaining.saturating_sub(active_start.elapsed());
|
||||
if remaining.is_zero() {
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum ClientOperationError {
|
||||
#[error(transparent)]
|
||||
@@ -472,6 +564,7 @@ pub struct RmcpClient {
|
||||
transport_recipe: TransportRecipe,
|
||||
initialize_context: Mutex<Option<InitializeContext>>,
|
||||
session_recovery_lock: Mutex<()>,
|
||||
elicitation_pause_state: ElicitationPauseState,
|
||||
}
|
||||
|
||||
impl RmcpClient {
|
||||
@@ -500,6 +593,7 @@ impl RmcpClient {
|
||||
transport_recipe,
|
||||
initialize_context: Mutex::new(None),
|
||||
session_recovery_lock: Mutex::new(()),
|
||||
elicitation_pause_state: ElicitationPauseState::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -528,6 +622,7 @@ impl RmcpClient {
|
||||
transport_recipe,
|
||||
initialize_context: Mutex::new(None),
|
||||
session_recovery_lock: Mutex::new(()),
|
||||
elicitation_pause_state: ElicitationPauseState::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -539,7 +634,11 @@ impl RmcpClient {
|
||||
timeout: Option<Duration>,
|
||||
send_elicitation: SendElicitation,
|
||||
) -> Result<InitializeResult> {
|
||||
let client_service = ElicitationClientService::new(params.clone(), send_elicitation);
|
||||
let client_service = ElicitationClientService::new(
|
||||
params.clone(),
|
||||
send_elicitation,
|
||||
self.elicitation_pause_state.clone(),
|
||||
);
|
||||
let pending_transport = {
|
||||
let mut guard = self.state.lock().await;
|
||||
match &mut *guard {
|
||||
@@ -1052,16 +1151,28 @@ impl RmcpClient {
|
||||
Fut: std::future::Future<Output = std::result::Result<T, rmcp::service::ServiceError>>,
|
||||
{
|
||||
let service = self.service().await?;
|
||||
match Self::run_service_operation_once(Arc::clone(&service), label, timeout, &operation)
|
||||
.await
|
||||
match Self::run_service_operation_once(
|
||||
Arc::clone(&service),
|
||||
label,
|
||||
timeout,
|
||||
self.elicitation_pause_state.clone(),
|
||||
&operation,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(result),
|
||||
Err(error) if Self::is_session_expired_404(&error) => {
|
||||
self.reinitialize_after_session_expiry(&service).await?;
|
||||
let recovered_service = self.service().await?;
|
||||
Self::run_service_operation_once(recovered_service, label, timeout, &operation)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
Self::run_service_operation_once(
|
||||
recovered_service,
|
||||
label,
|
||||
timeout,
|
||||
self.elicitation_pause_state.clone(),
|
||||
&operation,
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
@@ -1071,6 +1182,7 @@ impl RmcpClient {
|
||||
service: Arc<RunningService<RoleClient, ElicitationClientService>>,
|
||||
label: &str,
|
||||
timeout: Option<Duration>,
|
||||
pause_state: ElicitationPauseState,
|
||||
operation: &F,
|
||||
) -> std::result::Result<T, ClientOperationError>
|
||||
where
|
||||
@@ -1078,13 +1190,15 @@ impl RmcpClient {
|
||||
Fut: std::future::Future<Output = std::result::Result<T, rmcp::service::ServiceError>>,
|
||||
{
|
||||
match timeout {
|
||||
Some(duration) => time::timeout(duration, operation(service))
|
||||
.await
|
||||
.map_err(|_| ClientOperationError::Timeout {
|
||||
label: label.to_string(),
|
||||
duration,
|
||||
})?
|
||||
.map_err(ClientOperationError::from),
|
||||
Some(duration) => {
|
||||
active_time_timeout(duration, pause_state.subscribe(), operation(service))
|
||||
.await
|
||||
.map_err(|_| ClientOperationError::Timeout {
|
||||
label: label.to_string(),
|
||||
duration,
|
||||
})?
|
||||
.map_err(ClientOperationError::from)
|
||||
}
|
||||
None => operation(service).await.map_err(ClientOperationError::from),
|
||||
}
|
||||
}
|
||||
@@ -1207,3 +1321,32 @@ async fn create_oauth_transport_and_runtime(
|
||||
|
||||
Ok((transport, runtime))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::time;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_time_timeout_pauses_while_elicitation_is_pending() {
|
||||
let pause_state = ElicitationPauseState::new();
|
||||
let pause = pause_state.enter();
|
||||
tokio::spawn(async move {
|
||||
time::sleep(Duration::from_millis(75)).await;
|
||||
drop(pause);
|
||||
});
|
||||
|
||||
let result =
|
||||
active_time_timeout(Duration::from_millis(50), pause_state.subscribe(), async {
|
||||
time::sleep(Duration::from_millis(90)).await;
|
||||
"done"
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(Ok("done"), result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey;
|
||||
use codex_app_server_protocol::ThreadSourceKind;
|
||||
use codex_cloud_requirements::cloud_requirements_loader_for_storage;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::ExecServerRuntimePaths;
|
||||
use codex_login::AuthConfig;
|
||||
use codex_login::default_client::set_default_client_residency_requirement;
|
||||
use codex_login::enforce_login_restrictions;
|
||||
@@ -722,7 +723,12 @@ pub async fn run_main(
|
||||
}
|
||||
};
|
||||
|
||||
let environment_manager = Arc::new(EnvironmentManager::from_env());
|
||||
let environment_manager = Arc::new(EnvironmentManager::from_env_with_runtime_paths(Some(
|
||||
ExecServerRuntimePaths::from_optional_paths(
|
||||
arg0_paths.codex_self_exe.clone(),
|
||||
arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
)?,
|
||||
)));
|
||||
let cwd = cli.cwd.clone();
|
||||
let config_cwd =
|
||||
config_cwd_for_app_server_target(cwd.as_deref(), &app_server_target, &environment_manager)?;
|
||||
|
||||
2
justfile
2
justfile
@@ -14,7 +14,7 @@ codex *args:
|
||||
exec *args:
|
||||
cargo run --bin codex -- exec "$@"
|
||||
|
||||
# Start codex-exec-server and run codex-tui.
|
||||
# Start `codex exec-server` and run codex-tui.
|
||||
[no-cd]
|
||||
tui-with-exec-server *args:
|
||||
./scripts/run_tui_with_exec_server.sh "$@"
|
||||
|
||||
@@ -24,7 +24,7 @@ trap cleanup EXIT INT TERM HUP
|
||||
|
||||
(
|
||||
cd "$cargo_root"
|
||||
cargo run -p codex-exec-server -- --listen "$listen_url"
|
||||
cargo run -p codex-cli --bin codex -- exec-server --listen "$listen_url"
|
||||
) >"$stdout_log" 2>"$stderr_log" &
|
||||
server_pid="$!"
|
||||
|
||||
@@ -40,7 +40,7 @@ for _ in $(seq 1 "$((start_timeout_seconds * 20))"); do
|
||||
if ! kill -0 "$server_pid" >/dev/null 2>&1; then
|
||||
cat "$stderr_log" >&2 || true
|
||||
cat "$stdout_log" >&2 || true
|
||||
echo "failed to start codex-exec-server" >&2
|
||||
echo "failed to start codex exec-server" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -50,7 +50,7 @@ done
|
||||
if [[ -z "$exec_server_url" ]]; then
|
||||
cat "$stderr_log" >&2 || true
|
||||
cat "$stdout_log" >&2 || true
|
||||
echo "timed out waiting ${start_timeout_seconds}s for codex-exec-server to report its websocket URL" >&2
|
||||
echo "timed out waiting ${start_timeout_seconds}s for codex exec-server to report its websocket URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -105,10 +105,10 @@ remote_repo_root="$HOME/code/codex-sync"
|
||||
remote_codex_rs="$remote_repo_root/codex-rs"
|
||||
|
||||
cd "${remote_codex_rs}"
|
||||
cargo build -p codex-exec-server --bin codex-exec-server
|
||||
cargo build -p codex-cli --bin codex
|
||||
|
||||
rm -f "${remote_exec_server_log_path}" "${remote_exec_server_pid_path}"
|
||||
nohup ./target/debug/codex-exec-server --listen ws://127.0.0.1:0 \
|
||||
nohup ./target/debug/codex exec-server --listen ws://127.0.0.1:0 \
|
||||
>"${remote_exec_server_log_path}" 2>&1 &
|
||||
remote_exec_server_pid="$!"
|
||||
echo "${remote_exec_server_pid}" >"${remote_exec_server_pid_path}"
|
||||
|
||||
@@ -17,10 +17,10 @@ is_sourced() {
|
||||
|
||||
setup_remote_env() {
|
||||
local container_name
|
||||
local codex_exec_server_binary_path
|
||||
local codex_binary_path
|
||||
|
||||
container_name="${CODEX_TEST_REMOTE_ENV_CONTAINER_NAME:-codex-remote-test-env-local-$(date +%s)-${RANDOM}}"
|
||||
codex_exec_server_binary_path="${REPO_ROOT}/codex-rs/target/debug/codex-exec-server"
|
||||
codex_binary_path="${REPO_ROOT}/codex-rs/target/debug/codex"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker is required (Colima or Docker Desktop)" >&2
|
||||
@@ -33,17 +33,17 @@ setup_remote_env() {
|
||||
fi
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "cargo is required to build codex-exec-server" >&2
|
||||
echo "cargo is required to build codex" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
(
|
||||
cd "${REPO_ROOT}/codex-rs"
|
||||
cargo build -p codex-exec-server --bin codex-exec-server
|
||||
cargo build -p codex-cli --bin codex
|
||||
)
|
||||
|
||||
if [[ ! -f "${codex_exec_server_binary_path}" ]]; then
|
||||
echo "codex-exec-server binary not found at ${codex_exec_server_binary_path}" >&2
|
||||
if [[ ! -f "${codex_binary_path}" ]]; then
|
||||
echo "codex binary not found at ${codex_binary_path}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
# Codex CLI Runtime for Python SDK
|
||||
|
||||
Platform-specific runtime package consumed by the published `codex-app-server-sdk`.
|
||||
Platform-specific runtime package consumed by the published `openai-codex` SDK.
|
||||
|
||||
This package is staged during release so the SDK can pin an exact Codex CLI
|
||||
version without checking platform binaries into the repo.
|
||||
version without checking platform binaries into the repo. The distribution name
|
||||
is `openai-codex-cli-bin`, while the import module remains `codex_cli_bin`.
|
||||
|
||||
`codex-cli-bin` is intentionally wheel-only. Do not build or publish an sdist
|
||||
for this package.
|
||||
`openai-codex-cli-bin` is intentionally wheel-only. Do not build or publish an
|
||||
sdist for this package.
|
||||
|
||||
Expected wheel contents:
|
||||
|
||||
- macOS/Linux: `codex_cli_bin/bin/codex`
|
||||
- Windows: `codex_cli_bin/bin/codex.exe`,
|
||||
`codex_cli_bin/bin/codex-command-runner.exe`, and
|
||||
`codex_cli_bin/bin/codex-windows-sandbox-setup.exe`
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
|
||||
PLATFORM_TAG_BY_TARGET = {
|
||||
"aarch64-apple-darwin": "macosx_11_0_arm64",
|
||||
"x86_64-apple-darwin": "macosx_10_12_x86_64",
|
||||
"aarch64-unknown-linux-musl": "musllinux_1_2_aarch64",
|
||||
"x86_64-unknown-linux-musl": "musllinux_1_2_x86_64",
|
||||
"aarch64-pc-windows-msvc": "win_arm64",
|
||||
"x86_64-pc-windows-msvc": "win_amd64",
|
||||
}
|
||||
|
||||
|
||||
class RuntimeBuildHook(BuildHookInterface):
|
||||
def initialize(self, version: str, build_data: dict[str, object]) -> None:
|
||||
del version
|
||||
if self.target_name == "sdist":
|
||||
raise RuntimeError(
|
||||
"codex-cli-bin is wheel-only; build and publish platform wheels only."
|
||||
"openai-codex-cli-bin is wheel-only; build and publish platform wheels only."
|
||||
)
|
||||
|
||||
build_data["pure_python"] = False
|
||||
build_data["infer_tag"] = True
|
||||
target = os.environ.get("CODEX_PYTHON_RUNTIME_TARGET")
|
||||
if target is None:
|
||||
build_data["infer_tag"] = True
|
||||
return
|
||||
|
||||
platform_tag = PLATFORM_TAG_BY_TARGET.get(target)
|
||||
if platform_tag is None:
|
||||
raise RuntimeError(f"Unsupported Codex Python runtime target: {target}")
|
||||
build_data["tag"] = f"py3-none-{platform_tag}"
|
||||
|
||||
@@ -3,7 +3,7 @@ requires = ["hatchling>=1.24.0"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "codex-cli-bin"
|
||||
name = "openai-codex-cli-bin"
|
||||
version = "0.0.0-dev"
|
||||
description = "Pinned Codex CLI runtime for the Python SDK"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -3,12 +3,25 @@ from __future__ import annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE_NAME = "codex-cli-bin"
|
||||
PACKAGE_NAME = "openai-codex-cli-bin"
|
||||
|
||||
|
||||
def bundled_bin_dir() -> Path:
|
||||
return Path(__file__).resolve().parent / "bin"
|
||||
|
||||
|
||||
def bundled_runtime_files() -> tuple[Path, ...]:
|
||||
names = (
|
||||
("codex.exe", "codex-command-runner.exe", "codex-windows-sandbox-setup.exe")
|
||||
if os.name == "nt"
|
||||
else ("codex",)
|
||||
)
|
||||
return tuple(bundled_bin_dir() / name for name in names)
|
||||
|
||||
|
||||
def bundled_codex_path() -> Path:
|
||||
exe = "codex.exe" if os.name == "nt" else "codex"
|
||||
path = Path(__file__).resolve().parent / "bin" / exe
|
||||
path = bundled_bin_dir() / exe
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"{PACKAGE_NAME} is installed but missing its packaged codex binary at {path}"
|
||||
@@ -16,4 +29,9 @@ def bundled_codex_path() -> Path:
|
||||
return path
|
||||
|
||||
|
||||
__all__ = ["PACKAGE_NAME", "bundled_codex_path"]
|
||||
__all__ = [
|
||||
"PACKAGE_NAME",
|
||||
"bundled_bin_dir",
|
||||
"bundled_codex_path",
|
||||
"bundled_runtime_files",
|
||||
]
|
||||
|
||||
@@ -15,7 +15,7 @@ import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE_NAME = "codex-cli-bin"
|
||||
PACKAGE_NAME = "openai-codex-cli-bin"
|
||||
PINNED_RUNTIME_VERSION = "0.116.0-alpha.1"
|
||||
REPO_SLUG = "openai/codex"
|
||||
|
||||
@@ -39,17 +39,20 @@ def ensure_runtime_package_installed(
|
||||
installed_version = _installed_runtime_version(python_executable)
|
||||
normalized_requested = _normalized_package_version(requested_version)
|
||||
|
||||
if installed_version is not None and _normalized_package_version(installed_version) == normalized_requested:
|
||||
if (
|
||||
installed_version is not None
|
||||
and _normalized_package_version(installed_version) == normalized_requested
|
||||
):
|
||||
return requested_version
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="codex-python-runtime-") as temp_root_str:
|
||||
temp_root = Path(temp_root_str)
|
||||
archive_path = _download_release_archive(requested_version, temp_root)
|
||||
runtime_binary = _extract_runtime_binary(archive_path, temp_root)
|
||||
runtime_bundle_dir = _extract_runtime_bundle(archive_path, temp_root)
|
||||
staged_runtime_dir = _stage_runtime_package(
|
||||
sdk_python_dir,
|
||||
requested_version,
|
||||
runtime_binary,
|
||||
runtime_bundle_dir,
|
||||
temp_root / "runtime-stage",
|
||||
)
|
||||
_install_runtime_package(python_executable, staged_runtime_dir, install_target)
|
||||
@@ -61,7 +64,10 @@ def ensure_runtime_package_installed(
|
||||
importlib.invalidate_caches()
|
||||
|
||||
installed_version = _installed_runtime_version(python_executable)
|
||||
if installed_version is None or _normalized_package_version(installed_version) != normalized_requested:
|
||||
if (
|
||||
installed_version is None
|
||||
or _normalized_package_version(installed_version) != normalized_requested
|
||||
):
|
||||
raise RuntimeSetupError(
|
||||
f"Expected {PACKAGE_NAME} {requested_version} in {python_executable}, "
|
||||
f"but found {installed_version!r} after installation."
|
||||
@@ -105,7 +111,7 @@ def _installed_runtime_version(python_executable: str | Path) -> str | None:
|
||||
"try:\n"
|
||||
" from codex_cli_bin import bundled_codex_path\n"
|
||||
" bundled_codex_path()\n"
|
||||
" print(json.dumps({'version': importlib.metadata.version('codex-cli-bin')}))\n"
|
||||
f" print(json.dumps({{'version': importlib.metadata.version({PACKAGE_NAME!r})}}))\n"
|
||||
"except Exception:\n"
|
||||
" sys.exit(1)\n"
|
||||
)
|
||||
@@ -172,7 +178,9 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
|
||||
metadata = _release_metadata(version)
|
||||
assets = metadata.get("assets")
|
||||
if not isinstance(assets, list):
|
||||
raise RuntimeSetupError(f"Release rust-v{version} returned malformed assets metadata.")
|
||||
raise RuntimeSetupError(
|
||||
f"Release rust-v{version} returned malformed assets metadata."
|
||||
)
|
||||
asset = next(
|
||||
(
|
||||
item
|
||||
@@ -198,7 +206,10 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
|
||||
headers=_github_api_headers("application/octet-stream"),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request) as response, archive_path.open("wb") as fh:
|
||||
with (
|
||||
urllib.request.urlopen(request) as response,
|
||||
archive_path.open("wb") as fh,
|
||||
):
|
||||
shutil.copyfileobj(response, fh)
|
||||
return archive_path
|
||||
except urllib.error.HTTPError:
|
||||
@@ -236,7 +247,7 @@ def _download_release_archive(version: str, temp_root: Path) -> Path:
|
||||
return archive_path
|
||||
|
||||
|
||||
def _extract_runtime_binary(archive_path: Path, temp_root: Path) -> Path:
|
||||
def _extract_runtime_bundle(archive_path: Path, temp_root: Path) -> Path:
|
||||
extract_dir = temp_root / "extracted"
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
if archive_path.name.endswith(".tar.gz"):
|
||||
@@ -249,38 +260,24 @@ def _extract_runtime_binary(archive_path: Path, temp_root: Path) -> Path:
|
||||
with zipfile.ZipFile(archive_path) as zip_file:
|
||||
zip_file.extractall(extract_dir)
|
||||
else:
|
||||
raise RuntimeSetupError(f"Unsupported release archive format: {archive_path.name}")
|
||||
|
||||
binary_name = runtime_binary_name()
|
||||
archive_stem = archive_path.name.removesuffix(".tar.gz").removesuffix(".zip")
|
||||
candidates = [
|
||||
path
|
||||
for path in extract_dir.rglob("*")
|
||||
if path.is_file()
|
||||
and (
|
||||
path.name == binary_name
|
||||
or path.name == archive_stem
|
||||
or path.name.startswith("codex-")
|
||||
)
|
||||
]
|
||||
if not candidates:
|
||||
raise RuntimeSetupError(
|
||||
f"Failed to find {binary_name} in extracted runtime archive {archive_path.name}."
|
||||
f"Unsupported release archive format: {archive_path.name}"
|
||||
)
|
||||
return candidates[0]
|
||||
|
||||
return extract_dir
|
||||
|
||||
|
||||
def _stage_runtime_package(
|
||||
sdk_python_dir: Path,
|
||||
runtime_version: str,
|
||||
runtime_binary: Path,
|
||||
runtime_bundle_dir: Path,
|
||||
staging_dir: Path,
|
||||
) -> Path:
|
||||
script_module = _load_update_script_module(sdk_python_dir)
|
||||
return script_module.stage_python_runtime_package( # type: ignore[no-any-return]
|
||||
staging_dir,
|
||||
runtime_version,
|
||||
runtime_binary.resolve(),
|
||||
runtime_bundle_dir.resolve(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Sequence, get_args, get_origin
|
||||
|
||||
SDK_PKG_NAME = "openai-codex"
|
||||
RUNTIME_PKG_NAME = "openai-codex-cli-bin"
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
@@ -45,16 +48,30 @@ def schema_root_dir() -> Path:
|
||||
return repo_root() / "codex-rs" / "app-server-protocol" / "schema" / "json"
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
return platform.system().lower().startswith("win")
|
||||
def _is_windows(system_name: str | None = None) -> bool:
|
||||
return (system_name or platform.system()).lower().startswith("win")
|
||||
|
||||
|
||||
def runtime_binary_name() -> str:
|
||||
return "codex.exe" if _is_windows() else "codex"
|
||||
def runtime_binary_name(system_name: str | None = None) -> str:
|
||||
return "codex.exe" if _is_windows(system_name) else "codex"
|
||||
|
||||
|
||||
def runtime_file_names(system_name: str | None = None) -> tuple[str, ...]:
|
||||
if _is_windows(system_name):
|
||||
return (
|
||||
"codex.exe",
|
||||
"codex-command-runner.exe",
|
||||
"codex-windows-sandbox-setup.exe",
|
||||
)
|
||||
return ("codex",)
|
||||
|
||||
|
||||
def staged_runtime_bin_dir(root: Path) -> Path:
|
||||
return root / "src" / "codex_cli_bin" / "bin"
|
||||
|
||||
|
||||
def staged_runtime_bin_path(root: Path) -> Path:
|
||||
return root / "src" / "codex_cli_bin" / "bin" / runtime_binary_name()
|
||||
return staged_runtime_bin_dir(root) / runtime_binary_name()
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: Path) -> None:
|
||||
@@ -110,6 +127,39 @@ def _rewrite_project_version(pyproject_text: str, version: str) -> str:
|
||||
return updated
|
||||
|
||||
|
||||
def _rewrite_project_name(pyproject_text: str, name: str) -> str:
|
||||
updated, count = re.subn(
|
||||
r'^name = "[^"]+"$',
|
||||
f'name = "{name}"',
|
||||
pyproject_text,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if count != 1:
|
||||
raise RuntimeError("Could not rewrite project name in pyproject.toml")
|
||||
return updated
|
||||
|
||||
|
||||
def normalize_python_package_version(version: str) -> str:
|
||||
stripped = version.strip()
|
||||
if re.fullmatch(r"\d+\.\d+\.\d+(?:a\d+|b\d+|\.dev\d+)?", stripped):
|
||||
return stripped
|
||||
|
||||
prerelease_match = re.fullmatch(
|
||||
r"(\d+\.\d+\.\d+)-(alpha|beta)\.(\d+)",
|
||||
stripped,
|
||||
)
|
||||
if prerelease_match is not None:
|
||||
base, prerelease, number = prerelease_match.groups()
|
||||
marker = "a" if prerelease == "alpha" else "b"
|
||||
return f"{base}{marker}{number}"
|
||||
|
||||
raise RuntimeError(
|
||||
"Unsupported Python package version. Expected x.y.z, x.y.z-alpha.n, "
|
||||
f"x.y.z-beta.n, or an already-normalized PEP 440 version; got {version!r}."
|
||||
)
|
||||
|
||||
|
||||
def _rewrite_sdk_runtime_dependency(pyproject_text: str, runtime_version: str) -> str:
|
||||
match = re.search(r"^dependencies = \[(.*?)\]$", pyproject_text, flags=re.MULTILINE)
|
||||
if match is None:
|
||||
@@ -118,15 +168,46 @@ def _rewrite_sdk_runtime_dependency(pyproject_text: str, runtime_version: str) -
|
||||
)
|
||||
|
||||
raw_items = [item.strip() for item in match.group(1).split(",") if item.strip()]
|
||||
raw_items = [item for item in raw_items if "codex-cli-bin" not in item]
|
||||
raw_items.append(f'"codex-cli-bin=={runtime_version}"')
|
||||
raw_items = [
|
||||
item
|
||||
for item in raw_items
|
||||
if "codex-cli-bin" not in item and RUNTIME_PKG_NAME not in item
|
||||
]
|
||||
raw_items.append(f'"{RUNTIME_PKG_NAME}=={runtime_version}"')
|
||||
replacement = "dependencies = [\n " + ",\n ".join(raw_items) + ",\n]"
|
||||
return pyproject_text[: match.start()] + replacement + pyproject_text[match.end() :]
|
||||
|
||||
|
||||
def _rewrite_sdk_init_version(init_text: str, sdk_version: str) -> str:
|
||||
updated, count = re.subn(
|
||||
r'^__version__ = "[^"]+"$',
|
||||
f'__version__ = "{sdk_version}"',
|
||||
init_text,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if count != 1:
|
||||
raise RuntimeError("Could not rewrite SDK __version__")
|
||||
return updated
|
||||
|
||||
|
||||
def _rewrite_sdk_client_version(client_text: str, sdk_version: str) -> str:
|
||||
updated, count = re.subn(
|
||||
r'client_version: str = "[^"]+"',
|
||||
f'client_version: str = "{sdk_version}"',
|
||||
client_text,
|
||||
count=1,
|
||||
)
|
||||
if count != 1:
|
||||
raise RuntimeError("Could not rewrite AppServerConfig.client_version")
|
||||
return updated
|
||||
|
||||
|
||||
def stage_python_sdk_package(
|
||||
staging_dir: Path, sdk_version: str, runtime_version: str
|
||||
) -> Path:
|
||||
sdk_version = normalize_python_package_version(sdk_version)
|
||||
runtime_version = normalize_python_package_version(runtime_version)
|
||||
_copy_package_tree(sdk_root(), staging_dir)
|
||||
sdk_bin_dir = staging_dir / "src" / "codex_app_server" / "bin"
|
||||
if sdk_bin_dir.exists():
|
||||
@@ -134,32 +215,88 @@ def stage_python_sdk_package(
|
||||
|
||||
pyproject_path = staging_dir / "pyproject.toml"
|
||||
pyproject_text = pyproject_path.read_text()
|
||||
pyproject_text = _rewrite_project_name(pyproject_text, SDK_PKG_NAME)
|
||||
pyproject_text = _rewrite_project_version(pyproject_text, sdk_version)
|
||||
pyproject_text = _rewrite_sdk_runtime_dependency(pyproject_text, runtime_version)
|
||||
pyproject_path.write_text(pyproject_text)
|
||||
|
||||
init_path = staging_dir / "src" / "codex_app_server" / "__init__.py"
|
||||
init_path.write_text(_rewrite_sdk_init_version(init_path.read_text(), sdk_version))
|
||||
|
||||
client_path = staging_dir / "src" / "codex_app_server" / "client.py"
|
||||
client_path.write_text(
|
||||
_rewrite_sdk_client_version(client_path.read_text(), sdk_version)
|
||||
)
|
||||
return staging_dir
|
||||
|
||||
|
||||
def stage_python_runtime_package(
|
||||
staging_dir: Path, runtime_version: str, binary_path: Path
|
||||
staging_dir: Path, runtime_version: str, runtime_bundle_dir: Path
|
||||
) -> Path:
|
||||
runtime_version = normalize_python_package_version(runtime_version)
|
||||
_copy_package_tree(python_runtime_root(), staging_dir)
|
||||
|
||||
pyproject_path = staging_dir / "pyproject.toml"
|
||||
pyproject_path.write_text(
|
||||
_rewrite_project_version(pyproject_path.read_text(), runtime_version)
|
||||
)
|
||||
pyproject_text = _rewrite_project_name(pyproject_path.read_text(), RUNTIME_PKG_NAME)
|
||||
pyproject_text = _rewrite_project_version(pyproject_text, runtime_version)
|
||||
pyproject_path.write_text(pyproject_text)
|
||||
|
||||
out_bin = staged_runtime_bin_path(staging_dir)
|
||||
out_bin.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(binary_path, out_bin)
|
||||
if not _is_windows():
|
||||
out_bin.chmod(
|
||||
out_bin.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||||
)
|
||||
out_bin_dir = staged_runtime_bin_dir(staging_dir)
|
||||
out_bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
for runtime_file_name in runtime_file_names():
|
||||
source = _find_runtime_bundle_file(runtime_bundle_dir, runtime_file_name)
|
||||
out_path = out_bin_dir / runtime_file_name
|
||||
shutil.copy2(source, out_path)
|
||||
if not _is_windows():
|
||||
out_path.chmod(
|
||||
out_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||||
)
|
||||
return staging_dir
|
||||
|
||||
|
||||
def _find_runtime_bundle_file(runtime_bundle_dir: Path, destination_name: str) -> Path:
|
||||
if not runtime_bundle_dir.is_dir():
|
||||
raise RuntimeError(f"Runtime bundle directory not found: {runtime_bundle_dir}")
|
||||
|
||||
exact = runtime_bundle_dir / destination_name
|
||||
if exact.is_file():
|
||||
return exact
|
||||
|
||||
patterns = {
|
||||
"codex": re.compile(r"^codex-(?!responses-api-proxy)[^.]+$"),
|
||||
"codex.exe": re.compile(
|
||||
r"^codex-(?!command-runner|windows-sandbox-setup|responses-api-proxy).+\.exe$"
|
||||
),
|
||||
"codex-command-runner.exe": re.compile(r"^codex-command-runner-.+\.exe$"),
|
||||
"codex-windows-sandbox-setup.exe": re.compile(
|
||||
r"^codex-windows-sandbox-setup-.+\.exe$"
|
||||
),
|
||||
}
|
||||
pattern = patterns.get(destination_name)
|
||||
candidates = (
|
||||
[]
|
||||
if pattern is None
|
||||
else sorted(
|
||||
path
|
||||
for path in runtime_bundle_dir.iterdir()
|
||||
if path.is_file() and pattern.fullmatch(path.name)
|
||||
)
|
||||
)
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
if len(candidates) > 1:
|
||||
candidate_names = ", ".join(path.name for path in candidates)
|
||||
raise RuntimeError(
|
||||
f"Runtime bundle has multiple candidates for {destination_name}: "
|
||||
f"{candidate_names}"
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Runtime bundle {runtime_bundle_dir} is missing required file "
|
||||
f"{destination_name}"
|
||||
)
|
||||
|
||||
|
||||
def _flatten_string_enum_one_of(definition: dict[str, Any]) -> bool:
|
||||
branches = definition.get("oneOf")
|
||||
if not isinstance(branches, list) or not branches:
|
||||
@@ -928,7 +1065,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
stage_sdk_parser.add_argument(
|
||||
"--runtime-version",
|
||||
required=True,
|
||||
help="Pinned codex-cli-bin version for the staged SDK package",
|
||||
help=f"Pinned {RUNTIME_PKG_NAME} version for the staged SDK package",
|
||||
)
|
||||
stage_sdk_parser.add_argument(
|
||||
"--sdk-version",
|
||||
@@ -945,9 +1082,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="Output directory for the staged runtime package",
|
||||
)
|
||||
stage_runtime_parser.add_argument(
|
||||
"runtime_binary",
|
||||
"runtime_bundle_dir",
|
||||
type=Path,
|
||||
help="Path to the codex binary to package for this platform",
|
||||
help="Directory containing the Codex runtime files to package for this platform",
|
||||
)
|
||||
stage_runtime_parser.add_argument(
|
||||
"--runtime-version",
|
||||
@@ -984,7 +1121,7 @@ def run_command(args: argparse.Namespace, ops: CliOps) -> None:
|
||||
ops.stage_python_runtime_package(
|
||||
args.staging_dir,
|
||||
args.runtime_version,
|
||||
args.runtime_binary.resolve(),
|
||||
args.runtime_bundle_dir.resolve(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -47,7 +47,8 @@ from .retry import retry_on_overload
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=BaseModel)
|
||||
ApprovalHandler = Callable[[str, JsonObject | None], JsonObject]
|
||||
RUNTIME_PKG_NAME = "codex-cli-bin"
|
||||
SDK_PKG_NAME = "openai-codex"
|
||||
RUNTIME_PKG_NAME = "openai-codex-cli-bin"
|
||||
|
||||
|
||||
def _params_dict(
|
||||
|
||||
@@ -29,7 +29,22 @@ def _load_runtime_setup_module():
|
||||
runtime_setup_path = ROOT / "_runtime_setup.py"
|
||||
spec = importlib.util.spec_from_file_location("_runtime_setup", runtime_setup_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise AssertionError(f"Failed to load runtime setup module: {runtime_setup_path}")
|
||||
raise AssertionError(
|
||||
f"Failed to load runtime setup module: {runtime_setup_path}"
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _load_runtime_package_module(package_root: Path):
|
||||
runtime_init = package_root / "src" / "codex_cli_bin" / "__init__.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"codex_cli_bin_under_test", runtime_init
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise AssertionError(f"Failed to load runtime package module: {runtime_init}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
@@ -168,7 +183,9 @@ def test_examples_readme_matches_pinned_runtime_version() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_release_metadata_retries_without_invalid_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_release_metadata_retries_without_invalid_auth(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
runtime_setup = _load_runtime_setup_module()
|
||||
authorizations: list[str | None] = []
|
||||
|
||||
@@ -198,6 +215,14 @@ def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() ->
|
||||
)
|
||||
hook_source = (ROOT.parent / "python-runtime" / "hatch_build.py").read_text()
|
||||
hook_tree = ast.parse(hook_source)
|
||||
platform_tag_assignment = next(
|
||||
node
|
||||
for node in hook_tree.body
|
||||
if isinstance(node, ast.Assign)
|
||||
and len(node.targets) == 1
|
||||
and isinstance(node.targets[0], ast.Name)
|
||||
and node.targets[0].id == "PLATFORM_TAG_BY_TARGET"
|
||||
)
|
||||
initialize_fn = next(
|
||||
node
|
||||
for node in ast.walk(hook_tree)
|
||||
@@ -235,6 +260,7 @@ def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() ->
|
||||
and isinstance(node.value, ast.Constant)
|
||||
}
|
||||
|
||||
assert pyproject["project"]["name"] == "openai-codex-cli-bin"
|
||||
assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"] == {
|
||||
"packages": ["src/codex_cli_bin"],
|
||||
"include": ["src/codex_cli_bin/bin/**"],
|
||||
@@ -244,23 +270,51 @@ def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() ->
|
||||
"hooks": {"custom": {}},
|
||||
}
|
||||
assert sdist_guard is not None
|
||||
assert build_data_assignments == {"pure_python": False, "infer_tag": True}
|
||||
assert build_data_assignments == {"pure_python": False}
|
||||
assert ast.literal_eval(platform_tag_assignment.value) == {
|
||||
"aarch64-apple-darwin": "macosx_11_0_arm64",
|
||||
"x86_64-apple-darwin": "macosx_10_12_x86_64",
|
||||
"aarch64-unknown-linux-musl": "musllinux_1_2_aarch64",
|
||||
"x86_64-unknown-linux-musl": "musllinux_1_2_x86_64",
|
||||
"aarch64-pc-windows-msvc": "win_arm64",
|
||||
"x86_64-pc-windows-msvc": "win_amd64",
|
||||
}
|
||||
assert "CODEX_PYTHON_RUNTIME_TARGET" in hook_source
|
||||
assert '"infer_tag"' in hook_source
|
||||
assert '"tag"' in hook_source
|
||||
|
||||
|
||||
def test_stage_runtime_release_copies_binary_and_sets_version(tmp_path: Path) -> None:
|
||||
def test_python_release_version_normalization() -> None:
|
||||
script = _load_update_script_module()
|
||||
fake_binary = tmp_path / script.runtime_binary_name()
|
||||
|
||||
assert script.normalize_python_package_version("1.2.3") == "1.2.3"
|
||||
assert script.normalize_python_package_version("1.2.3-alpha.4") == "1.2.3a4"
|
||||
assert script.normalize_python_package_version("1.2.3-beta.5") == "1.2.3b5"
|
||||
assert script.normalize_python_package_version("1.2.3a4") == "1.2.3a4"
|
||||
assert script.normalize_python_package_version("0.0.0.dev0") == "0.0.0.dev0"
|
||||
|
||||
with pytest.raises(RuntimeError, match="Unsupported Python package version"):
|
||||
script.normalize_python_package_version("1.2.3-rc.1")
|
||||
|
||||
|
||||
def test_stage_runtime_release_copies_bundle_and_sets_version(tmp_path: Path) -> None:
|
||||
script = _load_update_script_module()
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
fake_binary = bundle_dir / script.runtime_binary_name()
|
||||
fake_binary.write_text("fake codex\n")
|
||||
|
||||
staged = script.stage_python_runtime_package(
|
||||
tmp_path / "runtime-stage",
|
||||
"1.2.3",
|
||||
fake_binary,
|
||||
"1.2.3-alpha.4",
|
||||
bundle_dir,
|
||||
)
|
||||
|
||||
assert staged == tmp_path / "runtime-stage"
|
||||
assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n"
|
||||
assert 'version = "1.2.3"' in (staged / "pyproject.toml").read_text()
|
||||
pyproject = (staged / "pyproject.toml").read_text()
|
||||
assert 'name = "openai-codex-cli-bin"' in pyproject
|
||||
assert 'version = "1.2.3a4"' in pyproject
|
||||
|
||||
|
||||
def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> None:
|
||||
@@ -270,13 +324,15 @@ def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) ->
|
||||
old_file.parent.mkdir(parents=True)
|
||||
old_file.write_text("stale")
|
||||
|
||||
fake_binary = tmp_path / script.runtime_binary_name()
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
fake_binary = bundle_dir / script.runtime_binary_name()
|
||||
fake_binary.write_text("fake codex\n")
|
||||
|
||||
staged = script.stage_python_runtime_package(
|
||||
staging_dir,
|
||||
"1.2.3",
|
||||
fake_binary,
|
||||
bundle_dir,
|
||||
)
|
||||
|
||||
assert staged == staging_dir
|
||||
@@ -284,13 +340,132 @@ def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) ->
|
||||
assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n"
|
||||
|
||||
|
||||
def test_stage_sdk_release_injects_exact_runtime_pin(tmp_path: Path) -> None:
|
||||
def test_stage_runtime_release_normalizes_target_suffixed_names(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
script = _load_update_script_module()
|
||||
staged = script.stage_python_sdk_package(tmp_path / "sdk-stage", "0.2.1", "1.2.3")
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "codex-x86_64-unknown-linux-musl").write_text("fake codex\n")
|
||||
|
||||
staged = script.stage_python_runtime_package(
|
||||
tmp_path / "runtime-stage",
|
||||
"1.2.3",
|
||||
bundle_dir,
|
||||
)
|
||||
|
||||
assert (staged / "src" / "codex_cli_bin" / "bin" / "codex").read_text() == (
|
||||
"fake codex\n"
|
||||
)
|
||||
|
||||
|
||||
def test_stage_runtime_release_requires_complete_windows_bundle(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
script = _load_update_script_module()
|
||||
monkeypatch.setattr(script.platform, "system", lambda: "Windows")
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "codex-x86_64-pc-windows-msvc.exe").write_text("codex\n")
|
||||
(bundle_dir / "codex-command-runner-x86_64-pc-windows-msvc.exe").write_text(
|
||||
"runner\n"
|
||||
)
|
||||
(bundle_dir / "codex-windows-sandbox-setup-x86_64-pc-windows-msvc.exe").write_text(
|
||||
"setup\n"
|
||||
)
|
||||
|
||||
staged = script.stage_python_runtime_package(
|
||||
tmp_path / "runtime-stage",
|
||||
"1.2.3",
|
||||
bundle_dir,
|
||||
)
|
||||
bin_dir = staged / "src" / "codex_cli_bin" / "bin"
|
||||
|
||||
assert (bin_dir / "codex.exe").read_text() == "codex\n"
|
||||
assert (bin_dir / "codex-command-runner.exe").read_text() == "runner\n"
|
||||
assert (bin_dir / "codex-windows-sandbox-setup.exe").read_text() == "setup\n"
|
||||
|
||||
|
||||
def test_stage_runtime_release_fails_for_missing_required_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
script = _load_update_script_module()
|
||||
monkeypatch.setattr(script.platform, "system", lambda: "Windows")
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "codex.exe").write_text("codex\n")
|
||||
|
||||
with pytest.raises(RuntimeError, match="codex-command-runner.exe"):
|
||||
script.stage_python_runtime_package(
|
||||
tmp_path / "runtime-stage",
|
||||
"1.2.3",
|
||||
bundle_dir,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_package_helpers_return_packaged_paths(tmp_path: Path) -> None:
|
||||
script = _load_update_script_module()
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "codex").write_text("fake codex\n")
|
||||
staged = script.stage_python_runtime_package(
|
||||
tmp_path / "runtime-stage",
|
||||
"1.2.3",
|
||||
bundle_dir,
|
||||
)
|
||||
|
||||
runtime_module = _load_runtime_package_module(staged)
|
||||
|
||||
assert runtime_module.PACKAGE_NAME == "openai-codex-cli-bin"
|
||||
assert runtime_module.bundled_bin_dir() == staged / "src" / "codex_cli_bin" / "bin"
|
||||
assert runtime_module.bundled_runtime_files() == (
|
||||
staged / "src" / "codex_cli_bin" / "bin" / "codex",
|
||||
)
|
||||
assert runtime_module.bundled_codex_path() == (
|
||||
staged / "src" / "codex_cli_bin" / "bin" / "codex"
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_package_helpers_report_missing_binary(tmp_path: Path) -> None:
|
||||
script = _load_update_script_module()
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "codex").write_text("fake codex\n")
|
||||
staged = script.stage_python_runtime_package(
|
||||
tmp_path / "runtime-stage",
|
||||
"1.2.3",
|
||||
bundle_dir,
|
||||
)
|
||||
(staged / "src" / "codex_cli_bin" / "bin" / "codex").unlink()
|
||||
|
||||
runtime_module = _load_runtime_package_module(staged)
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="openai-codex-cli-bin"):
|
||||
runtime_module.bundled_codex_path()
|
||||
|
||||
|
||||
def test_stage_sdk_release_injects_exact_runtime_pin_and_versions(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
script = _load_update_script_module()
|
||||
staged = script.stage_python_sdk_package(
|
||||
tmp_path / "sdk-stage",
|
||||
"0.2.1-beta.2",
|
||||
"1.2.3-alpha.4",
|
||||
)
|
||||
|
||||
pyproject = (staged / "pyproject.toml").read_text()
|
||||
assert 'version = "0.2.1"' in pyproject
|
||||
assert '"codex-cli-bin==1.2.3"' in pyproject
|
||||
assert 'name = "openai-codex"' in pyproject
|
||||
assert 'version = "0.2.1b2"' in pyproject
|
||||
assert '"openai-codex-cli-bin==1.2.3a4"' in pyproject
|
||||
assert (
|
||||
'__version__ = "0.2.1b2"'
|
||||
in (staged / "src" / "codex_app_server" / "__init__.py").read_text()
|
||||
)
|
||||
assert (
|
||||
'client_version: str = "0.2.1b2"'
|
||||
in (staged / "src" / "codex_app_server" / "client.py").read_text()
|
||||
)
|
||||
assert not any((staged / "src" / "codex_app_server").glob("bin/**"))
|
||||
|
||||
|
||||
@@ -329,7 +504,7 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
|
||||
return tmp_path / "sdk-stage"
|
||||
|
||||
def fake_stage_runtime_package(
|
||||
_staging_dir: Path, _runtime_version: str, _runtime_binary: Path
|
||||
_staging_dir: Path, _runtime_version: str, _runtime_bundle_dir: Path
|
||||
) -> Path:
|
||||
raise AssertionError("runtime staging should not run for stage-sdk")
|
||||
|
||||
@@ -350,14 +525,15 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None:
|
||||
|
||||
def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> None:
|
||||
script = _load_update_script_module()
|
||||
fake_binary = tmp_path / script.runtime_binary_name()
|
||||
fake_binary.write_text("fake codex\n")
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / script.runtime_binary_name()).write_text("fake codex\n")
|
||||
calls: list[str] = []
|
||||
args = script.parse_args(
|
||||
[
|
||||
"stage-runtime",
|
||||
str(tmp_path / "runtime-stage"),
|
||||
str(fake_binary),
|
||||
str(bundle_dir),
|
||||
"--runtime-version",
|
||||
"1.2.3",
|
||||
]
|
||||
@@ -372,7 +548,7 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) ->
|
||||
raise AssertionError("sdk staging should not run for stage-runtime")
|
||||
|
||||
def fake_stage_runtime_package(
|
||||
_staging_dir: Path, _runtime_version: str, _runtime_binary: Path
|
||||
_staging_dir: Path, _runtime_version: str, _runtime_bundle_dir: Path
|
||||
) -> Path:
|
||||
calls.append("stage_runtime")
|
||||
return tmp_path / "runtime-stage"
|
||||
|
||||
Reference in New Issue
Block a user