diff --git a/codex-rs/cli/src/landlock.rs b/codex-rs/cli/src/landlock.rs index f6638897..bc43eb57 100644 --- a/codex-rs/cli/src/landlock.rs +++ b/codex-rs/cli/src/landlock.rs @@ -18,7 +18,8 @@ pub fn run_landlock(command: Vec, sandbox_policy: SandboxPolicy) -> anyh // Spawn a new thread and apply the sandbox policies there. let handle = std::thread::spawn(move || -> anyhow::Result { - codex_core::linux::apply_sandbox_policy_to_current_thread(sandbox_policy)?; + let cwd = std::env::current_dir()?; + codex_core::linux::apply_sandbox_policy_to_current_thread(sandbox_policy, &cwd)?; let status = Command::new(&command[0]).args(&command[1..]).status()?; Ok(status) }); diff --git a/codex-rs/cli/src/seatbelt.rs b/codex-rs/cli/src/seatbelt.rs index 6c49d8cc..3c7ec2ba 100644 --- a/codex-rs/cli/src/seatbelt.rs +++ b/codex-rs/cli/src/seatbelt.rs @@ -5,7 +5,8 @@ pub async fn run_seatbelt( command: Vec, sandbox_policy: SandboxPolicy, ) -> anyhow::Result<()> { - let seatbelt_command = create_seatbelt_command(command, &sandbox_policy); + let cwd = std::env::current_dir().expect("failed to get cwd"); + let seatbelt_command = create_seatbelt_command(command, &sandbox_policy, &cwd); let status = tokio::process::Command::new(seatbelt_command[0].clone()) .args(&seatbelt_command[1..]) .spawn() diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index da2c6288..8f3420ac 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -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, ctrl_c: Arc, + /// 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, 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>, - state: Mutex, } +impl Session { + fn resolve_path(&self, path: Option) -> 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, - cwd: Option, + cwd: PathBuf, ) { - let cwd = cwd - .or_else(|| { - std::env::current_dir() - .ok() - .map(|p| p.to_string_lossy().to_string()) - }) - .unwrap_or_else(|| "".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::(&arguments) { + let params = match serde_json::from_str::(&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, writable_roots: &[PathBuf], + cwd: &Path, ) -> Option { - 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 { +fn get_writable_roots(cwd: &Path) -> Vec { 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 { } } - if let Ok(cwd) = std::env::current_dir() { - writable_roots.push(cwd); - } + writable_roots.push(cwd.to_path_buf()); writable_roots } diff --git a/codex-rs/core/src/codex_wrapper.rs b/codex-rs/core/src/codex_wrapper.rs index 223b051d..1481a019 100644 --- a/codex-rs/core/src/codex_wrapper.rs +++ b/codex-rs/core/src/codex_wrapper.rs @@ -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?; diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 0ab77ada..1557ce27 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -52,6 +52,11 @@ pub struct Config { /// /// If unset the feature is disabled. pub notify: Option>, + + /// 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, + pub cwd: Option, pub approval_policy: Option, pub sandbox_policy: Option, pub disable_response_storage: Option, @@ -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), diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index cf5fbd61..e6ebc31d 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -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, - pub workdir: Option, - - /// 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, } @@ -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, sandbox_policy: &SandboxPolicy, + cwd: &Path, ) -> Vec { 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::::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, Vec) = 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, @@ -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 diff --git a/codex-rs/core/src/linux.rs b/codex-rs/core/src/linux.rs index fac3ab30..a69f5619 100644 --- a/codex-rs/core/src/linux.rs +++ b/codex-rs/core/src/linux.rs @@ -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, ¶ms.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 2‑second timeout so even slow DNS timeouts // do not stall the suite. timeout_ms: Some(2_000), diff --git a/codex-rs/core/src/models.rs b/codex-rs/core/src/models.rs index 2665e8c1..b1a131da 100644 --- a/codex-rs/core/src/models.rs +++ b/codex-rs/core/src/models.rs @@ -102,6 +102,20 @@ impl From> 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, + pub workdir: Option, + + /// 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, +} + #[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 + ); + } } diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index d19a5386..851d80e2 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -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>, + + /// 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 { + pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec { let mut writable_roots = Vec::::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, /// The command's working directory if not the default cwd for the /// agent. - cwd: String, + cwd: PathBuf, }, ExecCommandEnd { diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 50ed3573..3d98be6c 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -22,6 +22,7 @@ pub fn assess_patch_safety( changes: &HashMap, 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 { fn is_write_patch_constrained_to_writable_paths( changes: &HashMap, writable_roots: &[PathBuf], + cwd: &Path, ) -> bool { // Early‑exit 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, )) } } diff --git a/codex-rs/core/tests/live_agent.rs b/codex-rs/core/tests/live_agent.rs index b780a287..596e8e6c 100644 --- a/codex-rs/core/tests/live_agent.rs +++ b/codex-rs/core/tests/live_agent.rs @@ -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 diff --git a/codex-rs/core/tests/previous_response_id.rs b/codex-rs/core/tests/previous_response_id.rs index 9410f7b5..830cda09 100644 --- a/codex-rs/core/tests/previous_response_id.rs +++ b/codex-rs/core/tests/previous_response_id.rs @@ -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 diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs index 858850f9..adadd079 100644 --- a/codex-rs/core/tests/stream_no_completed.rs +++ b/codex-rs/core/tests/stream_no_completed.rs @@ -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 diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 1b32b522..4443fd30 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -21,6 +21,10 @@ pub struct Cli { #[clap(flatten)] pub sandbox: SandboxPermissionOption, + /// Tell the agent to use the specified directory as its working root. + #[clap(long = "cd", short = 'C', value_name = "DIR")] + pub cwd: Option, + /// Allow running Codex outside a Git repository. #[arg(long = "skip-git-repo-check", default_value_t = false)] pub skip_git_repo_check: bool, diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index 9abdc96a..41b0af66 100644 --- a/codex-rs/exec/src/event_processor.rs +++ b/codex-rs/exec/src/event_processor.rs @@ -113,7 +113,7 @@ impl EventProcessor { "{} {} in {}", "exec".style(self.magenta), escape_command(&command).style(self.bold), - cwd, + cwd.to_string_lossy(), ); } EventMsg::ExecCommandEnd { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 1541102e..4f9c94b5 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -29,6 +29,7 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> { model, full_auto, sandbox, + cwd, skip_git_repo_check, disable_response_storage, color, @@ -81,6 +82,7 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> { } else { None }, + cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)), }; let config = Config::load_with_overrides(overrides)?; let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex(config).await?; diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 43a1f5b1..b180c503 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -28,6 +28,10 @@ pub struct Cli { #[clap(flatten)] pub sandbox: SandboxPermissionOption, + /// Tell the agent to use the specified directory as its working root. + #[clap(long = "cd", short = 'C', value_name = "DIR")] + pub cwd: Option, + /// Allow running Codex outside a Git repository. #[arg(long = "skip-git-repo-check", default_value_t = false)] pub skip_git_repo_check: bool, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e23b8c69..4c4f4e91 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -56,6 +56,7 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> { } else { None }, + cwd: cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)), }; #[allow(clippy::print_stderr)] match Config::load_with_overrides(overrides) {