feat: make cwd a required field of Config so we stop assuming std::env::current_dir() in a session (#800)

In order to expose Codex via an MCP server, I realized that we should be
taking `cwd` as a parameter rather than assuming
`std::env::current_dir()` as the `cwd`. Specifically, the user may want
to start a session in a directory other than the one where the MCP
server has been started.

This PR makes `cwd: PathBuf` a required field of `Session` and threads
it all the way through, though I think there is still an issue with not
honoring `workdir` for `apply_patch`, which is something we also had to
fix in the TypeScript version: https://github.com/openai/codex/pull/556.

This also adds `-C`/`--cd` to change the cwd via the command line.

To test, I ran:

```
cargo run --bin codex -- exec -C /tmp 'show the output of ls'
```

and verified it showed the contents of my `/tmp` folder instead of
`$PWD`.
This commit is contained in:
Michael Bolin
2025-05-04 10:57:12 -07:00
committed by GitHub
parent 4b61fb8bab
commit 421e159888
18 changed files with 210 additions and 102 deletions

View File

@@ -22,6 +22,7 @@ use tokio::sync::oneshot;
use tokio::sync::Notify;
use tokio::task::AbortHandle;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing::trace;
use tracing::warn;
@@ -40,6 +41,7 @@ use crate::models::ContentItem;
use crate::models::FunctionCallOutputPayload;
use crate::models::ResponseInputItem;
use crate::models::ResponseItem;
use crate::models::ShellToolCallParams;
use crate::protocol::AskForApproval;
use crate::protocol::Event;
use crate::protocol::EventMsg;
@@ -190,6 +192,10 @@ struct Session {
tx_event: Sender<Event>,
ctrl_c: Arc<Notify>,
/// The session's current working directory. All relative paths provided by
/// the model as well as sandbox policies are resolved against this path
/// instead of `std::env::current_dir()`.
cwd: PathBuf,
instructions: Option<String>,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
@@ -198,10 +204,17 @@ struct Session {
/// External notifier command (will be passed as args to exec()). When
/// `None` this feature is disabled.
notify: Option<Vec<String>>,
state: Mutex<State>,
}
impl Session {
fn resolve_path(&self, path: Option<String>) -> PathBuf {
path.as_ref()
.map(PathBuf::from)
.map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p))
}
}
/// Mutable state of the agent
#[derive(Default)]
struct State {
@@ -296,15 +309,8 @@ impl Session {
sub_id: &str,
call_id: &str,
command: Vec<String>,
cwd: Option<String>,
cwd: PathBuf,
) {
let cwd = cwd
.or_else(|| {
std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string())
})
.unwrap_or_else(|| "<unknown cwd>".to_string());
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::ExecCommandBegin {
@@ -518,8 +524,22 @@ async fn submission_loop(
sandbox_policy,
disable_response_storage,
notify,
cwd,
} => {
info!(model, "Configuring session");
if !cwd.is_absolute() {
let message = format!("cwd is not absolute: {cwd:?}");
error!(message);
let event = Event {
id: sub.id,
msg: EventMsg::Error { message },
};
if let Err(e) = tx_event.send(event).await {
error!("failed to send error message: {e:?}");
}
return;
}
let client = ModelClient::new(model.clone());
// abort any current running session and clone its state
@@ -538,7 +558,7 @@ async fn submission_loop(
},
};
// update session
let writable_roots = Mutex::new(get_writable_roots(&cwd));
sess = Some(Arc::new(Session {
client,
tx_event: tx_event.clone(),
@@ -546,7 +566,8 @@ async fn submission_loop(
instructions,
approval_policy,
sandbox_policy,
writable_roots: Mutex::new(get_writable_roots()),
cwd,
writable_roots,
notify,
state: Mutex::new(state),
}));
@@ -865,7 +886,7 @@ async fn handle_function_call(
match name.as_str() {
"container.exec" | "shell" => {
// parse command
let params = match serde_json::from_str::<ExecParams>(&arguments) {
let params = match serde_json::from_str::<ShellToolCallParams>(&arguments) {
Ok(v) => v,
Err(e) => {
// allow model to re-sample
@@ -904,12 +925,7 @@ async fn handle_function_call(
}
// this was not a valid patch, execute command
let repo_root = std::env::current_dir().expect("no current dir");
let workdir: PathBuf = params
.workdir
.as_ref()
.map(PathBuf::from)
.unwrap_or(repo_root.clone());
let workdir = sess.resolve_path(params.workdir.clone());
// safety checks
let safety = {
@@ -968,12 +984,16 @@ async fn handle_function_call(
&sub_id,
&call_id,
params.command.clone(),
params.workdir.clone(),
workdir.clone(),
)
.await;
let output_result = process_exec_tool_call(
params.clone(),
ExecParams {
command: params.command.clone(),
cwd: workdir.clone(),
timeout_ms: params.timeout_ms,
},
sandbox_type,
sess.ctrl_c.clone(),
&sess.sandbox_policy,
@@ -1051,18 +1071,23 @@ async fn handle_function_call(
// Emit a fresh Begin event so progress bars reset.
let retry_call_id = format!("{call_id}-retry");
let cwd = sess.resolve_path(params.workdir.clone());
sess.notify_exec_command_begin(
&sub_id,
&retry_call_id,
params.command.clone(),
params.workdir.clone(),
cwd.clone(),
)
.await;
// This is an escalated retry; the policy will not be
// examined and the sandbox has been set to `None`.
let retry_output_result = process_exec_tool_call(
params.clone(),
ExecParams {
command: params.command.clone(),
cwd: cwd.clone(),
timeout_ms: params.timeout_ms,
},
SandboxType::None,
sess.ctrl_c.clone(),
&sess.sandbox_policy,
@@ -1162,43 +1187,47 @@ async fn apply_patch(
guard.clone()
};
let auto_approved =
match assess_patch_safety(&changes, sess.approval_policy, &writable_roots_snapshot) {
SafetyCheck::AutoApprove { .. } => true,
SafetyCheck::AskUser => {
// Compute a readable summary of path changes to include in the
// approval request so the user can make an informed decision.
let rx_approve = sess
.request_patch_approval(sub_id.clone(), &changes, None, None)
.await;
match rx_approve.await.unwrap_or_default() {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false,
ReviewDecision::Denied | ReviewDecision::Abort => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "patch rejected by user".to_string(),
success: Some(false),
},
};
}
let auto_approved = match assess_patch_safety(
&changes,
sess.approval_policy,
&writable_roots_snapshot,
&sess.cwd,
) {
SafetyCheck::AutoApprove { .. } => true,
SafetyCheck::AskUser => {
// Compute a readable summary of path changes to include in the
// approval request so the user can make an informed decision.
let rx_approve = sess
.request_patch_approval(sub_id.clone(), &changes, None, None)
.await;
match rx_approve.await.unwrap_or_default() {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false,
ReviewDecision::Denied | ReviewDecision::Abort => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "patch rejected by user".to_string(),
success: Some(false),
},
};
}
}
SafetyCheck::Reject { reason } => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("patch rejected: {reason}"),
success: Some(false),
},
};
}
};
}
SafetyCheck::Reject { reason } => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("patch rejected: {reason}"),
success: Some(false),
},
};
}
};
// Verify write permissions before touching the filesystem.
let writable_snapshot = { sess.writable_roots.lock().unwrap().clone() };
if let Some(offending) = first_offending_path(&changes, &writable_snapshot) {
if let Some(offending) = first_offending_path(&changes, &writable_snapshot, &sess.cwd) {
let root = offending.parent().unwrap_or(&offending).to_path_buf();
let reason = Some(format!(
@@ -1255,11 +1284,13 @@ async fn apply_patch(
ApplyPatchFileChange::Update { .. } => path,
};
// Reuse safety normalisation logic: treat absolute path.
// Reuse safety normalization logic: treat absolute path.
let abs = if path_ref.is_absolute() {
path_ref.clone()
} else {
std::env::current_dir().unwrap_or_default().join(path_ref)
// TODO(mbolin): If workdir was supplied with apply_patch call,
// relative paths should be resolved against it.
sess.cwd.join(path_ref)
};
let writable = {
@@ -1345,9 +1376,8 @@ async fn apply_patch(
fn first_offending_path(
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
writable_roots: &[PathBuf],
cwd: &Path,
) -> Option<PathBuf> {
let cwd = std::env::current_dir().unwrap_or_default();
for (path, change) in changes {
let candidate = match change {
ApplyPatchFileChange::Add { .. } => path,
@@ -1485,7 +1515,7 @@ fn apply_changes_from_apply_patch(
})
}
fn get_writable_roots() -> Vec<PathBuf> {
fn get_writable_roots(cwd: &Path) -> Vec<std::path::PathBuf> {
let mut writable_roots = Vec::new();
if cfg!(target_os = "macos") {
// On macOS, $TMPDIR is private to the user.
@@ -1507,9 +1537,7 @@ fn get_writable_roots() -> Vec<PathBuf> {
}
}
if let Ok(cwd) = std::env::current_dir() {
writable_roots.push(cwd);
}
writable_roots.push(cwd.to_path_buf());
writable_roots
}

View File

@@ -26,6 +26,7 @@ pub async fn init_codex(config: Config) -> anyhow::Result<(CodexWrapper, Event,
sandbox_policy: config.sandbox_policy,
disable_response_storage: config.disable_response_storage,
notify: config.notify.clone(),
cwd: config.cwd.clone(),
})
.await?;

View File

@@ -52,6 +52,11 @@ pub struct Config {
///
/// If unset the feature is disabled.
pub notify: Option<Vec<String>>,
/// The directory that should be treated as the current working directory
/// for the session. All relative paths inside the business-logic layer are
/// resolved against this path.
pub cwd: PathBuf,
}
/// Base config deserialized from ~/.codex/config.toml.
@@ -135,6 +140,7 @@ where
#[derive(Default, Debug, Clone)]
pub struct ConfigOverrides {
pub model: Option<String>,
pub cwd: Option<PathBuf>,
pub approval_policy: Option<AskForApproval>,
pub sandbox_policy: Option<SandboxPolicy>,
pub disable_response_storage: Option<bool>,
@@ -158,6 +164,7 @@ impl Config {
// Destructure ConfigOverrides fully to ensure all overrides are applied.
let ConfigOverrides {
model,
cwd,
approval_policy,
sandbox_policy,
disable_response_storage,
@@ -180,6 +187,23 @@ impl Config {
Self {
model: model.or(cfg.model).unwrap_or_else(default_model),
cwd: cwd.map_or_else(
|| {
tracing::info!("cwd not set, using current dir");
std::env::current_dir().expect("cannot determine current dir")
},
|p| {
if p.is_absolute() {
p
} else {
// Resolve relative paths against the current working directory.
tracing::info!("cwd is relative, resolving against current dir");
let mut cwd = std::env::current_dir().expect("cannot determine cwd");
cwd.push(p);
cwd
}
},
),
approval_policy: approval_policy
.or(cfg.approval_policy)
.unwrap_or_else(AskForApproval::default),

View File

@@ -1,13 +1,14 @@
use std::io;
#[cfg(target_family = "unix")]
use std::os::unix::process::ExitStatusExt;
use std::path::Path;
use std::path::PathBuf;
use std::process::ExitStatus;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use serde::Deserialize;
use tokio::io::AsyncRead;
use tokio::io::AsyncReadExt;
use tokio::io::BufReader;
@@ -40,15 +41,10 @@ const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl
/// already has root access.
const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";
#[derive(Deserialize, Debug, Clone)]
#[derive(Debug, Clone)]
pub struct ExecParams {
pub command: Vec<String>,
pub workdir: Option<String>,
/// This is the maximum time in seconds that the command is allowed to run.
#[serde(rename = "timeout")]
// The wire format uses `timeout`, which has ambiguous units, so we use
// `timeout_ms` as the field name so it is clear in code.
pub cwd: PathBuf,
pub timeout_ms: Option<u64>,
}
@@ -97,14 +93,14 @@ pub async fn process_exec_tool_call(
SandboxType::MacosSeatbelt => {
let ExecParams {
command,
workdir,
cwd,
timeout_ms,
} = params;
let seatbelt_command = create_seatbelt_command(command, sandbox_policy);
let seatbelt_command = create_seatbelt_command(command, sandbox_policy, &cwd);
exec(
ExecParams {
command: seatbelt_command,
workdir,
cwd,
timeout_ms,
},
ctrl_c,
@@ -157,6 +153,7 @@ pub async fn process_exec_tool_call(
pub fn create_seatbelt_command(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
cwd: &Path,
) -> Vec<String> {
let (file_write_policy, extra_cli_args) = {
if sandbox_policy.has_full_disk_write_access() {
@@ -166,7 +163,7 @@ pub fn create_seatbelt_command(
Vec::<String>::new(),
)
} else {
let writable_roots = sandbox_policy.get_writable_roots();
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
let (writable_folder_policies, cli_args): (Vec<String>, Vec<String>) = writable_roots
.iter()
.enumerate()
@@ -234,7 +231,7 @@ pub struct ExecToolCallOutput {
pub async fn exec(
ExecParams {
command,
workdir,
cwd,
timeout_ms,
}: ExecParams,
ctrl_c: Arc<Notify>,
@@ -251,9 +248,7 @@ pub async fn exec(
if command.len() > 1 {
cmd.args(&command[1..]);
}
if let Some(dir) = &workdir {
cmd.current_dir(dir);
}
cmd.current_dir(cwd);
// Do not create a file descriptor for stdin because otherwise some
// commands may hang forever waiting for input. For example, ripgrep has

View File

@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
@@ -48,7 +49,7 @@ pub async fn exec_linux(
.expect("Failed to create runtime");
rt.block_on(async {
apply_sandbox_policy_to_current_thread(sandbox_policy)?;
apply_sandbox_policy_to_current_thread(sandbox_policy, &params.cwd)?;
exec(params, ctrl_c_copy).await
})
})
@@ -66,13 +67,16 @@ pub async fn exec_linux(
/// Apply sandbox policies inside this thread so only the child inherits
/// them, not the entire CLI process.
pub fn apply_sandbox_policy_to_current_thread(sandbox_policy: SandboxPolicy) -> Result<()> {
pub fn apply_sandbox_policy_to_current_thread(
sandbox_policy: SandboxPolicy,
cwd: &Path,
) -> Result<()> {
if !sandbox_policy.has_full_network_access() {
install_network_seccomp_filter_on_current_thread()?;
}
if !sandbox_policy.has_full_disk_write_access() {
let writable_roots = sandbox_policy.get_writable_roots();
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
install_filesystem_landlock_rules_on_current_thread(writable_roots)?;
}
@@ -189,7 +193,7 @@ mod tests_linux {
async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
let params = ExecParams {
command: cmd.iter().map(|elm| elm.to_string()).collect(),
workdir: None,
cwd: std::env::current_dir().expect("cwd should exist"),
timeout_ms: Some(timeout_ms),
};
@@ -262,7 +266,7 @@ mod tests_linux {
async fn assert_network_blocked(cmd: &[&str]) {
let params = ExecParams {
command: cmd.iter().map(|s| s.to_string()).collect(),
workdir: None,
cwd: std::env::current_dir().expect("cwd should exist"),
// Give the tool a generous 2second timeout so even slow DNS timeouts
// do not stall the suite.
timeout_ms: Some(2_000),

View File

@@ -102,6 +102,20 @@ impl From<Vec<InputItem>> for ResponseInputItem {
}
}
/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
/// or shell`, the `arguments` field should deserialize to this struct.
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct ShellToolCallParams {
pub command: Vec<String>,
pub workdir: Option<String>,
/// This is the maximum time in seconds that the command is allowed to run.
#[serde(rename = "timeout")]
// The wire format uses `timeout`, which has ambiguous units, so we use
// `timeout_ms` as the field name so it is clear in code.
pub timeout_ms: Option<u64>,
}
#[expect(dead_code)]
#[derive(Deserialize, Debug, Clone)]
pub struct FunctionCallOutputPayload {
@@ -183,4 +197,23 @@ mod tests {
assert_eq!(v.get("output").unwrap().as_str().unwrap(), "bad");
}
#[test]
fn deserialize_shell_tool_call_params() {
let json = r#"{
"command": ["ls", "-l"],
"workdir": "/tmp",
"timeout": 1000
}"#;
let params: ShellToolCallParams = serde_json::from_str(json).unwrap();
assert_eq!(
ShellToolCallParams {
command: vec!["ls".to_string(), "-l".to_string()],
workdir: Some("/tmp".to_string()),
timeout_ms: Some(1000),
},
params
);
}
}

View File

@@ -4,6 +4,7 @@
//! between user and agent.
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use serde::Deserialize;
@@ -43,6 +44,15 @@ pub enum Op {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
notify: Option<Vec<String>>,
/// Working directory that should be treated as the *root* of the
/// session. All relative paths supplied by the model as well as the
/// execution sandbox are resolved against this directory **instead**
/// of the process-wide current working directory. CLI front-ends are
/// expected to expand this to an absolute path before sending the
/// `ConfigureSession` operation so that the business-logic layer can
/// operate deterministically.
cwd: std::path::PathBuf,
},
/// Abort current task.
@@ -157,7 +167,7 @@ impl SandboxPolicy {
.any(|perm| matches!(perm, SandboxPermission::NetworkFullAccess))
}
pub fn get_writable_roots(&self) -> Vec<PathBuf> {
pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<PathBuf> {
let mut writable_roots = Vec::<PathBuf>::new();
for perm in &self.permissions {
use SandboxPermission::*;
@@ -193,12 +203,9 @@ impl SandboxPolicy {
writable_roots.push(PathBuf::from("/tmp"));
}
}
DiskWriteCwd => match std::env::current_dir() {
Ok(cwd) => writable_roots.push(cwd),
Err(err) => {
tracing::error!("Failed to get current working directory: {err}");
}
},
DiskWriteCwd => {
writable_roots.push(cwd.to_path_buf());
}
DiskWriteFolder { folder } => {
writable_roots.push(folder.clone());
}
@@ -317,7 +324,7 @@ pub enum EventMsg {
command: Vec<String>,
/// The command's working directory if not the default cwd for the
/// agent.
cwd: String,
cwd: PathBuf,
},
ExecCommandEnd {

View File

@@ -22,6 +22,7 @@ pub fn assess_patch_safety(
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
policy: AskForApproval,
writable_roots: &[PathBuf],
cwd: &Path,
) -> SafetyCheck {
if changes.is_empty() {
return SafetyCheck::Reject {
@@ -40,7 +41,7 @@ pub fn assess_patch_safety(
}
}
if is_write_patch_constrained_to_writable_paths(changes, writable_roots) {
if is_write_patch_constrained_to_writable_paths(changes, writable_roots, cwd) {
SafetyCheck::AutoApprove {
sandbox_type: SandboxType::None,
}
@@ -115,6 +116,7 @@ pub fn get_platform_sandbox() -> Option<SandboxType> {
fn is_write_patch_constrained_to_writable_paths(
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
writable_roots: &[PathBuf],
cwd: &Path,
) -> bool {
// Earlyexit if there are no declared writable roots.
if writable_roots.is_empty() {
@@ -141,11 +143,6 @@ fn is_write_patch_constrained_to_writable_paths(
// and roots are converted to absolute, normalized forms before the
// prefix check.
let is_path_writable = |p: &PathBuf| {
let cwd = match std::env::current_dir() {
Ok(cwd) => cwd,
Err(_) => return false,
};
let abs = if p.is_absolute() {
p.clone()
} else {
@@ -217,19 +214,22 @@ mod tests {
assert!(is_write_patch_constrained_to_writable_paths(
&add_inside,
&[PathBuf::from(".")]
&[PathBuf::from(".")],
&cwd,
));
let add_outside_2 = make_add_change(parent.join("outside.txt"));
assert!(!is_write_patch_constrained_to_writable_paths(
&add_outside_2,
&[PathBuf::from(".")]
&[PathBuf::from(".")],
&cwd,
));
// With parent dir added as writable root, it should pass.
assert!(is_write_patch_constrained_to_writable_paths(
&add_outside,
&[PathBuf::from("..")]
&[PathBuf::from("..")],
&cwd,
))
}
}

View File

@@ -58,6 +58,7 @@ async fn spawn_codex() -> Codex {
sandbox_policy: SandboxPolicy::new_read_only_policy(),
disable_response_storage: false,
notify: None,
cwd: std::env::current_dir().unwrap(),
},
})
.await

View File

@@ -98,6 +98,7 @@ async fn keeps_previous_response_id_between_tasks() {
sandbox_policy: SandboxPolicy::new_read_only_policy(),
disable_response_storage: false,
notify: None,
cwd: std::env::current_dir().unwrap(),
},
})
.await

View File

@@ -81,6 +81,7 @@ async fn retries_on_early_close() {
sandbox_policy: SandboxPolicy::new_read_only_policy(),
disable_response_storage: false,
notify: None,
cwd: std::env::current_dir().unwrap(),
},
})
.await