mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
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
188 lines
6.4 KiB
Rust
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)
|
|
}
|