Compare commits

..

3 Commits

Author SHA1 Message Date
Shaqayeq
f412b39118 python-runtime: prepare openai-codex-cli-bin package 2026-04-12 23:23:33 -07:00
starr-openai
d626dc3895 Run exec-server fs operations through sandbox helper (#17294)
## Summary
- run exec-server filesystem RPCs requiring sandboxing through a
`codex-fs` arg0 helper over stdin/stdout
- keep direct local filesystem execution for `DangerFullAccess` and
external sandbox policies
- remove the standalone exec-server binary path in favor of top-level
arg0 dispatch/runtime paths
- add sandbox escape regression coverage for local and remote filesystem
paths

## Validation
- `just fmt`
- `git diff --check`
- remote devbox: `cd codex-rs && bazel test --bes_backend=
--bes_results_url= //codex-rs/exec-server:all` (6/6 passed)

---------

Co-authored-by: Codex <noreply@openai.com>
2026-04-12 18:36:03 -07:00
pakrym-oai
7c1e41c8b6 Add MCP tool wall time to model output (#17406)
Include MCP wall time in the output so the model is aware of how long
it's calls are taking.
2026-04-12 18:26:15 -07:00
76 changed files with 3035 additions and 2606 deletions

2
codex-rs/Cargo.lock generated
View File

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

View File

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

View File

@@ -46,7 +46,7 @@ impl FsApi {
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
let bytes = self
.file_system
.read_file(&params.path)
.read_file(&params.path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsReadFileResponse {
@@ -64,7 +64,7 @@ impl FsApi {
))
})?;
self.file_system
.write_file(&params.path, bytes)
.write_file(&params.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(&params.path)
.get_metadata(&params.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(&params.path)
.read_directory(&params.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)?;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),
})
}
}

View File

@@ -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!(

View File

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

View File

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

View File

@@ -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>(())
}),
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"],
)

View File

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

View File

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

View File

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

View File

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

View File

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

View 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(&params.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(&params.path, bytes, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsHelperPayload::WriteFile(FsWriteFileResponse {}))
}
FsHelperRequest::CreateDirectory(params) => {
file_system
.create_directory(
&params.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(&params.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(&params.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(
&params.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(
&params.source_path,
&params.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(())
}
}

View 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(())
}

View 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])
);
}
}

View File

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

View File

@@ -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(&note_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(&note_path, Some(&sandbox_policy), &sandbox_cwd)?;
let error = enforce_read_access_for_cwd(&note_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))]

View File

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

View File

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

View 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))
}

View 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),
}
}

View File

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

View File

@@ -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(&params.path, params.sandbox_policy.as_ref())
.read_file(&params.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(&params.path, bytes, params.sandbox_policy.as_ref())
.write_file(&params.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(
&params.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(&params.path, params.sandbox_policy.as_ref())
.get_metadata(&params.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(&params.path, params.sandbox_policy.as_ref())
.read_directory(&params.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(
&params.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(
&params.source_path,
&params.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"));
}
}
}

View File

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

View File

@@ -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(&registry),
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(&registry),
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
CREATE TABLE thread_timers (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
source TEXT NOT NULL,
client_id TEXT NOT NULL,
trigger_json TEXT NOT NULL,
prompt TEXT NOT NULL,
delivery TEXT NOT NULL,
created_at INTEGER NOT NULL,
next_run_at INTEGER,
last_run_at INTEGER,
pending_run INTEGER NOT NULL
);
CREATE INDEX idx_thread_timers_thread_created
ON thread_timers(thread_id, created_at, id);
CREATE INDEX idx_thread_timers_thread_pending
ON thread_timers(thread_id, pending_run, created_at, id);
CREATE INDEX idx_thread_timers_thread_next_run
ON thread_timers(thread_id, next_run_at);

View File

@@ -1,77 +0,0 @@
ALTER TABLE thread_timers RENAME TO thread_timers_old;
CREATE TABLE thread_timers (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
source TEXT NOT NULL,
client_id TEXT NOT NULL,
trigger_json TEXT NOT NULL,
content TEXT NOT NULL,
instructions TEXT,
meta_json TEXT NOT NULL,
delivery TEXT NOT NULL,
created_at INTEGER NOT NULL,
next_run_at INTEGER,
last_run_at INTEGER,
pending_run INTEGER NOT NULL
);
INSERT INTO thread_timers (
id,
thread_id,
source,
client_id,
trigger_json,
content,
instructions,
meta_json,
delivery,
created_at,
next_run_at,
last_run_at,
pending_run
)
SELECT
id,
thread_id,
source,
client_id,
trigger_json,
prompt,
NULL,
'{}',
delivery,
created_at,
next_run_at,
last_run_at,
pending_run
FROM thread_timers_old;
DROP TABLE thread_timers_old;
CREATE INDEX idx_thread_timers_thread_created
ON thread_timers(thread_id, created_at, id);
CREATE INDEX idx_thread_timers_thread_pending
ON thread_timers(thread_id, pending_run, created_at, id);
CREATE INDEX idx_thread_timers_thread_next_run
ON thread_timers(thread_id, next_run_at);
CREATE TABLE external_messages (
seq INTEGER PRIMARY KEY,
id TEXT NOT NULL UNIQUE,
thread_id TEXT NOT NULL,
source TEXT NOT NULL,
content TEXT NOT NULL,
instructions TEXT,
meta_json TEXT NOT NULL,
delivery TEXT NOT NULL,
queued_at INTEGER NOT NULL
);
CREATE INDEX external_messages_thread_order_idx
ON external_messages(thread_id, queued_at, seq);
CREATE INDEX external_messages_thread_delivery_order_idx
ON external_messages(thread_id, delivery, queued_at, seq);

View File

@@ -36,9 +36,6 @@ pub use model::BackfillState;
pub use model::BackfillStats;
pub use model::BackfillStatus;
pub use model::DirectionalThreadSpawnEdgeStatus;
pub use model::ExternalMessage;
pub use model::ExternalMessageClaim;
pub use model::ExternalMessageCreateParams;
pub use model::ExtractionOutcome;
pub use model::SortKey;
pub use model::Stage1JobClaim;
@@ -48,12 +45,8 @@ pub use model::Stage1OutputRef;
pub use model::Stage1StartupClaimParams;
pub use model::ThreadMetadata;
pub use model::ThreadMetadataBuilder;
pub use model::ThreadTimer;
pub use model::ThreadTimerCreateParams;
pub use model::ThreadTimerUpdateParams;
pub use model::ThreadsPage;
pub use runtime::RemoteControlEnrollmentRecord;
pub use runtime::TimerDataVersionChecker;
pub use runtime::logs_db_filename;
pub use runtime::logs_db_path;
pub use runtime::state_db_filename;

View File

@@ -1,85 +0,0 @@
use sqlx::FromRow;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExternalMessageCreateParams {
pub id: String,
pub thread_id: String,
pub source: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub queued_at: i64,
}
impl ExternalMessageCreateParams {
pub fn new(
thread_id: String,
source: String,
content: String,
instructions: Option<String>,
meta_json: String,
delivery: String,
queued_at: i64,
) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
thread_id,
source,
content,
instructions,
meta_json,
delivery,
queued_at,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExternalMessage {
pub seq: i64,
pub id: String,
pub thread_id: String,
pub source: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub queued_at: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ExternalMessageClaim {
Claimed(ExternalMessage),
Invalid { id: String, reason: String },
NotReady,
}
#[derive(Debug, FromRow)]
pub(crate) struct ExternalMessageRow {
pub seq: i64,
pub id: String,
pub thread_id: String,
pub source: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub queued_at: i64,
}
impl From<ExternalMessageRow> for ExternalMessage {
fn from(row: ExternalMessageRow) -> Self {
Self {
seq: row.seq,
id: row.id,
thread_id: row.thread_id,
source: row.source,
content: row.content,
instructions: row.instructions,
meta_json: row.meta_json,
delivery: row.delivery,
queued_at: row.queued_at,
}
}
}

View File

@@ -1,11 +1,9 @@
mod agent_job;
mod backfill_state;
mod external_message;
mod graph;
mod log;
mod memories;
mod thread_metadata;
mod thread_timer;
pub use agent_job::AgentJob;
pub use agent_job::AgentJobCreateParams;
@@ -16,9 +14,6 @@ pub use agent_job::AgentJobProgress;
pub use agent_job::AgentJobStatus;
pub use backfill_state::BackfillState;
pub use backfill_state::BackfillStatus;
pub use external_message::ExternalMessage;
pub use external_message::ExternalMessageClaim;
pub use external_message::ExternalMessageCreateParams;
pub use graph::DirectionalThreadSpawnEdgeStatus;
pub use log::LogEntry;
pub use log::LogQuery;
@@ -37,16 +32,11 @@ pub use thread_metadata::SortKey;
pub use thread_metadata::ThreadMetadata;
pub use thread_metadata::ThreadMetadataBuilder;
pub use thread_metadata::ThreadsPage;
pub use thread_timer::ThreadTimer;
pub use thread_timer::ThreadTimerCreateParams;
pub use thread_timer::ThreadTimerUpdateParams;
pub(crate) use agent_job::AgentJobItemRow;
pub(crate) use agent_job::AgentJobRow;
pub(crate) use external_message::ExternalMessageRow;
pub(crate) use memories::Stage1OutputRow;
pub(crate) use memories::stage1_output_ref_from_parts;
pub(crate) use thread_metadata::ThreadRow;
pub(crate) use thread_metadata::anchor_from_item;
pub(crate) use thread_metadata::datetime_to_epoch_seconds;
pub(crate) use thread_timer::ThreadTimerRow;

View File

@@ -1,84 +0,0 @@
use sqlx::FromRow;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ThreadTimerCreateParams {
pub id: String,
pub thread_id: String,
pub source: String,
pub client_id: String,
pub trigger_json: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub created_at: i64,
pub next_run_at: Option<i64>,
pub last_run_at: Option<i64>,
pub pending_run: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ThreadTimerUpdateParams {
pub trigger_json: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub next_run_at: Option<i64>,
pub last_run_at: Option<i64>,
pub pending_run: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ThreadTimer {
pub id: String,
pub thread_id: String,
pub source: String,
pub client_id: String,
pub trigger_json: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub created_at: i64,
pub next_run_at: Option<i64>,
pub last_run_at: Option<i64>,
pub pending_run: bool,
}
#[derive(Debug, FromRow)]
pub(crate) struct ThreadTimerRow {
pub id: String,
pub thread_id: String,
pub source: String,
pub client_id: String,
pub trigger_json: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub created_at: i64,
pub next_run_at: Option<i64>,
pub last_run_at: Option<i64>,
pub pending_run: i64,
}
impl From<ThreadTimerRow> for ThreadTimer {
fn from(row: ThreadTimerRow) -> Self {
Self {
id: row.id,
thread_id: row.thread_id,
source: row.source,
client_id: row.client_id,
trigger_json: row.trigger_json,
content: row.content,
instructions: row.instructions,
meta_json: row.meta_json,
delivery: row.delivery,
created_at: row.created_at,
next_run_at: row.next_run_at,
last_run_at: row.last_run_at,
pending_run: row.pending_run != 0,
}
}
}

View File

@@ -5,9 +5,6 @@ use crate::AgentJobItemCreateParams;
use crate::AgentJobItemStatus;
use crate::AgentJobProgress;
use crate::AgentJobStatus;
use crate::ExternalMessage;
use crate::ExternalMessageClaim;
use crate::ExternalMessageCreateParams;
use crate::LOGS_DB_FILENAME;
use crate::LOGS_DB_VERSION;
use crate::LogEntry;
@@ -18,9 +15,6 @@ use crate::STATE_DB_VERSION;
use crate::SortKey;
use crate::ThreadMetadata;
use crate::ThreadMetadataBuilder;
use crate::ThreadTimer;
use crate::ThreadTimerCreateParams;
use crate::ThreadTimerUpdateParams;
use crate::ThreadsPage;
use crate::apply_rollout_item;
use crate::migrations::runtime_logs_migrator;
@@ -58,18 +52,14 @@ use tracing::warn;
mod agent_jobs;
mod backfill;
mod delivery_state;
mod external_messages;
mod logs;
mod memories;
mod remote_control;
#[cfg(test)]
mod test_support;
mod threads;
mod timers;
pub use remote_control::RemoteControlEnrollmentRecord;
pub use timers::TimerDataVersionChecker;
// "Partition" is the retained-log-content bucket we cap at 10 MiB:
// - one bucket per non-null thread_id

View File

@@ -1,131 +0,0 @@
//! Cleanup operations for per-thread delivery state.
//!
//! Timers and queued external messages are stored independently because they have
//! different runtime behavior, but thread lifecycle operations need to treat
//! them as one unit. This module owns that cross-table cleanup.
use super::*;
impl StateRuntime {
/// Delete all queued external messages and timers associated with `thread_id`.
pub async fn delete_thread_delivery_state(&self, thread_id: &str) -> anyhow::Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM external_messages WHERE thread_id = ?")
.bind(thread_id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM thread_timers WHERE thread_id = ?")
.bind(thread_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::StateRuntime;
use super::test_support::unique_temp_dir;
use crate::ExternalMessageCreateParams;
use crate::ThreadTimerCreateParams;
use pretty_assertions::assert_eq;
fn message_params(id: &str, thread_id: &str) -> ExternalMessageCreateParams {
ExternalMessageCreateParams {
id: id.to_string(),
thread_id: thread_id.to_string(),
source: "external".to_string(),
content: "do something".to_string(),
instructions: None,
meta_json: "{}".to_string(),
delivery: "after-turn".to_string(),
queued_at: 100,
}
}
fn timer_params(id: &str, thread_id: &str) -> ThreadTimerCreateParams {
ThreadTimerCreateParams {
id: id.to_string(),
thread_id: thread_id.to_string(),
source: "agent".to_string(),
client_id: "codex-tui".to_string(),
trigger_json: r#"{"kind":"delay","seconds":10,"repeat":false}"#.to_string(),
content: "run tests".to_string(),
instructions: None,
meta_json: "{}".to_string(),
delivery: "after-turn".to_string(),
created_at: 100,
next_run_at: Some(110),
last_run_at: None,
pending_run: false,
}
}
async fn test_runtime() -> std::sync::Arc<StateRuntime> {
StateRuntime::init(unique_temp_dir(), "test-provider".to_string())
.await
.expect("initialize runtime")
}
#[tokio::test]
async fn delete_thread_delivery_state_removes_messages_and_timers_for_thread() {
let runtime = test_runtime().await;
runtime
.create_external_message(&message_params("message-1", "thread-1"))
.await
.expect("create thread-1 message");
runtime
.create_external_message(&message_params("message-2", "thread-2"))
.await
.expect("create thread-2 message");
runtime
.create_thread_timer(&timer_params("timer-1", "thread-1"))
.await
.expect("create thread-1 timer");
runtime
.create_thread_timer(&timer_params("timer-2", "thread-2"))
.await
.expect("create thread-2 timer");
runtime
.delete_thread_delivery_state("thread-1")
.await
.expect("delete delivery state");
assert_eq!(
runtime
.list_external_messages("thread-1")
.await
.expect("list thread-1 messages"),
Vec::new()
);
assert_eq!(
runtime
.list_thread_timers("thread-1")
.await
.expect("list thread-1 timers"),
Vec::new()
);
assert_eq!(
runtime
.list_external_messages("thread-2")
.await
.expect("list thread-2 messages")
.into_iter()
.map(|message| message.id)
.collect::<Vec<_>>(),
vec!["message-2".to_string()]
);
assert_eq!(
runtime
.list_thread_timers("thread-2")
.await
.expect("list thread-2 timers")
.into_iter()
.map(|timer| timer.id)
.collect::<Vec<_>>(),
vec!["timer-2".to_string()]
);
}
}

View File

@@ -1,443 +0,0 @@
//! SQLite-backed state operations for queued external messages.
//!
//! This module extends [`StateRuntime`] with the storage APIs used by message
//! producers and active threads. Claiming a message deletes the row inside the
//! same transaction, so competing runtimes deliver each queued message at most
//! once.
use super::*;
use crate::model::ExternalMessageRow;
const DELIVERY_AFTER_TURN: &str = "after-turn";
const DELIVERY_STEER_CURRENT_TURN: &str = "steer-current-turn";
impl StateRuntime {
pub async fn create_external_message(
&self,
params: &ExternalMessageCreateParams,
) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO external_messages (
id,
thread_id,
source,
content,
instructions,
meta_json,
delivery,
queued_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(params.id.as_str())
.bind(params.thread_id.as_str())
.bind(params.source.as_str())
.bind(params.content.as_str())
.bind(params.instructions.as_deref())
.bind(params.meta_json.as_str())
.bind(params.delivery.as_str())
.bind(params.queued_at)
.execute(self.pool.as_ref())
.await?;
Ok(())
}
pub async fn list_external_messages(
&self,
thread_id: &str,
) -> anyhow::Result<Vec<ExternalMessage>> {
let rows = sqlx::query_as::<_, ExternalMessageRow>(
r#"
SELECT
seq,
id,
thread_id,
source,
content,
instructions,
meta_json,
delivery,
queued_at
FROM external_messages
WHERE thread_id = ?
ORDER BY queued_at ASC, seq ASC
"#,
)
.bind(thread_id)
.fetch_all(self.pool.as_ref())
.await?;
Ok(rows.into_iter().map(ExternalMessage::from).collect())
}
pub async fn delete_external_message(&self, thread_id: &str, id: &str) -> anyhow::Result<bool> {
let result = sqlx::query("DELETE FROM external_messages WHERE thread_id = ? AND id = ?")
.bind(thread_id)
.bind(id)
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn claim_next_external_message(
&self,
thread_id: &str,
can_after_turn: bool,
can_steer_current_turn: bool,
) -> anyhow::Result<Option<ExternalMessageClaim>> {
let row = sqlx::query_as::<_, ExternalMessageRow>(
r#"
DELETE FROM external_messages
WHERE seq = (
SELECT seq
FROM external_messages
WHERE thread_id = ?
ORDER BY queued_at ASC, seq ASC
LIMIT 1
)
AND (
delivery NOT IN (?, ?)
OR (delivery = ? AND ?)
OR (delivery = ? AND ?)
)
RETURNING
seq,
id,
thread_id,
source,
content,
instructions,
meta_json,
delivery,
queued_at
"#,
)
.bind(thread_id)
.bind(DELIVERY_AFTER_TURN)
.bind(DELIVERY_STEER_CURRENT_TURN)
.bind(DELIVERY_AFTER_TURN)
.bind(can_after_turn)
.bind(DELIVERY_STEER_CURRENT_TURN)
.bind(can_steer_current_turn || can_after_turn)
.fetch_optional(self.pool.as_ref())
.await?;
if let Some(row) = row {
return match row.delivery.as_str() {
DELIVERY_AFTER_TURN | DELIVERY_STEER_CURRENT_TURN => Ok(Some(
ExternalMessageClaim::Claimed(ExternalMessage::from(row)),
)),
delivery => Ok(Some(ExternalMessageClaim::Invalid {
id: row.id,
reason: format!("invalid delivery `{delivery}`"),
})),
};
}
let oldest_delivery = sqlx::query_scalar::<_, String>(
r#"
SELECT delivery
FROM external_messages
WHERE thread_id = ?
ORDER BY queued_at ASC, seq ASC
LIMIT 1
"#,
)
.bind(thread_id)
.fetch_optional(self.pool.as_ref())
.await?;
match oldest_delivery.as_deref() {
Some(DELIVERY_AFTER_TURN) if !can_after_turn => {
Ok(Some(ExternalMessageClaim::NotReady))
}
Some(DELIVERY_STEER_CURRENT_TURN) if !(can_steer_current_turn || can_after_turn) => {
Ok(Some(ExternalMessageClaim::NotReady))
}
None | Some(_) => Ok(None),
}
}
}
#[cfg(test)]
mod tests {
use super::StateRuntime;
use super::test_support::unique_temp_dir;
use crate::ExternalMessageClaim;
use crate::ExternalMessageCreateParams;
use pretty_assertions::assert_eq;
fn message_params(id: &str, thread_id: &str, queued_at: i64) -> ExternalMessageCreateParams {
ExternalMessageCreateParams {
id: id.to_string(),
thread_id: thread_id.to_string(),
source: "external".to_string(),
content: "do something".to_string(),
instructions: Some("be concise".to_string()),
meta_json: r#"{"ticket":"ABC_123"}"#.to_string(),
delivery: "after-turn".to_string(),
queued_at,
}
}
async fn test_runtime() -> std::sync::Arc<StateRuntime> {
StateRuntime::init(unique_temp_dir(), "test-provider".to_string())
.await
.expect("initialize runtime")
}
#[tokio::test]
async fn external_messages_table_and_indexes_exist() {
let runtime = test_runtime().await;
let names = sqlx::query_scalar::<_, String>(
r#"
SELECT name
FROM sqlite_master
WHERE tbl_name = 'external_messages'
AND name NOT LIKE 'sqlite_autoindex_%'
ORDER BY name
"#,
)
.fetch_all(runtime.pool.as_ref())
.await
.expect("query schema objects");
assert_eq!(
names,
vec![
"external_messages",
"external_messages_thread_delivery_order_idx",
"external_messages_thread_order_idx",
]
);
}
#[tokio::test]
async fn external_message_rows_round_trip() {
let runtime = test_runtime().await;
let params = message_params("message-1", "thread-1", /*queued_at*/ 100);
runtime
.create_external_message(&params)
.await
.expect("create message");
let messages = runtime
.list_external_messages("thread-1")
.await
.expect("list messages");
assert_eq!(messages.len(), 1);
let message = &messages[0];
assert_eq!(message.id, params.id);
assert_eq!(message.thread_id, params.thread_id);
assert_eq!(message.source, params.source);
assert_eq!(message.content, params.content);
assert_eq!(message.instructions, params.instructions);
assert_eq!(message.meta_json, params.meta_json);
assert_eq!(message.delivery, params.delivery);
assert_eq!(message.queued_at, params.queued_at);
}
#[tokio::test]
async fn delete_external_message_is_scoped_to_thread_id() {
let runtime = test_runtime().await;
runtime
.create_external_message(&message_params(
"message-1",
"thread-1",
/*queued_at*/ 100,
))
.await
.expect("create thread-1 message");
runtime
.create_external_message(&message_params(
"message-2",
"thread-2",
/*queued_at*/ 100,
))
.await
.expect("create thread-2 message");
let deleted_wrong_thread = runtime
.delete_external_message("thread-2", "message-1")
.await
.expect("delete wrong-external message");
assert!(!deleted_wrong_thread);
let deleted = runtime
.delete_external_message("thread-1", "message-1")
.await
.expect("delete thread-1 message");
assert!(deleted);
assert_eq!(
runtime
.list_external_messages("thread-1")
.await
.expect("list thread-1 messages"),
Vec::new()
);
assert_eq!(
runtime
.list_external_messages("thread-2")
.await
.expect("list thread-2 messages")
.into_iter()
.map(|message| message.id)
.collect::<Vec<_>>(),
vec!["message-2".to_string()]
);
}
#[tokio::test]
async fn claim_is_scoped_to_thread_id_and_ordered() {
let runtime = test_runtime().await;
runtime
.create_external_message(&message_params("newer", "thread-1", /*queued_at*/ 200))
.await
.expect("create newer message");
runtime
.create_external_message(&message_params(
"other-thread",
"thread-2",
/*queued_at*/ 50,
))
.await
.expect("create other external message");
runtime
.create_external_message(&message_params("older", "thread-1", /*queued_at*/ 100))
.await
.expect("create older message");
let claim = runtime
.claim_next_external_message(
"thread-1", /*can_after_turn*/ true, /*can_steer_current_turn*/ true,
)
.await
.expect("claim message");
let Some(ExternalMessageClaim::Claimed(claimed)) = claim else {
panic!("expected claimed message");
};
assert_eq!(claimed.id, "older");
assert_eq!(claimed.thread_id, "thread-1");
assert_eq!(claimed.queued_at, 100);
assert_eq!(
runtime
.list_external_messages("thread-1")
.await
.expect("list remaining thread-1 messages")
.into_iter()
.map(|message| message.id)
.collect::<Vec<_>>(),
vec!["newer".to_string()]
);
assert_eq!(
runtime
.list_external_messages("thread-2")
.await
.expect("list thread-2 messages")
.into_iter()
.map(|message| message.id)
.collect::<Vec<_>>(),
vec!["other-thread".to_string()]
);
}
#[tokio::test]
async fn claim_consumes_message_once() {
let runtime = test_runtime().await;
runtime
.create_external_message(&message_params(
"message-1",
"thread-1",
/*queued_at*/ 100,
))
.await
.expect("create message");
assert!(matches!(
runtime
.claim_next_external_message(
"thread-1", /*can_after_turn*/ true, /*can_steer_current_turn*/ true,
)
.await
.expect("claim message"),
Some(ExternalMessageClaim::Claimed(_))
));
assert_eq!(
runtime
.claim_next_external_message(
"thread-1", /*can_after_turn*/ true, /*can_steer_current_turn*/ true,
)
.await
.expect("claim message again"),
None
);
}
#[tokio::test]
async fn oldest_unclaimable_message_blocks_later_messages() {
let runtime = test_runtime().await;
let mut steer = message_params("steer", "thread-1", /*queued_at*/ 100);
steer.delivery = "steer-current-turn".to_string();
runtime
.create_external_message(&steer)
.await
.expect("create steer message");
runtime
.create_external_message(&message_params("after", "thread-1", /*queued_at*/ 200))
.await
.expect("create after-turn message");
assert_eq!(
runtime
.claim_next_external_message(
"thread-1", /*can_after_turn*/ false,
/*can_steer_current_turn*/ false,
)
.await
.expect("claim message"),
Some(ExternalMessageClaim::NotReady)
);
assert_eq!(
runtime
.list_external_messages("thread-1")
.await
.expect("list messages")
.into_iter()
.map(|message| message.id)
.collect::<Vec<_>>(),
vec!["steer".to_string(), "after".to_string()]
);
}
#[tokio::test]
async fn invalid_delivery_is_deleted_without_claiming() {
let runtime = test_runtime().await;
let mut params = message_params("bad", "thread-1", /*queued_at*/ 100);
params.delivery = "bad-delivery".to_string();
runtime
.create_external_message(&params)
.await
.expect("create message");
assert_eq!(
runtime
.claim_next_external_message(
"thread-1", /*can_after_turn*/ true, /*can_steer_current_turn*/ true,
)
.await
.expect("claim message"),
Some(ExternalMessageClaim::Invalid {
id: "bad".to_string(),
reason: "invalid delivery `bad-delivery`".to_string(),
})
);
assert!(
runtime
.list_external_messages("thread-1")
.await
.expect("list messages")
.is_empty()
);
}
}

View File

@@ -1,730 +0,0 @@
//! SQLite-backed state operations for per-thread timers.
//!
//! This module extends [`StateRuntime`] with timer CRUD, due-state updates, and
//! atomic pending-run claims. It also exposes a lightweight `PRAGMA
//! data_version` checker so active threads can notice cross-process timer
//! changes without constantly reconciling full timer rows.
use super::*;
use crate::model::ThreadTimerRow;
use tokio::sync::Mutex;
pub struct TimerDataVersionChecker {
conn: Mutex<SqliteConnection>,
}
impl TimerDataVersionChecker {
pub async fn data_version(&self) -> anyhow::Result<i64> {
let mut conn = self.conn.lock().await;
let version = sqlx::query_scalar::<_, i64>("PRAGMA data_version")
.fetch_one(&mut *conn)
.await?;
Ok(version)
}
}
impl StateRuntime {
pub async fn timer_data_version_checker(&self) -> anyhow::Result<TimerDataVersionChecker> {
let state_path = state_db_path(self.codex_home());
let options = base_sqlite_options(state_path.as_path());
let conn = options.connect().await?;
Ok(TimerDataVersionChecker {
conn: Mutex::new(conn),
})
}
pub async fn create_thread_timer(
&self,
params: &ThreadTimerCreateParams,
) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO thread_timers (
id,
thread_id,
source,
client_id,
trigger_json,
content,
instructions,
meta_json,
delivery,
created_at,
next_run_at,
last_run_at,
pending_run
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(params.id.as_str())
.bind(params.thread_id.as_str())
.bind(params.source.as_str())
.bind(params.client_id.as_str())
.bind(params.trigger_json.as_str())
.bind(params.content.as_str())
.bind(params.instructions.as_deref())
.bind(params.meta_json.as_str())
.bind(params.delivery.as_str())
.bind(params.created_at)
.bind(params.next_run_at)
.bind(params.last_run_at)
.bind(i64::from(params.pending_run))
.execute(self.pool.as_ref())
.await?;
Ok(())
}
pub async fn create_thread_timer_if_below_limit(
&self,
params: &ThreadTimerCreateParams,
max_thread_timers: usize,
) -> anyhow::Result<bool> {
let max_thread_timers = i64::try_from(max_thread_timers)?;
let result = sqlx::query(
r#"
INSERT INTO thread_timers (
id,
thread_id,
source,
client_id,
trigger_json,
content,
instructions,
meta_json,
delivery,
created_at,
next_run_at,
last_run_at,
pending_run
)
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
WHERE (
SELECT COUNT(*)
FROM thread_timers
WHERE thread_id = ?
) < ?
"#,
)
.bind(params.id.as_str())
.bind(params.thread_id.as_str())
.bind(params.source.as_str())
.bind(params.client_id.as_str())
.bind(params.trigger_json.as_str())
.bind(params.content.as_str())
.bind(params.instructions.as_deref())
.bind(params.meta_json.as_str())
.bind(params.delivery.as_str())
.bind(params.created_at)
.bind(params.next_run_at)
.bind(params.last_run_at)
.bind(i64::from(params.pending_run))
.bind(params.thread_id.as_str())
.bind(max_thread_timers)
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn list_thread_timers(&self, thread_id: &str) -> anyhow::Result<Vec<ThreadTimer>> {
let rows = sqlx::query_as::<_, ThreadTimerRow>(
r#"
SELECT
id,
thread_id,
source,
client_id,
trigger_json,
content,
instructions,
meta_json,
delivery,
created_at,
next_run_at,
last_run_at,
pending_run
FROM thread_timers
WHERE thread_id = ?
ORDER BY created_at ASC, id ASC
"#,
)
.bind(thread_id)
.fetch_all(self.pool.as_ref())
.await?;
Ok(rows.into_iter().map(ThreadTimer::from).collect())
}
pub async fn delete_thread_timer(&self, thread_id: &str, id: &str) -> anyhow::Result<bool> {
let result = sqlx::query("DELETE FROM thread_timers WHERE thread_id = ? AND id = ?")
.bind(thread_id)
.bind(id)
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn update_thread_timer_due(
&self,
thread_id: &str,
id: &str,
due_at: i64,
next_run_at: Option<i64>,
) -> anyhow::Result<bool> {
let result = sqlx::query(
r#"
UPDATE thread_timers
SET pending_run = 1,
next_run_at = ?
WHERE thread_id = ?
AND id = ?
AND pending_run = 0
AND next_run_at IS NOT NULL
AND next_run_at <= ?
"#,
)
.bind(next_run_at)
.bind(thread_id)
.bind(id)
.bind(due_at)
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn claim_one_shot_thread_timer(
&self,
thread_id: &str,
id: &str,
due_at: i64,
) -> anyhow::Result<bool> {
let result = sqlx::query(
r#"
DELETE FROM thread_timers
WHERE thread_id = ?
AND id = ?
AND (
pending_run = 1
OR (
pending_run = 0
AND next_run_at IS NOT NULL
AND next_run_at <= ?
)
)
"#,
)
.bind(thread_id)
.bind(id)
.bind(due_at)
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn claim_recurring_thread_timer(
&self,
thread_id: &str,
id: &str,
due_at: i64,
expected_last_run_at: Option<i64>,
params: &ThreadTimerUpdateParams,
) -> anyhow::Result<bool> {
let result = sqlx::query(
r#"
UPDATE thread_timers
SET trigger_json = ?,
content = ?,
instructions = ?,
meta_json = ?,
delivery = ?,
next_run_at = ?,
last_run_at = ?,
pending_run = ?
WHERE thread_id = ?
AND id = ?
AND (
pending_run = 1
OR (
pending_run = 0
AND next_run_at IS NOT NULL
AND next_run_at <= ?
)
)
AND (
(last_run_at IS NULL AND ? IS NULL)
OR last_run_at = ?
)
"#,
)
.bind(params.trigger_json.as_str())
.bind(params.content.as_str())
.bind(params.instructions.as_deref())
.bind(params.meta_json.as_str())
.bind(params.delivery.as_str())
.bind(params.next_run_at)
.bind(params.last_run_at)
.bind(i64::from(params.pending_run))
.bind(thread_id)
.bind(id)
.bind(due_at)
.bind(expected_last_run_at)
.bind(expected_last_run_at)
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
}
#[cfg(test)]
mod tests {
use super::StateRuntime;
use super::test_support::unique_temp_dir;
use crate::ThreadTimer;
use crate::ThreadTimerCreateParams;
use crate::ThreadTimerUpdateParams;
use pretty_assertions::assert_eq;
fn timer_params(id: &str, thread_id: &str) -> ThreadTimerCreateParams {
ThreadTimerCreateParams {
id: id.to_string(),
thread_id: thread_id.to_string(),
source: "agent".to_string(),
client_id: "codex-tui".to_string(),
trigger_json: r#"{"kind":"delay","seconds":10,"repeat":true}"#.to_string(),
content: "run tests".to_string(),
instructions: Some("keep output brief".to_string()),
meta_json: r#"{"ticket":"ABC_123"}"#.to_string(),
delivery: "after-turn".to_string(),
created_at: 100,
next_run_at: Some(110),
last_run_at: None,
pending_run: false,
}
}
async fn test_runtime() -> std::sync::Arc<StateRuntime> {
StateRuntime::init(unique_temp_dir(), "test-provider".to_string())
.await
.expect("initialize runtime")
}
#[tokio::test]
async fn thread_timers_table_and_indexes_exist() {
let runtime = test_runtime().await;
let names = sqlx::query_scalar::<_, String>(
r#"
SELECT name
FROM sqlite_master
WHERE tbl_name = 'thread_timers'
AND name NOT LIKE 'sqlite_autoindex_%'
ORDER BY name
"#,
)
.fetch_all(runtime.pool.as_ref())
.await
.expect("query schema objects");
assert_eq!(
names,
vec![
"idx_thread_timers_thread_created",
"idx_thread_timers_thread_next_run",
"idx_thread_timers_thread_pending",
"thread_timers",
]
);
}
#[tokio::test]
async fn thread_timer_rows_round_trip_source_and_client_metadata() {
let runtime = test_runtime().await;
let mut params = timer_params("timer-1", "thread-1");
params.pending_run = true;
params.last_run_at = Some(105);
runtime
.create_thread_timer(&params)
.await
.expect("create timer");
let timers = runtime
.list_thread_timers("thread-1")
.await
.expect("list timers");
assert_eq!(timers.len(), 1);
let timer = &timers[0];
assert_eq!(timer.id, params.id);
assert_eq!(timer.thread_id, params.thread_id);
assert_eq!(timer.source, params.source);
assert_eq!(timer.client_id, params.client_id);
assert_eq!(timer.trigger_json, params.trigger_json);
assert_eq!(timer.content, params.content);
assert_eq!(timer.instructions, params.instructions);
assert_eq!(timer.meta_json, params.meta_json);
assert_eq!(timer.delivery, params.delivery);
assert_eq!(timer.created_at, params.created_at);
assert_eq!(timer.next_run_at, params.next_run_at);
assert_eq!(timer.last_run_at, params.last_run_at);
assert_eq!(timer.pending_run, params.pending_run);
}
#[tokio::test]
async fn thread_timer_crud_is_scoped_to_thread_id() {
let runtime = test_runtime().await;
runtime
.create_thread_timer(&timer_params("timer-1", "thread-1"))
.await
.expect("create thread-1 timer");
runtime
.create_thread_timer(&timer_params("timer-2", "thread-2"))
.await
.expect("create thread-2 timer");
assert_eq!(
runtime
.list_thread_timers("thread-1")
.await
.expect("list thread-1 timers")
.into_iter()
.map(|timer| timer.id)
.collect::<Vec<_>>(),
vec!["timer-1".to_string()]
);
assert!(
!runtime
.delete_thread_timer("thread-1", "timer-2")
.await
.expect("delete wrong thread timer")
);
assert!(
runtime
.delete_thread_timer("thread-2", "timer-2")
.await
.expect("delete correct thread timer")
);
}
#[tokio::test]
async fn create_thread_timer_if_below_limit_rejects_full_thread() {
let runtime = test_runtime().await;
assert!(
runtime
.create_thread_timer_if_below_limit(
&timer_params("timer-1", "thread-1"),
/*max_thread_timers*/ 2,
)
.await
.expect("create first timer")
);
assert!(
runtime
.create_thread_timer_if_below_limit(
&timer_params("timer-2", "thread-1"),
/*max_thread_timers*/ 2,
)
.await
.expect("create second timer")
);
assert!(
!runtime
.create_thread_timer_if_below_limit(
&timer_params("timer-3", "thread-1"),
/*max_thread_timers*/ 2,
)
.await
.expect("reject third timer")
);
assert!(
runtime
.create_thread_timer_if_below_limit(
&timer_params("timer-4", "thread-2"),
/*max_thread_timers*/ 2,
)
.await
.expect("create timer for different thread")
);
assert_eq!(
runtime
.list_thread_timers("thread-1")
.await
.expect("list thread-1 timers")
.into_iter()
.map(|timer| timer.id)
.collect::<Vec<_>>(),
vec!["timer-1".to_string(), "timer-2".to_string()]
);
assert_eq!(
runtime
.list_thread_timers("thread-2")
.await
.expect("list thread-2 timers")
.into_iter()
.map(|timer| timer.id)
.collect::<Vec<_>>(),
vec!["timer-4".to_string()]
);
}
#[tokio::test]
async fn one_shot_claim_consumes_pending_timer_once() {
let runtime = test_runtime().await;
let mut params = timer_params("timer-1", "thread-1");
params.pending_run = true;
params.next_run_at = None;
runtime
.create_thread_timer(&params)
.await
.expect("create pending timer");
assert!(
runtime
.claim_one_shot_thread_timer("thread-1", "timer-1", /*due_at*/ 110)
.await
.expect("claim timer")
);
assert!(
!runtime
.claim_one_shot_thread_timer("thread-1", "timer-1", /*due_at*/ 110)
.await
.expect("claim timer again")
);
assert!(
runtime
.list_thread_timers("thread-1")
.await
.expect("list timers")
.is_empty()
);
}
#[tokio::test]
async fn recurring_claim_updates_pending_timer_once() {
let runtime = test_runtime().await;
let mut params = timer_params("timer-1", "thread-1");
params.pending_run = true;
runtime
.create_thread_timer(&params)
.await
.expect("create pending timer");
let update = ThreadTimerUpdateParams {
trigger_json: params.trigger_json.clone(),
content: "updated content".to_string(),
instructions: None,
meta_json: "{}".to_string(),
delivery: "steer-current-turn".to_string(),
next_run_at: Some(120),
last_run_at: Some(110),
pending_run: false,
};
assert!(
runtime
.claim_recurring_thread_timer(
"thread-1", "timer-1", /*due_at*/ 110, /*expected_last_run_at*/ None,
&update,
)
.await
.expect("claim recurring timer")
);
assert!(
!runtime
.claim_recurring_thread_timer(
"thread-1", "timer-1", /*due_at*/ 110, /*expected_last_run_at*/ None,
&update,
)
.await
.expect("claim recurring timer again")
);
let timers = runtime
.list_thread_timers("thread-1")
.await
.expect("list timers");
assert_eq!(timers.len(), 1);
assert_eq!(timers[0].delivery, "steer-current-turn");
assert_eq!(timers[0].content, "updated content");
assert_eq!(timers[0].instructions, None);
assert_eq!(timers[0].meta_json, "{}");
assert_eq!(timers[0].next_run_at, Some(120));
assert_eq!(timers[0].last_run_at, Some(110));
assert!(!timers[0].pending_run);
}
#[tokio::test]
async fn one_shot_claim_consumes_overdue_timer_after_restart() {
let runtime = test_runtime().await;
let mut params = timer_params("timer-1", "thread-1");
params.trigger_json = r#"{"kind":"delay","seconds":10,"repeat":false}"#.to_string();
params.next_run_at = Some(110);
params.pending_run = false;
runtime
.create_thread_timer(&params)
.await
.expect("create overdue one-shot timer");
assert!(
runtime
.claim_one_shot_thread_timer("thread-1", "timer-1", /*due_at*/ 110)
.await
.expect("claim overdue one-shot timer")
);
assert!(
runtime
.list_thread_timers("thread-1")
.await
.expect("list timers")
.is_empty()
);
}
#[tokio::test]
async fn recurring_claim_consumes_overdue_timer_after_restart() {
let runtime = test_runtime().await;
let mut params = timer_params("timer-1", "thread-1");
params.next_run_at = Some(110);
params.pending_run = false;
runtime
.create_thread_timer(&params)
.await
.expect("create overdue recurring timer");
let update = ThreadTimerUpdateParams {
trigger_json: params.trigger_json.clone(),
content: params.content.clone(),
instructions: params.instructions.clone(),
meta_json: params.meta_json.clone(),
delivery: params.delivery.clone(),
next_run_at: Some(120),
last_run_at: Some(110),
pending_run: false,
};
assert!(
runtime
.claim_recurring_thread_timer(
"thread-1", "timer-1", /*due_at*/ 110, /*expected_last_run_at*/ None,
&update,
)
.await
.expect("claim overdue recurring timer")
);
let timers = runtime
.list_thread_timers("thread-1")
.await
.expect("list timers");
assert_eq!(timers.len(), 1);
assert_eq!(timers[0].next_run_at, Some(120));
assert_eq!(timers[0].last_run_at, Some(110));
assert!(!timers[0].pending_run);
}
#[tokio::test]
async fn due_update_rejects_stale_timer_row_after_claim() {
let runtime = test_runtime().await;
let mut params = timer_params("timer-1", "thread-1");
params.next_run_at = Some(110);
params.pending_run = false;
runtime
.create_thread_timer(&params)
.await
.expect("create overdue recurring timer");
let update = ThreadTimerUpdateParams {
trigger_json: params.trigger_json.clone(),
content: params.content.clone(),
instructions: params.instructions.clone(),
meta_json: params.meta_json.clone(),
delivery: params.delivery.clone(),
next_run_at: Some(120),
last_run_at: Some(110),
pending_run: false,
};
assert!(
runtime
.claim_recurring_thread_timer(
"thread-1", "timer-1", /*due_at*/ 110, /*expected_last_run_at*/ None,
&update,
)
.await
.expect("claim overdue recurring timer")
);
assert!(
!runtime
.update_thread_timer_due("thread-1", "timer-1", /*due_at*/ 110, Some(130))
.await
.expect("stale due update should be rejected")
);
assert_eq!(
runtime
.list_thread_timers("thread-1")
.await
.expect("list timers"),
vec![ThreadTimer {
id: params.id,
thread_id: params.thread_id,
source: params.source,
client_id: params.client_id,
trigger_json: params.trigger_json,
content: params.content,
instructions: params.instructions,
meta_json: params.meta_json,
delivery: params.delivery,
created_at: params.created_at,
next_run_at: Some(120),
last_run_at: Some(110),
pending_run: false,
}]
);
}
#[tokio::test]
async fn recurring_idle_claim_rejects_stale_last_run_at_even_when_pending_stays_true() {
let runtime = test_runtime().await;
let mut params = timer_params("timer-1", "thread-1");
params.pending_run = true;
params.last_run_at = Some(100);
runtime
.create_thread_timer(&params)
.await
.expect("create pending timer");
let update = ThreadTimerUpdateParams {
trigger_json: params.trigger_json.clone(),
content: params.content.clone(),
instructions: params.instructions.clone(),
meta_json: params.meta_json.clone(),
delivery: params.delivery.clone(),
next_run_at: Some(120),
last_run_at: Some(110),
pending_run: true,
};
assert!(
runtime
.claim_recurring_thread_timer(
"thread-1",
"timer-1",
/*due_at*/ 110,
/*expected_last_run_at*/ Some(100),
&update,
)
.await
.expect("claim recurring idle timer")
);
assert!(
!runtime
.claim_recurring_thread_timer(
"thread-1",
"timer-1",
/*due_at*/ 110,
/*expected_last_run_at*/ Some(100),
&update,
)
.await
.expect("claim recurring idle timer again")
);
let timers = runtime
.list_thread_timers("thread-1")
.await
.expect("list timers");
assert_eq!(timers.len(), 1);
assert_eq!(timers[0].last_run_at, Some(110));
assert!(timers[0].pending_run);
}
}

View File

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

View File

@@ -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 "$@"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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