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:
@@ -18,7 +18,8 @@ pub fn run_landlock(command: Vec<String>, sandbox_policy: SandboxPolicy) -> anyh
|
||||
|
||||
// Spawn a new thread and apply the sandbox policies there.
|
||||
let handle = std::thread::spawn(move || -> anyhow::Result<ExitStatus> {
|
||||
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)
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ pub async fn run_seatbelt(
|
||||
command: Vec<String>,
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
// 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,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
|
||||
/// Allow running Codex outside a Git repository.
|
||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||
pub skip_git_repo_check: bool,
|
||||
|
||||
@@ -113,7 +113,7 @@ impl EventProcessor {
|
||||
"{} {} in {}",
|
||||
"exec".style(self.magenta),
|
||||
escape_command(&command).style(self.bold),
|
||||
cwd,
|
||||
cwd.to_string_lossy(),
|
||||
);
|
||||
}
|
||||
EventMsg::ExecCommandEnd {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
|
||||
/// Allow running Codex outside a Git repository.
|
||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||
pub skip_git_repo_check: bool,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user