mirror of
https://github.com/openai/codex.git
synced 2026-04-28 08:34:54 +00:00
544 lines
19 KiB
Markdown
544 lines
19 KiB
Markdown
# PR #2340: fix: introduce MutexExt::lock_unchecked() so we stop ignoring unwrap() throughout codex.rs
|
||
|
||
- URL: https://github.com/openai/codex/pull/2340
|
||
- Author: bolinfest
|
||
- Created: 2025-08-15 04:51:10 UTC
|
||
- Updated: 2025-08-15 16:14:53 UTC
|
||
- Changes: +106/-88, Files changed: 4, Commits: 2
|
||
|
||
## Description
|
||
|
||
This way we are sure a dangerous `unwrap()` does not sneak in!
|
||
|
||
|
||
|
||
|
||
|
||
|
||
---
|
||
[//]: # (BEGIN SAPLING FOOTER)
|
||
Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/2340).
|
||
* #2345
|
||
* #2329
|
||
* #2343
|
||
* __->__ #2340
|
||
* #2338
|
||
|
||
## Full Diff
|
||
|
||
```diff
|
||
diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs
|
||
index 21e80406e5..fcccb40f8c 100644
|
||
--- a/codex-rs/core/src/apply_patch.rs
|
||
+++ b/codex-rs/core/src/apply_patch.rs
|
||
@@ -8,7 +8,6 @@ use crate::safety::assess_patch_safety;
|
||
use codex_apply_patch::ApplyPatchAction;
|
||
use codex_apply_patch::ApplyPatchFileChange;
|
||
use std::collections::HashMap;
|
||
-use std::path::Path;
|
||
use std::path::PathBuf;
|
||
|
||
pub const CODEX_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch";
|
||
@@ -45,12 +44,10 @@ pub(crate) async fn apply_patch(
|
||
call_id: &str,
|
||
action: ApplyPatchAction,
|
||
) -> InternalApplyPatchInvocation {
|
||
- let writable_roots_snapshot = sess.get_writable_roots().to_vec();
|
||
-
|
||
match assess_patch_safety(
|
||
&action,
|
||
sess.get_approval_policy(),
|
||
- &writable_roots_snapshot,
|
||
+ sess.get_sandbox_policy(),
|
||
sess.get_cwd(),
|
||
) {
|
||
SafetyCheck::AutoApprove { .. } => {
|
||
@@ -124,30 +121,3 @@ pub(crate) fn convert_apply_patch_to_protocol(
|
||
}
|
||
result
|
||
}
|
||
-
|
||
-pub(crate) fn get_writable_roots(cwd: &Path) -> Vec<PathBuf> {
|
||
- let mut writable_roots = Vec::new();
|
||
- if cfg!(target_os = "macos") {
|
||
- // On macOS, $TMPDIR is private to the user.
|
||
- writable_roots.push(std::env::temp_dir());
|
||
-
|
||
- // Allow pyenv to update its shims directory. Without this, any tool
|
||
- // that happens to be managed by `pyenv` will fail with an error like:
|
||
- //
|
||
- // pyenv: cannot rehash: $HOME/.pyenv/shims isn't writable
|
||
- //
|
||
- // which is emitted every time `pyenv` tries to run `rehash` (for
|
||
- // example, after installing a new Python package that drops an entry
|
||
- // point). Although the sandbox is intentionally read‑only by default,
|
||
- // writing to the user's local `pyenv` directory is safe because it
|
||
- // is already user‑writable and scoped to the current user account.
|
||
- if let Ok(home_dir) = std::env::var("HOME") {
|
||
- let pyenv_dir = PathBuf::from(home_dir).join(".pyenv");
|
||
- writable_roots.push(pyenv_dir);
|
||
- }
|
||
- }
|
||
-
|
||
- writable_roots.push(cwd.to_path_buf());
|
||
-
|
||
- writable_roots
|
||
-}
|
||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||
index 482ac2f1ab..6b6ba9ad7a 100644
|
||
--- a/codex-rs/core/src/codex.rs
|
||
+++ b/codex-rs/core/src/codex.rs
|
||
@@ -1,6 +1,3 @@
|
||
-// Poisoned mutex should fail the program
|
||
-#![expect(clippy::unwrap_used)]
|
||
-
|
||
use std::borrow::Cow;
|
||
use std::collections::HashMap;
|
||
use std::collections::HashSet;
|
||
@@ -8,6 +5,7 @@ use std::path::Path;
|
||
use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
use std::sync::Mutex;
|
||
+use std::sync::MutexGuard;
|
||
use std::sync::atomic::AtomicU64;
|
||
use std::time::Duration;
|
||
|
||
@@ -31,12 +29,11 @@ use tracing::warn;
|
||
use uuid::Uuid;
|
||
|
||
use crate::ModelProviderInfo;
|
||
+use crate::apply_patch;
|
||
use crate::apply_patch::ApplyPatchExec;
|
||
use crate::apply_patch::CODEX_APPLY_PATCH_ARG1;
|
||
use crate::apply_patch::InternalApplyPatchInvocation;
|
||
use crate::apply_patch::convert_apply_patch_to_protocol;
|
||
-use crate::apply_patch::get_writable_roots;
|
||
-use crate::apply_patch::{self};
|
||
use crate::client::ModelClient;
|
||
use crate::client_common::Prompt;
|
||
use crate::client_common::ResponseEvent;
|
||
@@ -108,6 +105,21 @@ use crate::turn_diff_tracker::TurnDiffTracker;
|
||
use crate::user_notification::UserNotification;
|
||
use crate::util::backoff;
|
||
|
||
+// A convenience extension trait for acquiring mutex locks where poisoning is
|
||
+// unrecoverable and should abort the program. This avoids scattered `.unwrap()`
|
||
+// calls on `lock()` while still surfacing a clear panic message when a lock is
|
||
+// poisoned.
|
||
+trait MutexExt<T> {
|
||
+ fn lock_unchecked(&self) -> MutexGuard<'_, T>;
|
||
+}
|
||
+
|
||
+impl<T> MutexExt<T> for Mutex<T> {
|
||
+ fn lock_unchecked(&self) -> MutexGuard<'_, T> {
|
||
+ #[expect(clippy::expect_used)]
|
||
+ self.lock().expect("poisoned lock")
|
||
+ }
|
||
+}
|
||
+
|
||
/// The high-level interface to the Codex system.
|
||
/// It operates as a queue pair where you send submissions and receive events.
|
||
pub struct Codex {
|
||
@@ -230,7 +242,6 @@ pub(crate) struct Session {
|
||
approval_policy: AskForApproval,
|
||
sandbox_policy: SandboxPolicy,
|
||
shell_environment_policy: ShellEnvironmentPolicy,
|
||
- writable_roots: Vec<PathBuf>,
|
||
disable_response_storage: bool,
|
||
tools_config: ToolsConfig,
|
||
|
||
@@ -409,8 +420,6 @@ impl Session {
|
||
state.history.record_items(&restored_items);
|
||
}
|
||
|
||
- let writable_roots = get_writable_roots(&cwd);
|
||
-
|
||
// Handle MCP manager result and record any startup failures.
|
||
let (mcp_connection_manager, failed_clients) = match mcp_res {
|
||
Ok((mgr, failures)) => (mgr, failures),
|
||
@@ -463,7 +472,6 @@ impl Session {
|
||
sandbox_policy,
|
||
shell_environment_policy: config.shell_environment_policy.clone(),
|
||
cwd,
|
||
- writable_roots,
|
||
mcp_connection_manager,
|
||
notify,
|
||
state: Mutex::new(state),
|
||
@@ -507,14 +515,14 @@ impl Session {
|
||
Ok(sess)
|
||
}
|
||
|
||
- pub(crate) fn get_writable_roots(&self) -> &[PathBuf] {
|
||
- &self.writable_roots
|
||
- }
|
||
-
|
||
pub(crate) fn get_approval_policy(&self) -> AskForApproval {
|
||
self.approval_policy
|
||
}
|
||
|
||
+ pub(crate) fn get_sandbox_policy(&self) -> &SandboxPolicy {
|
||
+ &self.sandbox_policy
|
||
+ }
|
||
+
|
||
pub(crate) fn get_cwd(&self) -> &Path {
|
||
&self.cwd
|
||
}
|
||
@@ -526,7 +534,7 @@ impl Session {
|
||
}
|
||
|
||
pub fn set_task(&self, task: AgentTask) {
|
||
- let mut state = self.state.lock().unwrap();
|
||
+ let mut state = self.state.lock_unchecked();
|
||
if let Some(current_task) = state.current_task.take() {
|
||
current_task.abort();
|
||
}
|
||
@@ -534,7 +542,7 @@ impl Session {
|
||
}
|
||
|
||
pub fn remove_task(&self, sub_id: &str) {
|
||
- let mut state = self.state.lock().unwrap();
|
||
+ let mut state = self.state.lock_unchecked();
|
||
if let Some(task) = &state.current_task {
|
||
if task.sub_id == sub_id {
|
||
state.current_task.take();
|
||
@@ -570,7 +578,7 @@ impl Session {
|
||
};
|
||
let _ = self.tx_event.send(event).await;
|
||
{
|
||
- let mut state = self.state.lock().unwrap();
|
||
+ let mut state = self.state.lock_unchecked();
|
||
state.pending_approvals.insert(sub_id, tx_approve);
|
||
}
|
||
rx_approve
|
||
@@ -596,21 +604,21 @@ impl Session {
|
||
};
|
||
let _ = self.tx_event.send(event).await;
|
||
{
|
||
- let mut state = self.state.lock().unwrap();
|
||
+ let mut state = self.state.lock_unchecked();
|
||
state.pending_approvals.insert(sub_id, tx_approve);
|
||
}
|
||
rx_approve
|
||
}
|
||
|
||
pub fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) {
|
||
- let mut state = self.state.lock().unwrap();
|
||
+ let mut state = self.state.lock_unchecked();
|
||
if let Some(tx_approve) = state.pending_approvals.remove(sub_id) {
|
||
tx_approve.send(decision).ok();
|
||
}
|
||
}
|
||
|
||
pub fn add_approved_command(&self, cmd: Vec<String>) {
|
||
- let mut state = self.state.lock().unwrap();
|
||
+ let mut state = self.state.lock_unchecked();
|
||
state.approved_commands.insert(cmd);
|
||
}
|
||
|
||
@@ -620,14 +628,14 @@ impl Session {
|
||
debug!("Recording items for conversation: {items:?}");
|
||
self.record_state_snapshot(items).await;
|
||
|
||
- self.state.lock().unwrap().history.record_items(items);
|
||
+ self.state.lock_unchecked().history.record_items(items);
|
||
}
|
||
|
||
async fn record_state_snapshot(&self, items: &[ResponseItem]) {
|
||
let snapshot = { crate::rollout::SessionStateSnapshot {} };
|
||
|
||
let recorder = {
|
||
- let guard = self.rollout.lock().unwrap();
|
||
+ let guard = self.rollout.lock_unchecked();
|
||
guard.as_ref().cloned()
|
||
};
|
||
|
||
@@ -805,12 +813,12 @@ impl Session {
|
||
/// Build the full turn input by concatenating the current conversation
|
||
/// history with additional items for this turn.
|
||
pub fn turn_input_with_history(&self, extra: Vec<ResponseItem>) -> Vec<ResponseItem> {
|
||
- [self.state.lock().unwrap().history.contents(), extra].concat()
|
||
+ [self.state.lock_unchecked().history.contents(), extra].concat()
|
||
}
|
||
|
||
/// Returns the input if there was no task running to inject into
|
||
pub fn inject_input(&self, input: Vec<InputItem>) -> Result<(), Vec<InputItem>> {
|
||
- let mut state = self.state.lock().unwrap();
|
||
+ let mut state = self.state.lock_unchecked();
|
||
if state.current_task.is_some() {
|
||
state.pending_input.push(input.into());
|
||
Ok(())
|
||
@@ -820,7 +828,7 @@ impl Session {
|
||
}
|
||
|
||
pub fn get_pending_input(&self) -> Vec<ResponseInputItem> {
|
||
- let mut state = self.state.lock().unwrap();
|
||
+ let mut state = self.state.lock_unchecked();
|
||
if state.pending_input.is_empty() {
|
||
Vec::with_capacity(0)
|
||
} else {
|
||
@@ -844,7 +852,7 @@ impl Session {
|
||
|
||
fn abort(&self) {
|
||
info!("Aborting existing session");
|
||
- let mut state = self.state.lock().unwrap();
|
||
+ let mut state = self.state.lock_unchecked();
|
||
state.pending_approvals.clear();
|
||
state.pending_input.clear();
|
||
if let Some(task) = state.current_task.take() {
|
||
@@ -1048,7 +1056,7 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||
|
||
// Gracefully flush and shutdown rollout recorder on session end so tests
|
||
// that inspect the rollout file do not race with the background writer.
|
||
- let recorder_opt = sess.rollout.lock().unwrap().take();
|
||
+ let recorder_opt = sess.rollout.lock_unchecked().take();
|
||
if let Some(rec) = recorder_opt {
|
||
if let Err(e) = rec.shutdown().await {
|
||
warn!("failed to shutdown rollout recorder: {e}");
|
||
@@ -1464,7 +1472,7 @@ async fn try_run_turn(
|
||
}
|
||
ResponseEvent::OutputTextDelta(delta) => {
|
||
{
|
||
- let mut st = sess.state.lock().unwrap();
|
||
+ let mut st = sess.state.lock_unchecked();
|
||
st.history.append_assistant_text(&delta);
|
||
}
|
||
|
||
@@ -1580,7 +1588,7 @@ async fn run_compact_task(
|
||
};
|
||
sess.send_event(event).await;
|
||
|
||
- let mut state = sess.state.lock().unwrap();
|
||
+ let mut state = sess.state.lock_unchecked();
|
||
state.history.keep_last_messages(1);
|
||
}
|
||
|
||
@@ -1620,8 +1628,9 @@ async fn handle_response_item(
|
||
};
|
||
sess.tx_event.send(event).await.ok();
|
||
}
|
||
- if sess.show_raw_agent_reasoning && content.is_some() {
|
||
- let content = content.unwrap();
|
||
+ if sess.show_raw_agent_reasoning
|
||
+ && let Some(content) = content
|
||
+ {
|
||
for item in content {
|
||
let text = match item {
|
||
ReasoningItemContent::ReasoningText { text } => text,
|
||
@@ -1891,7 +1900,7 @@ async fn handle_container_exec_with_params(
|
||
}
|
||
None => {
|
||
let safety = {
|
||
- let state = sess.state.lock().unwrap();
|
||
+ let state = sess.state.lock_unchecked();
|
||
assess_command_safety(
|
||
¶ms.command,
|
||
sess.approval_policy,
|
||
@@ -2231,7 +2240,7 @@ async fn drain_to_completed(sess: &Session, sub_id: &str, prompt: &Prompt) -> Co
|
||
match event {
|
||
Ok(ResponseEvent::OutputItemDone(item)) => {
|
||
// Record only to in-memory conversation history; avoid state snapshot.
|
||
- let mut state = sess.state.lock().unwrap();
|
||
+ let mut state = sess.state.lock_unchecked();
|
||
state.history.record_items(std::slice::from_ref(&item));
|
||
}
|
||
Ok(ResponseEvent::Completed {
|
||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||
index ac95b6a20a..1d264d3ed1 100644
|
||
--- a/codex-rs/core/src/protocol.rs
|
||
+++ b/codex-rs/core/src/protocol.rs
|
||
@@ -156,10 +156,31 @@ pub enum SandboxPolicy {
|
||
/// not modified by the agent.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct WritableRoot {
|
||
+ /// Absolute path, by construction.
|
||
pub root: PathBuf,
|
||
+
|
||
+ /// Also absolute paths, by construction.
|
||
pub read_only_subpaths: Vec<PathBuf>,
|
||
}
|
||
|
||
+impl WritableRoot {
|
||
+ pub(crate) fn is_path_writable(&self, path: &Path) -> bool {
|
||
+ // Check if the path is under the root.
|
||
+ if !path.starts_with(&self.root) {
|
||
+ return false;
|
||
+ }
|
||
+
|
||
+ // Check if the path is under any of the read-only subpaths.
|
||
+ for subpath in &self.read_only_subpaths {
|
||
+ if path.starts_with(subpath) {
|
||
+ return false;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ true
|
||
+ }
|
||
+}
|
||
+
|
||
impl FromStr for SandboxPolicy {
|
||
type Err = serde_json::Error;
|
||
|
||
diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs
|
||
index 74872ddc4f..c878a71110 100644
|
||
--- a/codex-rs/core/src/safety.rs
|
||
+++ b/codex-rs/core/src/safety.rs
|
||
@@ -21,7 +21,7 @@ pub enum SafetyCheck {
|
||
pub fn assess_patch_safety(
|
||
action: &ApplyPatchAction,
|
||
policy: AskForApproval,
|
||
- writable_roots: &[PathBuf],
|
||
+ sandbox_policy: &SandboxPolicy,
|
||
cwd: &Path,
|
||
) -> SafetyCheck {
|
||
if action.is_empty() {
|
||
@@ -45,7 +45,7 @@ pub fn assess_patch_safety(
|
||
// is possible that paths in the patch are hard links to files outside the
|
||
// writable roots, so we should still run `apply_patch` in a sandbox in that
|
||
// case.
|
||
- if is_write_patch_constrained_to_writable_paths(action, writable_roots, cwd)
|
||
+ if is_write_patch_constrained_to_writable_paths(action, sandbox_policy, cwd)
|
||
|| policy == AskForApproval::OnFailure
|
||
{
|
||
// Only auto‑approve when we can actually enforce a sandbox. Otherwise
|
||
@@ -171,13 +171,19 @@ pub fn get_platform_sandbox() -> Option<SandboxType> {
|
||
|
||
fn is_write_patch_constrained_to_writable_paths(
|
||
action: &ApplyPatchAction,
|
||
- writable_roots: &[PathBuf],
|
||
+ sandbox_policy: &SandboxPolicy,
|
||
cwd: &Path,
|
||
) -> bool {
|
||
// Early‑exit if there are no declared writable roots.
|
||
- if writable_roots.is_empty() {
|
||
- return false;
|
||
- }
|
||
+ let writable_roots = match sandbox_policy {
|
||
+ SandboxPolicy::ReadOnly => {
|
||
+ return false;
|
||
+ }
|
||
+ SandboxPolicy::DangerFullAccess => {
|
||
+ return true;
|
||
+ }
|
||
+ SandboxPolicy::WorkspaceWrite { .. } => sandbox_policy.get_writable_roots_with_cwd(cwd),
|
||
+ };
|
||
|
||
// Normalize a path by removing `.` and resolving `..` without touching the
|
||
// filesystem (works even if the file does not exist).
|
||
@@ -209,15 +215,9 @@ fn is_write_patch_constrained_to_writable_paths(
|
||
None => return false,
|
||
};
|
||
|
||
- writable_roots.iter().any(|root| {
|
||
- let root_abs = if root.is_absolute() {
|
||
- root.clone()
|
||
- } else {
|
||
- normalize(&cwd.join(root)).unwrap_or_else(|| cwd.join(root))
|
||
- };
|
||
-
|
||
- abs.starts_with(&root_abs)
|
||
- })
|
||
+ writable_roots
|
||
+ .iter()
|
||
+ .any(|writable_root| writable_root.is_path_writable(&abs))
|
||
};
|
||
|
||
for (path, change) in action.changes() {
|
||
@@ -246,38 +246,56 @@ fn is_write_patch_constrained_to_writable_paths(
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
+ use tempfile::TempDir;
|
||
|
||
#[test]
|
||
fn test_writable_roots_constraint() {
|
||
- let cwd = std::env::current_dir().unwrap();
|
||
+ // Use a temporary directory as our workspace to avoid touching
|
||
+ // the real current working directory.
|
||
+ let tmp = TempDir::new().unwrap();
|
||
+ let cwd = tmp.path().to_path_buf();
|
||
let parent = cwd.parent().unwrap().to_path_buf();
|
||
|
||
- // Helper to build a single‑entry map representing a patch that adds a
|
||
- // file at `p`.
|
||
+ // Helper to build a single‑entry patch that adds a file at `p`.
|
||
let make_add_change = |p: PathBuf| ApplyPatchAction::new_add_for_test(&p, "".to_string());
|
||
|
||
let add_inside = make_add_change(cwd.join("inner.txt"));
|
||
let add_outside = make_add_change(parent.join("outside.txt"));
|
||
|
||
+ // Policy limited to the workspace only; exclude system temp roots so
|
||
+ // only `cwd` is writable by default.
|
||
+ let policy_workspace_only = SandboxPolicy::WorkspaceWrite {
|
||
+ writable_roots: vec![],
|
||
+ network_access: false,
|
||
+ exclude_tmpdir_env_var: true,
|
||
+ exclude_slash_tmp: true,
|
||
+ };
|
||
+
|
||
assert!(is_write_patch_constrained_to_writable_paths(
|
||
&add_inside,
|
||
- &[PathBuf::from(".")],
|
||
+ &policy_workspace_only,
|
||
&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(".")],
|
||
+ &add_outside,
|
||
+ &policy_workspace_only,
|
||
&cwd,
|
||
));
|
||
|
||
- // With parent dir added as writable root, it should pass.
|
||
+ // With the parent dir explicitly added as a writable root, the
|
||
+ // outside write should be permitted.
|
||
+ let policy_with_parent = SandboxPolicy::WorkspaceWrite {
|
||
+ writable_roots: vec![parent.clone()],
|
||
+ network_access: false,
|
||
+ exclude_tmpdir_env_var: true,
|
||
+ exclude_slash_tmp: true,
|
||
+ };
|
||
assert!(is_write_patch_constrained_to_writable_paths(
|
||
&add_outside,
|
||
- &[PathBuf::from("..")],
|
||
+ &policy_with_parent,
|
||
&cwd,
|
||
- ))
|
||
+ ));
|
||
}
|
||
|
||
#[test]
|
||
```
|
||
|
||
## Review Comments
|
||
|
||
### codex-rs/core/src/codex.rs
|
||
|
||
- Created: 2025-08-15 16:13:47 UTC | Link: https://github.com/openai/codex/pull/2340#discussion_r2279404819
|
||
|
||
```diff
|
||
@@ -107,6 +105,21 @@ use crate::turn_diff_tracker::TurnDiffTracker;
|
||
use crate::user_notification::UserNotification;
|
||
use crate::util::backoff;
|
||
|
||
+// A convenience extension trait for acquiring mutex locks where poisoning is
|
||
+// unrecoverable and should abort the program. This avoids scattered `.unwrap()`
|
||
+// calls on `lock()` while still surfacing a clear panic message when a lock is
|
||
+// poisoned.
|
||
+trait MutexExt<T> {
|
||
+ fn lock_unchecked(&self) -> MutexGuard<'_, T>;
|
||
+}
|
||
+
|
||
+impl<T> MutexExt<T> for Mutex<T> {
|
||
+ fn lock_unchecked(&self) -> MutexGuard<'_, T> {
|
||
+ #[expect(clippy::expect_used)]
|
||
+ self.lock().expect("poisoned lock")
|
||
+ }
|
||
+}
|
||
```
|
||
|
||
> Let's do that as needed. For now, this is the only file in `core` that needs this.
|
||
>
|
||
> In most other cases, I expect `RwLock` is more appropriate than `Mutex`, but I audited our access and it isn't that read-heavy. |