Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::*;
use codex_core::exec_env::inject_apply_patch_env;
use codex_protocol::shell_environment::is_non_inheritable_env_var;

#[derive(Clone)]
Expand Down Expand Up @@ -165,6 +166,7 @@ impl CommandExecRequestProcessor {
}
}
env.retain(|name, _| !is_non_inheritable_env_var(name));
inject_apply_patch_env(&mut env, &self.config.features);
let timeout_ms = match timeout_ms {
Some(timeout_ms) => match u64::try_from(timeout_ms) {
Ok(timeout_ms) => Some(timeout_ms),
Expand Down
85 changes: 85 additions & 0 deletions codex-rs/app-server/tests/suite/v2/command_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxPolicy;
use codex_core::exec_env::CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR;
use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::shell_environment::OPENAI_FEDERATION_RULE_ID_ENV_VAR;
Expand Down Expand Up @@ -198,6 +199,90 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un
Ok(())
}

#[derive(Clone, Copy)]
enum CommandExecApplyPatchRollout {
Enabled,
Disabled,
}

#[tokio::test]
async fn command_exec_apply_patch_preserves_line_endings_despite_client_override() -> Result<()> {
assert_command_exec_apply_patch_rollout(
CommandExecApplyPatchRollout::Enabled,
"0",
b"after\r\n",
)
.await
}

#[tokio::test]
async fn command_exec_apply_patch_normalizes_line_endings_despite_stale_overrides() -> Result<()> {
assert_command_exec_apply_patch_rollout(CommandExecApplyPatchRollout::Disabled, "1", b"after\n")
.await
}

async fn assert_command_exec_apply_patch_rollout(
rollout: CommandExecApplyPatchRollout,
client_override: &str,
expected_contents: &[u8],
) -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;

let feature_enabled = matches!(rollout, CommandExecApplyPatchRollout::Enabled);
insert_command_exec_config(
codex_home.path(),
&format!("[features]\napply_patch_preserve_line_endings = {feature_enabled}\n"),
)?;

let workspace = TempDir::new()?;
let file_path = workspace.path().join("crlf.txt");
std::fs::write(&file_path, b"before\r\n")?;

let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[(CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR, Some("1"))])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;

let patch = "*** Begin Patch\n*** Update File: crlf.txt\n@@\n-before\n+after\n*** End Patch\n";
let request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec!["apply_patch".to_string(), patch.to_string()],
process_id: None,
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: Some(workspace.path().to_path_buf()),
env: Some(HashMap::from([(
CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR.to_string(),
Some(client_override.to_string()),
)])),
size: None,
sandbox_policy: Some(SandboxPolicy::DangerFullAccess),
permission_profile: None,
})
.await?;

let response: CommandExecResponse = mcp.read_response(request_id).await?;
assert_eq!(
response,
CommandExecResponse {
exit_code: 0,
stdout: "Success. Updated the following files:\nM crlf.txt\n".to_string(),
stderr: String::new(),
}
);
assert_eq!(std::fs::read(file_path)?, expected_contents);
Ok(())
}

#[tokio::test]
async fn command_exec_accepts_permission_profile() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
Expand Down
27 changes: 16 additions & 11 deletions codex-rs/apply-patch/tests/suite/scenarios.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use anyhow::Context;
use codex_apply_patch::CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR;
use codex_utils_cargo_bin::repo_root;
use codex_utils_cargo_bin::find_resource;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::fs;
Expand All @@ -10,17 +11,18 @@ use tempfile::tempdir;

#[test]
fn test_apply_patch_scenarios() -> anyhow::Result<()> {
let scenarios_dir = repo_root()?
.join("codex-rs")
.join("apply-patch")
.join("tests")
.join("fixtures")
.join("scenarios");
for scenario in fs::read_dir(scenarios_dir)? {
let scenarios_marker = find_resource!("tests/fixtures/scenarios/.gitattributes")?;
let scenarios_dir = scenarios_marker
.parent()
.context("scenario marker should have a parent directory")?;
for scenario in fs::read_dir(scenarios_dir)
.with_context(|| format!("failed to read {}", scenarios_dir.display()))?
{
let scenario = scenario?;
let path = scenario.path();
if path.is_dir() {
run_apply_patch_scenario(&path)?;
run_apply_patch_scenario(&path)
.with_context(|| format!("failed to run scenario {}", path.display()))?;
}
}
Ok(())
Expand All @@ -38,7 +40,9 @@ fn run_apply_patch_scenario(dir: &Path) -> anyhow::Result<()> {
}

// Read the patch.txt file
let patch = fs::read_to_string(dir.join("patch.txt"))?;
let patch_path = dir.join("patch.txt");
let patch = fs::read_to_string(&patch_path)
.with_context(|| format!("failed to read {}", patch_path.display()))?;

// Run apply_patch in the temporary directory. We intentionally do not assert
// on the exit status here; the scenarios are specified purely in terms of
Expand All @@ -47,7 +51,8 @@ fn run_apply_patch_scenario(dir: &Path) -> anyhow::Result<()> {
.arg(patch)
.env(CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR, "1")
.current_dir(tmp.path())
.output()?;
.output()
.with_context(|| format!("failed to run scenario {}", dir.display()))?;

// Assert that the final state matches the expected state exactly
let expected_dir = dir.join("expected");
Expand Down
Loading
Loading