feat: use DotSlash to fetch zsh fork for tests

This commit is contained in:
Michael Bolin
2026-02-22 15:45:11 -08:00
parent 0a0caa9df2
commit ad081f7f93
3 changed files with 267 additions and 89 deletions

View File

@@ -2,13 +2,9 @@
//
// Running these tests with the patched zsh fork:
//
// The suite uses `CODEX_TEST_ZSH_PATH` when set. Example:
// CODEX_TEST_ZSH_PATH="$HOME/.local/codex-zsh-77045ef/bin/zsh" \
// cargo test -p codex-app-server turn_start_zsh_fork -- --nocapture
//
// For a single test:
// CODEX_TEST_ZSH_PATH="$HOME/.local/codex-zsh-77045ef/bin/zsh" \
// cargo test -p codex-app-server turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2 -- --nocapture
// The suite resolves the shared test-only zsh DotSlash file at
// `exec-server/tests/suite/zsh` via DotSlash on first use, so `dotslash` and
// network access are required the first time the artifact is fetched.
use anyhow::Result;
use app_test_support::McpProcess;
@@ -38,6 +34,7 @@ use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -57,7 +54,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace)?;
let Some(zsh_path) = find_test_zsh_path() else {
let Some(zsh_path) = find_test_zsh_path()? else {
eprintln!("skipping zsh fork test: no zsh executable found");
return Ok(());
};
@@ -82,7 +79,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
&zsh_path,
)?;
let mut mcp = McpProcess::new(&codex_home).await?;
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
@@ -167,7 +164,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace)?;
let Some(zsh_path) = find_test_zsh_path() else {
let Some(zsh_path) = find_test_zsh_path()? else {
eprintln!("skipping zsh fork decline test: no zsh executable found");
return Ok(());
};
@@ -199,7 +196,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
&zsh_path,
)?;
let mut mcp = McpProcess::new(&codex_home).await?;
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
@@ -303,7 +300,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace)?;
let Some(zsh_path) = find_test_zsh_path() else {
let Some(zsh_path) = find_test_zsh_path()? else {
eprintln!("skipping zsh fork cancel test: no zsh executable found");
return Ok(());
};
@@ -332,7 +329,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
&zsh_path,
)?;
let mut mcp = McpProcess::new(&codex_home).await?;
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
@@ -434,7 +431,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace)?;
let Some(zsh_path) = find_test_zsh_path() else {
let Some(zsh_path) = find_test_zsh_path()? else {
eprintln!("skipping zsh fork subcommand decline test: no zsh executable found");
return Ok(());
};
@@ -446,6 +443,17 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
return Ok(());
}
eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display());
let zsh_path_for_config = {
let path = workspace.join("zsh-no-rc");
std::fs::write(
&path,
format!("#!/bin/sh\nexec \"{}\" -df \"$@\"\n", zsh_path.display()),
)?;
let mut permissions = std::fs::metadata(&path)?.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&path, permissions)?;
path
};
let tool_call_arguments = serde_json::to_string(&serde_json::json!({
"command": "/usr/bin/true && /usr/bin/true",
@@ -471,10 +479,10 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
(Feature::UnifiedExec, false),
(Feature::ShellSnapshot, false),
]),
&zsh_path,
&zsh_path_for_config,
)?;
let mut mcp = McpProcess::new(&codex_home).await?;
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
@@ -517,10 +525,12 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
let mut approval_ids = Vec::new();
for decision in [
let target_decisions = [
CommandExecutionApprovalDecision::Accept,
CommandExecutionApprovalDecision::Cancel,
] {
];
let mut target_decision_index = 0;
while target_decision_index < target_decisions.len() {
let server_req = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
@@ -531,13 +541,26 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
panic!("expected CommandExecutionRequestApproval request");
};
assert_eq!(params.item_id, "call-zsh-fork-subcommand-decline");
approval_ids.push(
params
.approval_id
.clone()
.expect("approval_id must be present for zsh subcommand approvals"),
);
assert_eq!(params.thread_id, thread.id);
let is_target_subcommand = params.command.as_deref() == Some("/usr/bin/true");
if is_target_subcommand {
approval_ids.push(
params
.approval_id
.clone()
.expect("approval_id must be present for zsh subcommand approvals"),
);
}
let decision = if is_target_subcommand {
let decision = target_decisions[target_decision_index].clone();
target_decision_index += 1;
decision
} else {
// Login-shell startup may trigger intercepted subcommands (for
// example `/usr/libexec/path_helper`). Accept those so the test can
// focus on the two `/usr/bin/true` subcommands in the tool call.
CommandExecutionApprovalDecision::Accept
};
mcp.send_response(
request_id,
serde_json::to_value(CommandExecutionRequestApprovalResponse { decision })?,
@@ -545,6 +568,9 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
.await?;
}
assert_eq!(approval_ids.len(), 2);
assert_ne!(approval_ids[0], approval_ids[1]);
let parent_completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let completed_notif = mcp
@@ -563,32 +589,61 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
}
}
})
.await??;
.await;
let ThreadItem::CommandExecution {
id,
status,
aggregated_output,
..
} = parent_completed_command_execution
else {
unreachable!("loop ensures we break on parent command execution item");
};
assert_eq!(id, "call-zsh-fork-subcommand-decline");
assert_eq!(status, CommandExecutionStatus::Declined);
assert!(
aggregated_output.is_none()
|| aggregated_output == Some("exec command rejected by user".to_string())
);
assert_eq!(approval_ids.len(), 2);
assert_ne!(approval_ids[0], approval_ids[1]);
match parent_completed_command_execution {
Ok(Ok(parent_completed_command_execution)) => {
let ThreadItem::CommandExecution {
id,
status,
aggregated_output,
..
} = parent_completed_command_execution
else {
unreachable!("loop ensures we break on parent command execution item");
};
assert_eq!(id, "call-zsh-fork-subcommand-decline");
assert_eq!(status, CommandExecutionStatus::Declined);
assert!(
aggregated_output.is_none()
|| aggregated_output == Some("exec command rejected by user".to_string())
);
mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT)
.await?;
mcp.interrupt_turn_and_wait_for_aborted(
thread.id.clone(),
turn.id.clone(),
DEFAULT_READ_TIMEOUT,
)
.await?;
}
Ok(Err(error)) => return Err(error),
Err(_) => {
// Some zsh builds abort the turn immediately after the rejected
// subcommand without emitting a parent `item/completed`.
let completed_notif = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let completed: TurnCompletedNotification = serde_json::from_value(
completed_notif
.params
.expect("turn/completed params must be present"),
)?;
assert_eq!(completed.thread_id, thread.id);
assert_eq!(completed.turn.id, turn.id);
assert_eq!(completed.turn.status, TurnStatus::Interrupted);
}
}
Ok(())
}
async fn create_zsh_test_mcp_process(codex_home: &Path, zdotdir: &Path) -> Result<McpProcess> {
let zdotdir = zdotdir.to_string_lossy().into_owned();
McpProcess::new_with_env(codex_home, &[("ZDOTDIR", Some(zdotdir.as_str()))]).await
}
fn create_config_toml(
codex_home: &Path,
server_uri: &str,
@@ -640,36 +695,52 @@ stream_max_retries = 0
)
}
fn find_test_zsh_path() -> Option<std::path::PathBuf> {
if let Some(path) = std::env::var_os("CODEX_TEST_ZSH_PATH") {
let path = std::path::PathBuf::from(path);
if path.is_file() {
return Some(path);
}
panic!(
"CODEX_TEST_ZSH_PATH is set but is not a file: {}",
path.display()
fn find_test_zsh_path() -> Result<Option<std::path::PathBuf>> {
let repo_root = codex_utils_cargo_bin::repo_root()?;
let dotslash_zsh = repo_root.join("codex-rs/exec-server/tests/suite/zsh");
if !dotslash_zsh.is_file() {
eprintln!(
"skipping zsh fork test: shared zsh DotSlash file not found at {}",
dotslash_zsh.display()
);
return Ok(None);
}
for candidate in ["/bin/zsh", "/usr/bin/zsh"] {
let path = Path::new(candidate);
if path.is_file() {
return Some(path.to_path_buf());
match std::process::Command::new("dotslash")
.arg("--")
.arg("fetch")
.arg(&dotslash_zsh)
.output()
{
Ok(output) if output.status.success() => {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
let path = std::path::PathBuf::from(path);
if path.is_file() {
return Ok(Some(path));
}
eprintln!(
"ignoring vendored zsh path from dotslash because it is not a file: {}",
path.display()
);
} else {
eprintln!(
"ignoring vendored zsh path from dotslash because fetch output was empty"
);
}
}
Ok(output) => {
eprintln!(
"failed to fetch vendored zsh via dotslash (status {status:?}): {stderr}",
status = output.status,
stderr = String::from_utf8_lossy(&output.stderr).trim()
);
}
Err(error) => {
eprintln!("failed to run dotslash to fetch vendored zsh: {error}");
}
}
let shell = std::env::var_os("SHELL")?;
let shell_path = std::path::PathBuf::from(shell);
if shell_path
.file_name()
.is_some_and(|file_name| file_name == "zsh")
&& shell_path.is_file()
{
return Some(shell_path);
}
None
Ok(None)
}
fn supports_exec_wrapper_intercept(zsh_path: &Path) -> bool {

View File

@@ -61,15 +61,9 @@ prefix_rule(
/// Verify the same prompt/escalation flow works when the server is launched
/// with a patched zsh binary.
///
/// Set CODEX_TEST_ZSH_PATH to enable this test locally or in CI.
/// The suite resolves `tests/suite/zsh` via DotSlash on first use.
#[tokio::test(flavor = "current_thread")]
async fn accept_elicitation_for_prompt_rule_with_zsh() -> Result<()> {
let Some(zsh_path) = std::env::var_os("CODEX_TEST_ZSH_PATH") else {
eprintln!("skipping zsh test: CODEX_TEST_ZSH_PATH is not set");
return Ok(());
};
let zsh_path = PathBuf::from(zsh_path);
let codex_home = TempDir::new()?;
write_default_execpolicy(
r#"
@@ -87,6 +81,11 @@ prefix_rule(
.await?;
let dotslash_cache_temp_dir = TempDir::new()?;
let dotslash_cache = dotslash_cache_temp_dir.path();
let zsh_path = resolve_test_zsh_path(dotslash_cache).await?;
eprintln!(
"using zsh path for exec-server test: {}",
zsh_path.display()
);
let transport =
create_transport_with_shell_path(codex_home.as_ref(), dotslash_cache, &zsh_path).await?;
run_accept_elicitation_for_prompt_rule_with_transport(transport).await
@@ -95,13 +94,13 @@ prefix_rule(
async fn run_accept_elicitation_for_prompt_rule_with_transport(
transport: rmcp::transport::TokioChildProcess,
) -> Result<()> {
// Create an MCP client that approves expected elicitation messages.
// Create an MCP client that approves the expected elicitation message.
let project_root = TempDir::new()?;
let project_root_path = project_root.path().canonicalize().unwrap();
let git_path = resolve_git_path(USE_LOGIN_SHELL).await?;
let git_init_command = format!("{git_path} init --quiet .");
let expected_elicitation_message = format!(
"Allow agent to run `{} init .` in `{}`?",
git_path,
"Allow agent to run `{git_path} init --quiet .` in `{}`?",
project_root_path.display()
);
let elicitation_requests: Arc<Mutex<Vec<CreateElicitationRequestParams>>> = Default::default();
@@ -142,7 +141,7 @@ async fn run_accept_elicitation_for_prompt_rule_with_transport(
arguments: Some(object(json!(
{
"login": USE_LOGIN_SHELL,
"command": "git init .",
"command": git_init_command,
"workdir": project_root_path.to_string_lossy(),
}
))),
@@ -157,15 +156,11 @@ async fn run_accept_elicitation_for_prompt_rule_with_transport(
let ExecResult {
exit_code, output, ..
} = serde_json::from_str::<ExecResult>(&tool_call_content.text)?;
let git_init_succeeded = format!(
"Initialized empty Git repository in {}/.git/\n",
project_root_path.display()
);
// Normally, this would be an exact match, but it might include extra output
// if `git config set advice.defaultBranchName false` has not been set.
// `git init --quiet` is expected to suppress the usual initialization
// banner, so assert on success and filesystem effects instead of output.
assert!(
output.contains(&git_init_succeeded),
"expected output `{output}` to contain `{git_init_succeeded}`"
output.is_empty(),
"expected no output from `git init --quiet .`, got `{output}`"
);
assert_eq!(exit_code, 0, "command should succeed");
assert_eq!(is_error, Some(false), "command should succeed");
@@ -192,6 +187,38 @@ async fn run_accept_elicitation_for_prompt_rule_with_transport(
Ok(())
}
async fn resolve_test_zsh_path(dotslash_cache: &std::path::Path) -> Result<PathBuf> {
let dotslash_zsh = codex_utils_cargo_bin::find_resource!("tests/suite/zsh")?;
let output = Command::new("dotslash")
.arg("--")
.arg("fetch")
.arg(&dotslash_zsh)
.env("DOTSLASH_CACHE", dotslash_cache)
.output()
.await
.context("failed to run dotslash to fetch test zsh")?;
ensure!(
output.status.success(),
"dotslash fetch for test zsh failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
let zsh_path = String::from_utf8(output.stdout)
.context("dotslash fetch output for test zsh was not utf8")?
.trim()
.to_string();
ensure!(
!zsh_path.is_empty(),
"dotslash fetch output for test zsh was empty"
);
let zsh_path = PathBuf::from(zsh_path);
ensure!(
zsh_path.is_file(),
"dotslash returned non-file zsh path: {}",
zsh_path.display()
);
Ok(zsh_path)
}
fn ensure_codex_cli() -> Result<PathBuf> {
let codex_cli = codex_utils_cargo_bin::cargo_bin("codex")?;
@@ -235,5 +262,13 @@ async fn resolve_git_path(use_login_shell: bool) -> Result<String> {
.trim()
.to_string();
ensure!(!git_path.is_empty(), "git path should not be empty");
Ok(git_path)
let git_path = PathBuf::from(git_path);
let git_path = if git_path.is_absolute() {
git_path
} else {
std::env::current_dir()
.context("failed to resolve cwd while normalizing git path")?
.join(git_path)
};
Ok(git_path.display().to_string())
}

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env dotslash
// This is the patched zsh fork built by
// `.github/workflows/shell-tool-mcp.yml` for the shell-tool-mcp package.
// Fetching the prebuilt version via DotSlash makes it easier to write
// integration tests that exercise the zsh fork behavior in exec-server tests.
//
// TODO(mbolin): Currently, we use a .tgz artifact that includes binaries for
// multiple platforms, but we could save a bit of space by making arch-specific
// artifacts available in the GitHub releases and referencing those here.
{
"name": "codex-zsh",
"platforms": {
// macOS 13 builds (and therefore x86_64) were dropped in
// https://github.com/openai/codex/pull/7295, so we only provide an
// Apple Silicon build for now.
"macos-aarch64": {
"size": 53771483,
"hash": "blake3",
"digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3",
"format": "tar.gz",
"path": "package/vendor/aarch64-apple-darwin/zsh/macos-15/zsh",
"providers": [
{
"url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz"
},
{
"type": "github-release",
"repo": "openai/codex",
"tag": "rust-v0.104.0",
"name": "codex-shell-tool-mcp-npm-0.104.0.tgz"
}
]
},
"linux-x86_64": {
"size": 53771483,
"hash": "blake3",
"digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3",
"format": "tar.gz",
"path": "package/vendor/x86_64-unknown-linux-musl/zsh/ubuntu-24.04/zsh",
"providers": [
{
"url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz"
},
{
"type": "github-release",
"repo": "openai/codex",
"tag": "rust-v0.104.0",
"name": "codex-shell-tool-mcp-npm-0.104.0.tgz"
}
]
},
"linux-aarch64": {
"size": 53771483,
"hash": "blake3",
"digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3",
"format": "tar.gz",
"path": "package/vendor/aarch64-unknown-linux-musl/zsh/ubuntu-24.04/zsh",
"providers": [
{
"url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz"
},
{
"type": "github-release",
"repo": "openai/codex",
"tag": "rust-v0.104.0",
"name": "codex-shell-tool-mcp-npm-0.104.0.tgz"
}
]
},
}
}