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.
|
// Spawn a new thread and apply the sandbox policies there.
|
||||||
let handle = std::thread::spawn(move || -> anyhow::Result<ExitStatus> {
|
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()?;
|
let status = Command::new(&command[0]).args(&command[1..]).status()?;
|
||||||
Ok(status)
|
Ok(status)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ pub async fn run_seatbelt(
|
|||||||
command: Vec<String>,
|
command: Vec<String>,
|
||||||
sandbox_policy: SandboxPolicy,
|
sandbox_policy: SandboxPolicy,
|
||||||
) -> anyhow::Result<()> {
|
) -> 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())
|
let status = tokio::process::Command::new(seatbelt_command[0].clone())
|
||||||
.args(&seatbelt_command[1..])
|
.args(&seatbelt_command[1..])
|
||||||
.spawn()
|
.spawn()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use tokio::sync::oneshot;
|
|||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
use tokio::task::AbortHandle;
|
use tokio::task::AbortHandle;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
use tracing::error;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
@@ -40,6 +41,7 @@ use crate::models::ContentItem;
|
|||||||
use crate::models::FunctionCallOutputPayload;
|
use crate::models::FunctionCallOutputPayload;
|
||||||
use crate::models::ResponseInputItem;
|
use crate::models::ResponseInputItem;
|
||||||
use crate::models::ResponseItem;
|
use crate::models::ResponseItem;
|
||||||
|
use crate::models::ShellToolCallParams;
|
||||||
use crate::protocol::AskForApproval;
|
use crate::protocol::AskForApproval;
|
||||||
use crate::protocol::Event;
|
use crate::protocol::Event;
|
||||||
use crate::protocol::EventMsg;
|
use crate::protocol::EventMsg;
|
||||||
@@ -190,6 +192,10 @@ struct Session {
|
|||||||
tx_event: Sender<Event>,
|
tx_event: Sender<Event>,
|
||||||
ctrl_c: Arc<Notify>,
|
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>,
|
instructions: Option<String>,
|
||||||
approval_policy: AskForApproval,
|
approval_policy: AskForApproval,
|
||||||
sandbox_policy: SandboxPolicy,
|
sandbox_policy: SandboxPolicy,
|
||||||
@@ -198,10 +204,17 @@ struct Session {
|
|||||||
/// External notifier command (will be passed as args to exec()). When
|
/// External notifier command (will be passed as args to exec()). When
|
||||||
/// `None` this feature is disabled.
|
/// `None` this feature is disabled.
|
||||||
notify: Option<Vec<String>>,
|
notify: Option<Vec<String>>,
|
||||||
|
|
||||||
state: Mutex<State>,
|
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
|
/// Mutable state of the agent
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct State {
|
struct State {
|
||||||
@@ -296,15 +309,8 @@ impl Session {
|
|||||||
sub_id: &str,
|
sub_id: &str,
|
||||||
call_id: &str,
|
call_id: &str,
|
||||||
command: Vec<String>,
|
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 {
|
let event = Event {
|
||||||
id: sub_id.to_string(),
|
id: sub_id.to_string(),
|
||||||
msg: EventMsg::ExecCommandBegin {
|
msg: EventMsg::ExecCommandBegin {
|
||||||
@@ -518,8 +524,22 @@ async fn submission_loop(
|
|||||||
sandbox_policy,
|
sandbox_policy,
|
||||||
disable_response_storage,
|
disable_response_storage,
|
||||||
notify,
|
notify,
|
||||||
|
cwd,
|
||||||
} => {
|
} => {
|
||||||
info!(model, "Configuring session");
|
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());
|
let client = ModelClient::new(model.clone());
|
||||||
|
|
||||||
// abort any current running session and clone its state
|
// 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 {
|
sess = Some(Arc::new(Session {
|
||||||
client,
|
client,
|
||||||
tx_event: tx_event.clone(),
|
tx_event: tx_event.clone(),
|
||||||
@@ -546,7 +566,8 @@ async fn submission_loop(
|
|||||||
instructions,
|
instructions,
|
||||||
approval_policy,
|
approval_policy,
|
||||||
sandbox_policy,
|
sandbox_policy,
|
||||||
writable_roots: Mutex::new(get_writable_roots()),
|
cwd,
|
||||||
|
writable_roots,
|
||||||
notify,
|
notify,
|
||||||
state: Mutex::new(state),
|
state: Mutex::new(state),
|
||||||
}));
|
}));
|
||||||
@@ -865,7 +886,7 @@ async fn handle_function_call(
|
|||||||
match name.as_str() {
|
match name.as_str() {
|
||||||
"container.exec" | "shell" => {
|
"container.exec" | "shell" => {
|
||||||
// parse command
|
// parse command
|
||||||
let params = match serde_json::from_str::<ExecParams>(&arguments) {
|
let params = match serde_json::from_str::<ShellToolCallParams>(&arguments) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// allow model to re-sample
|
// allow model to re-sample
|
||||||
@@ -904,12 +925,7 @@ async fn handle_function_call(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// this was not a valid patch, execute command
|
// this was not a valid patch, execute command
|
||||||
let repo_root = std::env::current_dir().expect("no current dir");
|
let workdir = sess.resolve_path(params.workdir.clone());
|
||||||
let workdir: PathBuf = params
|
|
||||||
.workdir
|
|
||||||
.as_ref()
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or(repo_root.clone());
|
|
||||||
|
|
||||||
// safety checks
|
// safety checks
|
||||||
let safety = {
|
let safety = {
|
||||||
@@ -968,12 +984,16 @@ async fn handle_function_call(
|
|||||||
&sub_id,
|
&sub_id,
|
||||||
&call_id,
|
&call_id,
|
||||||
params.command.clone(),
|
params.command.clone(),
|
||||||
params.workdir.clone(),
|
workdir.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let output_result = process_exec_tool_call(
|
let output_result = process_exec_tool_call(
|
||||||
params.clone(),
|
ExecParams {
|
||||||
|
command: params.command.clone(),
|
||||||
|
cwd: workdir.clone(),
|
||||||
|
timeout_ms: params.timeout_ms,
|
||||||
|
},
|
||||||
sandbox_type,
|
sandbox_type,
|
||||||
sess.ctrl_c.clone(),
|
sess.ctrl_c.clone(),
|
||||||
&sess.sandbox_policy,
|
&sess.sandbox_policy,
|
||||||
@@ -1051,18 +1071,23 @@ async fn handle_function_call(
|
|||||||
|
|
||||||
// Emit a fresh Begin event so progress bars reset.
|
// Emit a fresh Begin event so progress bars reset.
|
||||||
let retry_call_id = format!("{call_id}-retry");
|
let retry_call_id = format!("{call_id}-retry");
|
||||||
|
let cwd = sess.resolve_path(params.workdir.clone());
|
||||||
sess.notify_exec_command_begin(
|
sess.notify_exec_command_begin(
|
||||||
&sub_id,
|
&sub_id,
|
||||||
&retry_call_id,
|
&retry_call_id,
|
||||||
params.command.clone(),
|
params.command.clone(),
|
||||||
params.workdir.clone(),
|
cwd.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// This is an escalated retry; the policy will not be
|
// This is an escalated retry; the policy will not be
|
||||||
// examined and the sandbox has been set to `None`.
|
// examined and the sandbox has been set to `None`.
|
||||||
let retry_output_result = process_exec_tool_call(
|
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,
|
SandboxType::None,
|
||||||
sess.ctrl_c.clone(),
|
sess.ctrl_c.clone(),
|
||||||
&sess.sandbox_policy,
|
&sess.sandbox_policy,
|
||||||
@@ -1162,43 +1187,47 @@ async fn apply_patch(
|
|||||||
guard.clone()
|
guard.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let auto_approved =
|
let auto_approved = match assess_patch_safety(
|
||||||
match assess_patch_safety(&changes, sess.approval_policy, &writable_roots_snapshot) {
|
&changes,
|
||||||
SafetyCheck::AutoApprove { .. } => true,
|
sess.approval_policy,
|
||||||
SafetyCheck::AskUser => {
|
&writable_roots_snapshot,
|
||||||
// Compute a readable summary of path changes to include in the
|
&sess.cwd,
|
||||||
// approval request so the user can make an informed decision.
|
) {
|
||||||
let rx_approve = sess
|
SafetyCheck::AutoApprove { .. } => true,
|
||||||
.request_patch_approval(sub_id.clone(), &changes, None, None)
|
SafetyCheck::AskUser => {
|
||||||
.await;
|
// Compute a readable summary of path changes to include in the
|
||||||
match rx_approve.await.unwrap_or_default() {
|
// approval request so the user can make an informed decision.
|
||||||
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false,
|
let rx_approve = sess
|
||||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
.request_patch_approval(sub_id.clone(), &changes, None, None)
|
||||||
return ResponseInputItem::FunctionCallOutput {
|
.await;
|
||||||
call_id,
|
match rx_approve.await.unwrap_or_default() {
|
||||||
output: FunctionCallOutputPayload {
|
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false,
|
||||||
content: "patch rejected by user".to_string(),
|
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||||
success: Some(false),
|
return ResponseInputItem::FunctionCallOutput {
|
||||||
},
|
call_id,
|
||||||
};
|
output: FunctionCallOutputPayload {
|
||||||
}
|
content: "patch rejected by user".to_string(),
|
||||||
|
success: Some(false),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SafetyCheck::Reject { reason } => {
|
}
|
||||||
return ResponseInputItem::FunctionCallOutput {
|
SafetyCheck::Reject { reason } => {
|
||||||
call_id,
|
return ResponseInputItem::FunctionCallOutput {
|
||||||
output: FunctionCallOutputPayload {
|
call_id,
|
||||||
content: format!("patch rejected: {reason}"),
|
output: FunctionCallOutputPayload {
|
||||||
success: Some(false),
|
content: format!("patch rejected: {reason}"),
|
||||||
},
|
success: Some(false),
|
||||||
};
|
},
|
||||||
}
|
};
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Verify write permissions before touching the filesystem.
|
// Verify write permissions before touching the filesystem.
|
||||||
let writable_snapshot = { sess.writable_roots.lock().unwrap().clone() };
|
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 root = offending.parent().unwrap_or(&offending).to_path_buf();
|
||||||
|
|
||||||
let reason = Some(format!(
|
let reason = Some(format!(
|
||||||
@@ -1255,11 +1284,13 @@ async fn apply_patch(
|
|||||||
ApplyPatchFileChange::Update { .. } => path,
|
ApplyPatchFileChange::Update { .. } => path,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reuse safety normalisation logic: treat absolute path.
|
// Reuse safety normalization logic: treat absolute path.
|
||||||
let abs = if path_ref.is_absolute() {
|
let abs = if path_ref.is_absolute() {
|
||||||
path_ref.clone()
|
path_ref.clone()
|
||||||
} else {
|
} 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 = {
|
let writable = {
|
||||||
@@ -1345,9 +1376,8 @@ async fn apply_patch(
|
|||||||
fn first_offending_path(
|
fn first_offending_path(
|
||||||
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
|
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
|
||||||
writable_roots: &[PathBuf],
|
writable_roots: &[PathBuf],
|
||||||
|
cwd: &Path,
|
||||||
) -> Option<PathBuf> {
|
) -> Option<PathBuf> {
|
||||||
let cwd = std::env::current_dir().unwrap_or_default();
|
|
||||||
|
|
||||||
for (path, change) in changes {
|
for (path, change) in changes {
|
||||||
let candidate = match change {
|
let candidate = match change {
|
||||||
ApplyPatchFileChange::Add { .. } => path,
|
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();
|
let mut writable_roots = Vec::new();
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
// On macOS, $TMPDIR is private to the user.
|
// 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.to_path_buf());
|
||||||
writable_roots.push(cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
writable_roots
|
writable_roots
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub async fn init_codex(config: Config) -> anyhow::Result<(CodexWrapper, Event,
|
|||||||
sandbox_policy: config.sandbox_policy,
|
sandbox_policy: config.sandbox_policy,
|
||||||
disable_response_storage: config.disable_response_storage,
|
disable_response_storage: config.disable_response_storage,
|
||||||
notify: config.notify.clone(),
|
notify: config.notify.clone(),
|
||||||
|
cwd: config.cwd.clone(),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ pub struct Config {
|
|||||||
///
|
///
|
||||||
/// If unset the feature is disabled.
|
/// If unset the feature is disabled.
|
||||||
pub notify: Option<Vec<String>>,
|
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.
|
/// Base config deserialized from ~/.codex/config.toml.
|
||||||
@@ -135,6 +140,7 @@ where
|
|||||||
#[derive(Default, Debug, Clone)]
|
#[derive(Default, Debug, Clone)]
|
||||||
pub struct ConfigOverrides {
|
pub struct ConfigOverrides {
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
|
pub cwd: Option<PathBuf>,
|
||||||
pub approval_policy: Option<AskForApproval>,
|
pub approval_policy: Option<AskForApproval>,
|
||||||
pub sandbox_policy: Option<SandboxPolicy>,
|
pub sandbox_policy: Option<SandboxPolicy>,
|
||||||
pub disable_response_storage: Option<bool>,
|
pub disable_response_storage: Option<bool>,
|
||||||
@@ -158,6 +164,7 @@ impl Config {
|
|||||||
// Destructure ConfigOverrides fully to ensure all overrides are applied.
|
// Destructure ConfigOverrides fully to ensure all overrides are applied.
|
||||||
let ConfigOverrides {
|
let ConfigOverrides {
|
||||||
model,
|
model,
|
||||||
|
cwd,
|
||||||
approval_policy,
|
approval_policy,
|
||||||
sandbox_policy,
|
sandbox_policy,
|
||||||
disable_response_storage,
|
disable_response_storage,
|
||||||
@@ -180,6 +187,23 @@ impl Config {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
model: model.or(cfg.model).unwrap_or_else(default_model),
|
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
|
approval_policy: approval_policy
|
||||||
.or(cfg.approval_policy)
|
.or(cfg.approval_policy)
|
||||||
.unwrap_or_else(AskForApproval::default),
|
.unwrap_or_else(AskForApproval::default),
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
use std::io;
|
use std::io;
|
||||||
#[cfg(target_family = "unix")]
|
#[cfg(target_family = "unix")]
|
||||||
use std::os::unix::process::ExitStatusExt;
|
use std::os::unix::process::ExitStatusExt;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::process::ExitStatus;
|
use std::process::ExitStatus;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tokio::io::AsyncRead;
|
use tokio::io::AsyncRead;
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
use tokio::io::BufReader;
|
use tokio::io::BufReader;
|
||||||
@@ -40,15 +41,10 @@ const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl
|
|||||||
/// already has root access.
|
/// already has root access.
|
||||||
const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";
|
const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ExecParams {
|
pub struct ExecParams {
|
||||||
pub command: Vec<String>,
|
pub command: Vec<String>,
|
||||||
pub workdir: Option<String>,
|
pub cwd: PathBuf,
|
||||||
|
|
||||||
/// 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>,
|
pub timeout_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,14 +93,14 @@ pub async fn process_exec_tool_call(
|
|||||||
SandboxType::MacosSeatbelt => {
|
SandboxType::MacosSeatbelt => {
|
||||||
let ExecParams {
|
let ExecParams {
|
||||||
command,
|
command,
|
||||||
workdir,
|
cwd,
|
||||||
timeout_ms,
|
timeout_ms,
|
||||||
} = params;
|
} = params;
|
||||||
let seatbelt_command = create_seatbelt_command(command, sandbox_policy);
|
let seatbelt_command = create_seatbelt_command(command, sandbox_policy, &cwd);
|
||||||
exec(
|
exec(
|
||||||
ExecParams {
|
ExecParams {
|
||||||
command: seatbelt_command,
|
command: seatbelt_command,
|
||||||
workdir,
|
cwd,
|
||||||
timeout_ms,
|
timeout_ms,
|
||||||
},
|
},
|
||||||
ctrl_c,
|
ctrl_c,
|
||||||
@@ -157,6 +153,7 @@ pub async fn process_exec_tool_call(
|
|||||||
pub fn create_seatbelt_command(
|
pub fn create_seatbelt_command(
|
||||||
command: Vec<String>,
|
command: Vec<String>,
|
||||||
sandbox_policy: &SandboxPolicy,
|
sandbox_policy: &SandboxPolicy,
|
||||||
|
cwd: &Path,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
let (file_write_policy, extra_cli_args) = {
|
let (file_write_policy, extra_cli_args) = {
|
||||||
if sandbox_policy.has_full_disk_write_access() {
|
if sandbox_policy.has_full_disk_write_access() {
|
||||||
@@ -166,7 +163,7 @@ pub fn create_seatbelt_command(
|
|||||||
Vec::<String>::new(),
|
Vec::<String>::new(),
|
||||||
)
|
)
|
||||||
} else {
|
} 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
|
let (writable_folder_policies, cli_args): (Vec<String>, Vec<String>) = writable_roots
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -234,7 +231,7 @@ pub struct ExecToolCallOutput {
|
|||||||
pub async fn exec(
|
pub async fn exec(
|
||||||
ExecParams {
|
ExecParams {
|
||||||
command,
|
command,
|
||||||
workdir,
|
cwd,
|
||||||
timeout_ms,
|
timeout_ms,
|
||||||
}: ExecParams,
|
}: ExecParams,
|
||||||
ctrl_c: Arc<Notify>,
|
ctrl_c: Arc<Notify>,
|
||||||
@@ -251,9 +248,7 @@ pub async fn exec(
|
|||||||
if command.len() > 1 {
|
if command.len() > 1 {
|
||||||
cmd.args(&command[1..]);
|
cmd.args(&command[1..]);
|
||||||
}
|
}
|
||||||
if let Some(dir) = &workdir {
|
cmd.current_dir(cwd);
|
||||||
cmd.current_dir(dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not create a file descriptor for stdin because otherwise some
|
// Do not create a file descriptor for stdin because otherwise some
|
||||||
// commands may hang forever waiting for input. For example, ripgrep has
|
// commands may hang forever waiting for input. For example, ripgrep has
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ pub async fn exec_linux(
|
|||||||
.expect("Failed to create runtime");
|
.expect("Failed to create runtime");
|
||||||
|
|
||||||
rt.block_on(async {
|
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
|
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
|
/// Apply sandbox policies inside this thread so only the child inherits
|
||||||
/// them, not the entire CLI process.
|
/// 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() {
|
if !sandbox_policy.has_full_network_access() {
|
||||||
install_network_seccomp_filter_on_current_thread()?;
|
install_network_seccomp_filter_on_current_thread()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !sandbox_policy.has_full_disk_write_access() {
|
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)?;
|
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) {
|
async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
|
||||||
let params = ExecParams {
|
let params = ExecParams {
|
||||||
command: cmd.iter().map(|elm| elm.to_string()).collect(),
|
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),
|
timeout_ms: Some(timeout_ms),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -262,7 +266,7 @@ mod tests_linux {
|
|||||||
async fn assert_network_blocked(cmd: &[&str]) {
|
async fn assert_network_blocked(cmd: &[&str]) {
|
||||||
let params = ExecParams {
|
let params = ExecParams {
|
||||||
command: cmd.iter().map(|s| s.to_string()).collect(),
|
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
|
// Give the tool a generous 2‑second timeout so even slow DNS timeouts
|
||||||
// do not stall the suite.
|
// do not stall the suite.
|
||||||
timeout_ms: Some(2_000),
|
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)]
|
#[expect(dead_code)]
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
pub struct FunctionCallOutputPayload {
|
pub struct FunctionCallOutputPayload {
|
||||||
@@ -183,4 +197,23 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(v.get("output").unwrap().as_str().unwrap(), "bad");
|
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.
|
//! between user and agent.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -43,6 +44,15 @@ pub enum Op {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
notify: Option<Vec<String>>,
|
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.
|
/// Abort current task.
|
||||||
@@ -157,7 +167,7 @@ impl SandboxPolicy {
|
|||||||
.any(|perm| matches!(perm, SandboxPermission::NetworkFullAccess))
|
.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();
|
let mut writable_roots = Vec::<PathBuf>::new();
|
||||||
for perm in &self.permissions {
|
for perm in &self.permissions {
|
||||||
use SandboxPermission::*;
|
use SandboxPermission::*;
|
||||||
@@ -193,12 +203,9 @@ impl SandboxPolicy {
|
|||||||
writable_roots.push(PathBuf::from("/tmp"));
|
writable_roots.push(PathBuf::from("/tmp"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DiskWriteCwd => match std::env::current_dir() {
|
DiskWriteCwd => {
|
||||||
Ok(cwd) => writable_roots.push(cwd),
|
writable_roots.push(cwd.to_path_buf());
|
||||||
Err(err) => {
|
}
|
||||||
tracing::error!("Failed to get current working directory: {err}");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
DiskWriteFolder { folder } => {
|
DiskWriteFolder { folder } => {
|
||||||
writable_roots.push(folder.clone());
|
writable_roots.push(folder.clone());
|
||||||
}
|
}
|
||||||
@@ -317,7 +324,7 @@ pub enum EventMsg {
|
|||||||
command: Vec<String>,
|
command: Vec<String>,
|
||||||
/// The command's working directory if not the default cwd for the
|
/// The command's working directory if not the default cwd for the
|
||||||
/// agent.
|
/// agent.
|
||||||
cwd: String,
|
cwd: PathBuf,
|
||||||
},
|
},
|
||||||
|
|
||||||
ExecCommandEnd {
|
ExecCommandEnd {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub fn assess_patch_safety(
|
|||||||
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
|
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
|
||||||
policy: AskForApproval,
|
policy: AskForApproval,
|
||||||
writable_roots: &[PathBuf],
|
writable_roots: &[PathBuf],
|
||||||
|
cwd: &Path,
|
||||||
) -> SafetyCheck {
|
) -> SafetyCheck {
|
||||||
if changes.is_empty() {
|
if changes.is_empty() {
|
||||||
return SafetyCheck::Reject {
|
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 {
|
SafetyCheck::AutoApprove {
|
||||||
sandbox_type: SandboxType::None,
|
sandbox_type: SandboxType::None,
|
||||||
}
|
}
|
||||||
@@ -115,6 +116,7 @@ pub fn get_platform_sandbox() -> Option<SandboxType> {
|
|||||||
fn is_write_patch_constrained_to_writable_paths(
|
fn is_write_patch_constrained_to_writable_paths(
|
||||||
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
|
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
|
||||||
writable_roots: &[PathBuf],
|
writable_roots: &[PathBuf],
|
||||||
|
cwd: &Path,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
// Early‑exit if there are no declared writable roots.
|
// Early‑exit if there are no declared writable roots.
|
||||||
if writable_roots.is_empty() {
|
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
|
// and roots are converted to absolute, normalized forms before the
|
||||||
// prefix check.
|
// prefix check.
|
||||||
let is_path_writable = |p: &PathBuf| {
|
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() {
|
let abs = if p.is_absolute() {
|
||||||
p.clone()
|
p.clone()
|
||||||
} else {
|
} else {
|
||||||
@@ -217,19 +214,22 @@ mod tests {
|
|||||||
|
|
||||||
assert!(is_write_patch_constrained_to_writable_paths(
|
assert!(is_write_patch_constrained_to_writable_paths(
|
||||||
&add_inside,
|
&add_inside,
|
||||||
&[PathBuf::from(".")]
|
&[PathBuf::from(".")],
|
||||||
|
&cwd,
|
||||||
));
|
));
|
||||||
|
|
||||||
let add_outside_2 = make_add_change(parent.join("outside.txt"));
|
let add_outside_2 = make_add_change(parent.join("outside.txt"));
|
||||||
assert!(!is_write_patch_constrained_to_writable_paths(
|
assert!(!is_write_patch_constrained_to_writable_paths(
|
||||||
&add_outside_2,
|
&add_outside_2,
|
||||||
&[PathBuf::from(".")]
|
&[PathBuf::from(".")],
|
||||||
|
&cwd,
|
||||||
));
|
));
|
||||||
|
|
||||||
// With parent dir added as writable root, it should pass.
|
// With parent dir added as writable root, it should pass.
|
||||||
assert!(is_write_patch_constrained_to_writable_paths(
|
assert!(is_write_patch_constrained_to_writable_paths(
|
||||||
&add_outside,
|
&add_outside,
|
||||||
&[PathBuf::from("..")]
|
&[PathBuf::from("..")],
|
||||||
|
&cwd,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ async fn spawn_codex() -> Codex {
|
|||||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||||
disable_response_storage: false,
|
disable_response_storage: false,
|
||||||
notify: None,
|
notify: None,
|
||||||
|
cwd: std::env::current_dir().unwrap(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ async fn keeps_previous_response_id_between_tasks() {
|
|||||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||||
disable_response_storage: false,
|
disable_response_storage: false,
|
||||||
notify: None,
|
notify: None,
|
||||||
|
cwd: std::env::current_dir().unwrap(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ async fn retries_on_early_close() {
|
|||||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||||
disable_response_storage: false,
|
disable_response_storage: false,
|
||||||
notify: None,
|
notify: None,
|
||||||
|
cwd: std::env::current_dir().unwrap(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ pub struct Cli {
|
|||||||
#[clap(flatten)]
|
#[clap(flatten)]
|
||||||
pub sandbox: SandboxPermissionOption,
|
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.
|
/// Allow running Codex outside a Git repository.
|
||||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||||
pub skip_git_repo_check: bool,
|
pub skip_git_repo_check: bool,
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ impl EventProcessor {
|
|||||||
"{} {} in {}",
|
"{} {} in {}",
|
||||||
"exec".style(self.magenta),
|
"exec".style(self.magenta),
|
||||||
escape_command(&command).style(self.bold),
|
escape_command(&command).style(self.bold),
|
||||||
cwd,
|
cwd.to_string_lossy(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
EventMsg::ExecCommandEnd {
|
EventMsg::ExecCommandEnd {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
|
|||||||
model,
|
model,
|
||||||
full_auto,
|
full_auto,
|
||||||
sandbox,
|
sandbox,
|
||||||
|
cwd,
|
||||||
skip_git_repo_check,
|
skip_git_repo_check,
|
||||||
disable_response_storage,
|
disable_response_storage,
|
||||||
color,
|
color,
|
||||||
@@ -81,6 +82,7 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)),
|
||||||
};
|
};
|
||||||
let config = Config::load_with_overrides(overrides)?;
|
let config = Config::load_with_overrides(overrides)?;
|
||||||
let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex(config).await?;
|
let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex(config).await?;
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ pub struct Cli {
|
|||||||
#[clap(flatten)]
|
#[clap(flatten)]
|
||||||
pub sandbox: SandboxPermissionOption,
|
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.
|
/// Allow running Codex outside a Git repository.
|
||||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||||
pub skip_git_repo_check: bool,
|
pub skip_git_repo_check: bool,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
cwd: cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)),
|
||||||
};
|
};
|
||||||
#[allow(clippy::print_stderr)]
|
#[allow(clippy::print_stderr)]
|
||||||
match Config::load_with_overrides(overrides) {
|
match Config::load_with_overrides(overrides) {
|
||||||
|
|||||||
Reference in New Issue
Block a user