Files
codex/codex-rs/exec-server/tests/suite/accept_elicitation.rs
Michael Bolin e61bae12e3 feat: introduce codex-utils-cargo-bin as an alternative to assert_cmd::Command (#8496)
This PR introduces a `codex-utils-cargo-bin` utility crate that
wraps/replaces our use of `assert_cmd::Command` and
`escargot::CargoBuild`.

As you can infer from the introduction of `buck_project_root()` in this
PR, I am attempting to make it possible to build Codex under
[Buck2](https://buck2.build) as well as `cargo`. With Buck2, I hope to
achieve faster incremental local builds (largely due to Buck2's
[dice](https://buck2.build/docs/insights_and_knowledge/modern_dice/)
build strategy, as well as benefits from its local build daemon) as well
as faster CI builds if we invest in remote execution and caching.

See
https://buck2.build/docs/getting_started/what_is_buck2/#why-use-buck2-key-advantages
for more details about the performance advantages of Buck2.

Buck2 enforces stronger requirements in terms of build and test
isolation. It discourages assumptions about absolute paths (which is key
to enabling remote execution). Because the `CARGO_BIN_EXE_*` environment
variables that Cargo provides are absolute paths (which
`assert_cmd::Command` reads), this is a problem for Buck2, which is why
we need this `codex-utils-cargo-bin` utility.

My WIP-Buck2 setup sets the `CARGO_BIN_EXE_*` environment variables
passed to a `rust_test()` build rule as relative paths.
`codex-utils-cargo-bin` will resolve these values to absolute paths,
when necessary.


---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/8496).
* #8498
* __->__ #8496
2025-12-23 19:29:32 -08:00

188 lines
6.4 KiB
Rust

#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::borrow::Cow;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use codex_exec_server::ExecResult;
use exec_server_test_support::InteractiveClient;
use exec_server_test_support::create_transport;
use exec_server_test_support::notify_readable_sandbox;
use exec_server_test_support::write_default_execpolicy;
use maplit::hashset;
use pretty_assertions::assert_eq;
use rmcp::ServiceExt;
use rmcp::model::CallToolRequestParam;
use rmcp::model::CallToolResult;
use rmcp::model::CreateElicitationRequestParam;
use rmcp::model::EmptyResult;
use rmcp::model::ServerResult;
use rmcp::model::object;
use serde_json::json;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::fs::symlink;
use tempfile::TempDir;
use tokio::process::Command;
/// Verify that when using a read-only sandbox and an execpolicy that prompts,
/// the proper elicitation is sent. Upon auto-approving the elicitation, the
/// command should be run privileged outside the sandbox.
#[tokio::test(flavor = "current_thread")]
async fn accept_elicitation_for_prompt_rule() -> Result<()> {
// Configure a stdio transport that will launch the MCP server using
// $CODEX_HOME with an execpolicy that prompts for `git init` commands.
let codex_home = TempDir::new()?;
write_default_execpolicy(
r#"
# Create a rule with `decision = "prompt"` to exercise the elicitation flow.
prefix_rule(
pattern = ["git", "init"],
decision = "prompt",
match = [
"git init ."
],
)
"#,
codex_home.as_ref(),
)
.await?;
let dotslash_cache_temp_dir = TempDir::new()?;
let dotslash_cache = dotslash_cache_temp_dir.path();
let transport = create_transport(codex_home.as_ref(), dotslash_cache).await?;
// Create an MCP client that approves expected elicitation messages.
let project_root = TempDir::new()?;
let project_root_path = project_root.path().canonicalize().unwrap();
let git_path = resolve_git_path().await?;
let expected_elicitation_message = format!(
"Allow agent to run `{} init .` in `{}`?",
git_path,
project_root_path.display()
);
let elicitation_requests: Arc<Mutex<Vec<CreateElicitationRequestParam>>> = Default::default();
let client = InteractiveClient {
elicitations_to_accept: hashset! { expected_elicitation_message.clone() },
elicitation_requests: elicitation_requests.clone(),
};
// Start the MCP server.
let service: rmcp::service::RunningService<rmcp::RoleClient, InteractiveClient> =
client.serve(transport).await?;
// Notify the MCP server about the current sandbox state before making any
// `shell` tool calls.
let linux_sandbox_exe_folder = TempDir::new()?;
let codex_linux_sandbox_exe = if cfg!(target_os = "linux") {
let codex_linux_sandbox_exe = linux_sandbox_exe_folder.path().join("codex-linux-sandbox");
let codex_cli = ensure_codex_cli()?;
symlink(&codex_cli, &codex_linux_sandbox_exe)?;
Some(codex_linux_sandbox_exe)
} else {
None
};
let response =
notify_readable_sandbox(&project_root_path, codex_linux_sandbox_exe, &service).await?;
let ServerResult::EmptyResult(EmptyResult {}) = response else {
panic!("expected EmptyResult from sandbox state notification but found: {response:?}");
};
// Call the shell tool and verify that an elicitation was created and
// auto-approved.
let CallToolResult {
content, is_error, ..
} = service
.call_tool(CallToolRequestParam {
name: Cow::Borrowed("shell"),
arguments: Some(object(json!(
{
"login": false,
"command": "git init .",
"workdir": project_root_path.to_string_lossy(),
}
))),
})
.await?;
let tool_call_content = content
.first()
.expect("expected non-empty content")
.as_text()
.expect("expected text content");
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.
assert!(
output.contains(&git_init_succeeded),
"expected output `{output}` to contain `{git_init_succeeded}`"
);
assert_eq!(exit_code, 0, "command should succeed");
assert_eq!(is_error, Some(false), "command should succeed");
assert!(
project_root_path.join(".git").is_dir(),
"git repo should exist"
);
let elicitation_messages = elicitation_requests
.lock()
.unwrap()
.iter()
.map(|r| r.message.clone())
.collect::<Vec<_>>();
assert_eq!(vec![expected_elicitation_message], elicitation_messages);
Ok(())
}
fn ensure_codex_cli() -> Result<PathBuf> {
let codex_cli = codex_utils_cargo_bin::cargo_bin("codex")?;
let metadata = codex_cli.metadata().with_context(|| {
format!(
"failed to read metadata for codex binary at {}",
codex_cli.display()
)
})?;
ensure!(
metadata.is_file(),
"expected codex binary at {} to be a file; run `cargo build -p codex-cli --bin codex` before this test",
codex_cli.display()
);
let mode = metadata.permissions().mode();
ensure!(
mode & 0o111 != 0,
"codex binary at {} is not executable (mode {mode:o}); run `cargo build -p codex-cli --bin codex` before this test",
codex_cli.display()
);
Ok(codex_cli)
}
async fn resolve_git_path() -> Result<String> {
let git = Command::new("bash")
.arg("-lc")
.arg("command -v git")
.output()
.await
.context("failed to resolve git via login shell")?;
ensure!(
git.status.success(),
"failed to resolve git via login shell: {}",
String::from_utf8_lossy(&git.stderr)
);
let git_path = String::from_utf8(git.stdout)
.context("git path was not valid utf8")?
.trim()
.to_string();
ensure!(!git_path.is_empty(), "git path should not be empty");
Ok(git_path)
}