This PR does two things because after I got deep into the first one I started pulling on the thread to the second: - Makes `ConversationManager` the place where all in-memory conversations are created and stored. Previously, `MessageProcessor` in the `codex-mcp-server` crate was doing this via its `session_map`, but this is something that should be done in `codex-core`. - It unwinds the `ctrl_c: tokio::sync::Notify` that was threaded throughout our code. I think this made sense at one time, but now that we handle Ctrl-C within the TUI and have a proper `Op::Interrupt` event, I don't think this was quite right, so I removed it. For `codex exec` and `codex proto`, we now use `tokio::signal::ctrl_c()` directly, but we no longer make `Notify` a field of `Codex` or `CodexConversation`. Changes of note: - Adds the files `conversation_manager.rs` and `codex_conversation.rs` to `codex-core`. - `Codex` and `CodexSpawnOk` are no longer exported from `codex-core`: other crates must use `CodexConversation` instead (which is created via `ConversationManager`). - `core/src/codex_wrapper.rs` has been deleted in favor of `ConversationManager`. - `ConversationManager::new_conversation()` returns `NewConversation`, which is in line with the `new_conversation` tool we want to add to the MCP server. Note `NewConversation` includes `SessionConfiguredEvent`, so we eliminate checks in cases like `codex-rs/core/tests/client.rs` to verify `SessionConfiguredEvent` is the first event because that is now internal to `ConversationManager`. - Quite a bit of code was deleted from `codex-rs/mcp-server/src/message_processor.rs` since it no longer has to manage multiple conversations itself: it goes through `ConversationManager` instead. - `core/tests/live_agent.rs` has been deleted because I had to update a bunch of tests and all the tests in here were ignored, and I don't think anyone ever ran them, so this was just technical debt, at this point. - Removed `notify_on_sigint()` from `util.rs` (and in a follow-up, I hope to refactor the blandly-named `util.rs` into more descriptive files). - In general, I started replacing local variables named `codex` as `conversation`, where appropriate, though admittedly I didn't do it through all the integration tests because that would have added a lot of noise to this PR. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/2240). * #2264 * #2263 * __->__ #2240
236 lines
7.2 KiB
Rust
236 lines
7.2 KiB
Rust
use shlex;
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub struct ZshShell {
|
|
shell_path: String,
|
|
zshrc_path: String,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum Shell {
|
|
Zsh(ZshShell),
|
|
Unknown,
|
|
}
|
|
|
|
impl Shell {
|
|
pub fn format_default_shell_invocation(&self, command: Vec<String>) -> Option<Vec<String>> {
|
|
match self {
|
|
Shell::Zsh(zsh) => {
|
|
if !std::path::Path::new(&zsh.zshrc_path).exists() {
|
|
return None;
|
|
}
|
|
|
|
let mut result = vec![zsh.shell_path.clone()];
|
|
result.push("-lc".to_string());
|
|
|
|
let joined = strip_bash_lc(&command)
|
|
.or_else(|| shlex::try_join(command.iter().map(|s| s.as_str())).ok());
|
|
|
|
if let Some(joined) = joined {
|
|
result.push(format!("source {} && ({joined})", zsh.zshrc_path));
|
|
} else {
|
|
return None;
|
|
}
|
|
Some(result)
|
|
}
|
|
Shell::Unknown => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn strip_bash_lc(command: &Vec<String>) -> Option<String> {
|
|
match command.as_slice() {
|
|
// exactly three items
|
|
[first, second, third]
|
|
// first two must be "bash", "-lc"
|
|
if first == "bash" && second == "-lc" =>
|
|
{
|
|
Some(third.clone())
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
pub async fn default_user_shell() -> Shell {
|
|
use tokio::process::Command;
|
|
use whoami;
|
|
|
|
let user = whoami::username();
|
|
let home = format!("/Users/{user}");
|
|
let output = Command::new("dscl")
|
|
.args([".", "-read", &home, "UserShell"])
|
|
.output()
|
|
.await
|
|
.ok();
|
|
match output {
|
|
Some(o) => {
|
|
if !o.status.success() {
|
|
return Shell::Unknown;
|
|
}
|
|
let stdout = String::from_utf8_lossy(&o.stdout);
|
|
for line in stdout.lines() {
|
|
if let Some(shell_path) = line.strip_prefix("UserShell: ") {
|
|
if shell_path.ends_with("/zsh") {
|
|
return Shell::Zsh(ZshShell {
|
|
shell_path: shell_path.to_string(),
|
|
zshrc_path: format!("{home}/.zshrc"),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Shell::Unknown
|
|
}
|
|
_ => Shell::Unknown,
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub async fn default_user_shell() -> Shell {
|
|
Shell::Unknown
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[cfg(target_os = "macos")]
|
|
mod tests {
|
|
use super::*;
|
|
use std::process::Command;
|
|
|
|
#[tokio::test]
|
|
#[expect(clippy::unwrap_used)]
|
|
async fn test_current_shell_detects_zsh() {
|
|
let shell = Command::new("sh")
|
|
.arg("-c")
|
|
.arg("echo $SHELL")
|
|
.output()
|
|
.unwrap();
|
|
|
|
let home = std::env::var("HOME").unwrap();
|
|
let shell_path = String::from_utf8_lossy(&shell.stdout).trim().to_string();
|
|
if shell_path.ends_with("/zsh") {
|
|
assert_eq!(
|
|
default_user_shell().await,
|
|
Shell::Zsh(ZshShell {
|
|
shell_path: shell_path.to_string(),
|
|
zshrc_path: format!("{home}/.zshrc",),
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_run_with_profile_zshrc_not_exists() {
|
|
let shell = Shell::Zsh(ZshShell {
|
|
shell_path: "/bin/zsh".to_string(),
|
|
zshrc_path: "/does/not/exist/.zshrc".to_string(),
|
|
});
|
|
let actual_cmd = shell.format_default_shell_invocation(vec!["myecho".to_string()]);
|
|
assert_eq!(actual_cmd, None);
|
|
}
|
|
|
|
#[expect(clippy::unwrap_used)]
|
|
#[tokio::test]
|
|
async fn test_run_with_profile_escaping_and_execution() {
|
|
let shell_path = "/bin/zsh";
|
|
|
|
let cases = vec![
|
|
(
|
|
vec!["myecho"],
|
|
vec![shell_path, "-lc", "source ZSHRC_PATH && (myecho)"],
|
|
Some("It works!\n"),
|
|
),
|
|
(
|
|
vec!["myecho"],
|
|
vec![shell_path, "-lc", "source ZSHRC_PATH && (myecho)"],
|
|
Some("It works!\n"),
|
|
),
|
|
(
|
|
vec!["bash", "-c", "echo 'single' \"double\""],
|
|
vec![
|
|
shell_path,
|
|
"-lc",
|
|
"source ZSHRC_PATH && (bash -c \"echo 'single' \\\"double\\\"\")",
|
|
],
|
|
Some("single double\n"),
|
|
),
|
|
(
|
|
vec!["bash", "-lc", "echo 'single' \"double\""],
|
|
vec![
|
|
shell_path,
|
|
"-lc",
|
|
"source ZSHRC_PATH && (echo 'single' \"double\")",
|
|
],
|
|
Some("single double\n"),
|
|
),
|
|
];
|
|
for (input, expected_cmd, expected_output) in cases {
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::exec::ExecParams;
|
|
use crate::exec::SandboxType;
|
|
use crate::exec::process_exec_tool_call;
|
|
use crate::protocol::SandboxPolicy;
|
|
|
|
// create a temp directory with a zshrc file in it
|
|
let temp_home = tempfile::tempdir().unwrap();
|
|
let zshrc_path = temp_home.path().join(".zshrc");
|
|
std::fs::write(
|
|
&zshrc_path,
|
|
r#"
|
|
set -x
|
|
function myecho {
|
|
echo 'It works!'
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let shell = Shell::Zsh(ZshShell {
|
|
shell_path: shell_path.to_string(),
|
|
zshrc_path: zshrc_path.to_str().unwrap().to_string(),
|
|
});
|
|
|
|
let actual_cmd = shell
|
|
.format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect());
|
|
let expected_cmd = expected_cmd
|
|
.iter()
|
|
.map(|s| {
|
|
s.replace("ZSHRC_PATH", zshrc_path.to_str().unwrap())
|
|
.to_string()
|
|
})
|
|
.collect();
|
|
|
|
assert_eq!(actual_cmd, Some(expected_cmd));
|
|
// Actually run the command and check output/exit code
|
|
let output = process_exec_tool_call(
|
|
ExecParams {
|
|
command: actual_cmd.unwrap(),
|
|
cwd: PathBuf::from(temp_home.path()),
|
|
timeout_ms: None,
|
|
env: HashMap::from([(
|
|
"HOME".to_string(),
|
|
temp_home.path().to_str().unwrap().to_string(),
|
|
)]),
|
|
with_escalated_permissions: None,
|
|
justification: None,
|
|
},
|
|
SandboxType::None,
|
|
&SandboxPolicy::DangerFullAccess,
|
|
&None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(output.exit_code, 0, "input: {input:?} output: {output:?}");
|
|
if let Some(expected) = expected_output {
|
|
assert_eq!(
|
|
output.stdout.text, expected,
|
|
"input: {input:?} output: {output:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|