mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
feat: introduce sandbox-aware filesystem abstraction
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Generate the JSON Schema for `config.toml` and write it to `config.schema.json`.
|
||||
@@ -11,6 +12,15 @@ struct Args {
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = std::env::args_os();
|
||||
let _program_name = args.next();
|
||||
if matches!(
|
||||
args.next().as_deref(),
|
||||
Some(flag) if flag == std::ffi::OsStr::new(CODEX_CORE_FS_OPS_ARG1)
|
||||
) {
|
||||
return codex_fs_ops::run_from_args(args);
|
||||
}
|
||||
|
||||
let args = Args::parse();
|
||||
let out_path = args
|
||||
.out
|
||||
|
||||
@@ -112,6 +112,7 @@ pub mod default_client;
|
||||
pub mod project_doc;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
mod sandboxed_fs;
|
||||
pub mod seatbelt;
|
||||
pub mod shell;
|
||||
pub mod shell_snapshot;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::exec::ExecExpiration;
|
||||
use crate::sandboxing::CommandSpec;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::sandboxing::execute_env;
|
||||
use crate::sandboxing::merge_permission_profiles;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
use crate::tools::sandboxing::SandboxablePreference;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1;
|
||||
use codex_fs_ops::FsError;
|
||||
use codex_fs_ops::FsErrorKind;
|
||||
use codex_fs_ops::FsPayload;
|
||||
use codex_fs_ops::FsResponse;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const SANDBOXED_FS_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum SandboxedFsError {
|
||||
#[error("failed to determine codex executable: {message}")]
|
||||
ResolveExe { message: String },
|
||||
#[error("sandboxed fs helper timed out while accessing `{path}`")]
|
||||
TimedOut { path: PathBuf },
|
||||
#[error("sandboxed fs helper exited with code {exit_code} while accessing `{path}`: {message}")]
|
||||
ProcessFailed {
|
||||
path: PathBuf,
|
||||
exit_code: i32,
|
||||
message: String,
|
||||
},
|
||||
#[error("sandboxed fs helper returned invalid output for `{path}`: {message}")]
|
||||
InvalidResponse { path: PathBuf, message: String },
|
||||
#[error("sandboxed fs helper could not access `{path}`: {error}")]
|
||||
Operation { path: PathBuf, error: FsError },
|
||||
}
|
||||
|
||||
impl SandboxedFsError {
|
||||
pub(crate) fn operation_error_kind(&self) -> Option<&FsErrorKind> {
|
||||
match self {
|
||||
Self::Operation { error, .. } => Some(&error.kind),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn operation_error_message(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Operation { error, .. } => Some(error.message.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn to_io_error(&self) -> std::io::Error {
|
||||
match self {
|
||||
Self::Operation { error, .. } => error.to_io_error(),
|
||||
Self::TimedOut { .. } => {
|
||||
std::io::Error::new(std::io::ErrorKind::TimedOut, self.to_string())
|
||||
}
|
||||
Self::ResolveExe { .. } | Self::ProcessFailed { .. } | Self::InvalidResponse { .. } => {
|
||||
std::io::Error::other(self.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_bytes(
|
||||
session: &Arc<Session>,
|
||||
turn: &Arc<TurnContext>,
|
||||
path: &Path,
|
||||
) -> Result<Vec<u8>, SandboxedFsError> {
|
||||
let path_buf = path.to_path_buf();
|
||||
let payload = run_request(session, turn, "read_bytes", &path_buf).await?;
|
||||
let FsPayload::Bytes { base64 } = payload else {
|
||||
return Err(SandboxedFsError::InvalidResponse {
|
||||
path: path_buf,
|
||||
message: "expected bytes payload".to_string(),
|
||||
});
|
||||
};
|
||||
BASE64_STANDARD
|
||||
.decode(base64)
|
||||
.map_err(|error| SandboxedFsError::InvalidResponse {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("failed to decode base64 payload: {error}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn read_text(
|
||||
session: &Arc<Session>,
|
||||
turn: &Arc<TurnContext>,
|
||||
path: &Path,
|
||||
) -> Result<String, SandboxedFsError> {
|
||||
let path_buf = path.to_path_buf();
|
||||
let payload = run_request(session, turn, "read_text", &path_buf).await?;
|
||||
let FsPayload::Text { text } = payload else {
|
||||
return Err(SandboxedFsError::InvalidResponse {
|
||||
path: path_buf,
|
||||
message: "expected text payload".to_string(),
|
||||
});
|
||||
};
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
async fn run_request(
|
||||
session: &Arc<Session>,
|
||||
turn: &Arc<TurnContext>,
|
||||
operation: &str,
|
||||
path: &Path,
|
||||
) -> Result<FsPayload, SandboxedFsError> {
|
||||
let exe = resolve_codex_exe(turn)?;
|
||||
let additional_permissions = effective_granted_permissions(session).await;
|
||||
let sandbox_manager = crate::sandboxing::SandboxManager::new();
|
||||
let attempt = SandboxAttempt {
|
||||
sandbox: sandbox_manager.select_initial(
|
||||
&turn.file_system_sandbox_policy,
|
||||
turn.network_sandbox_policy,
|
||||
SandboxablePreference::Auto,
|
||||
turn.windows_sandbox_level,
|
||||
/*has_managed_network_requirements*/ false,
|
||||
),
|
||||
policy: &turn.sandbox_policy,
|
||||
file_system_policy: &turn.file_system_sandbox_policy,
|
||||
network_policy: turn.network_sandbox_policy,
|
||||
enforce_managed_network: false,
|
||||
manager: &sandbox_manager,
|
||||
sandbox_cwd: &turn.cwd,
|
||||
codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(),
|
||||
use_legacy_landlock: turn.features.use_legacy_landlock(),
|
||||
windows_sandbox_level: turn.windows_sandbox_level,
|
||||
windows_sandbox_private_desktop: turn.config.permissions.windows_sandbox_private_desktop,
|
||||
};
|
||||
let exec_request = attempt
|
||||
.env_for(
|
||||
CommandSpec {
|
||||
program: exe.to_string_lossy().to_string(),
|
||||
args: vec![
|
||||
CODEX_CORE_FS_OPS_ARG1.to_string(),
|
||||
operation.to_string(),
|
||||
path.to_string_lossy().to_string(),
|
||||
],
|
||||
cwd: turn.cwd.clone(),
|
||||
env: HashMap::new(),
|
||||
expiration: ExecExpiration::Timeout(SANDBOXED_FS_TIMEOUT),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
additional_permissions,
|
||||
justification: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.map_err(|error| SandboxedFsError::ProcessFailed {
|
||||
path: path.to_path_buf(),
|
||||
exit_code: -1,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let output =
|
||||
execute_env(exec_request, None)
|
||||
.await
|
||||
.map_err(|error| SandboxedFsError::ProcessFailed {
|
||||
path: path.to_path_buf(),
|
||||
exit_code: -1,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
if output.timed_out {
|
||||
return Err(SandboxedFsError::TimedOut {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
if output.exit_code != 0 {
|
||||
let stderr = output.stderr.text.trim();
|
||||
let stdout = output.stdout.text.trim();
|
||||
let message = if !stderr.is_empty() {
|
||||
stderr.to_string()
|
||||
} else if !stdout.is_empty() {
|
||||
stdout.to_string()
|
||||
} else {
|
||||
"no error details emitted".to_string()
|
||||
};
|
||||
return Err(SandboxedFsError::ProcessFailed {
|
||||
path: path.to_path_buf(),
|
||||
exit_code: output.exit_code,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
let response: FsResponse =
|
||||
serde_json::from_str(output.stdout.text.trim()).map_err(|error| {
|
||||
SandboxedFsError::InvalidResponse {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("failed to parse helper response: {error}"),
|
||||
}
|
||||
})?;
|
||||
|
||||
match response {
|
||||
FsResponse::Success { payload } => Ok(payload),
|
||||
FsResponse::Error { error } => Err(SandboxedFsError::Operation {
|
||||
path: path.to_path_buf(),
|
||||
error,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn effective_granted_permissions(session: &Session) -> Option<PermissionProfile> {
|
||||
let granted_session_permissions = session.granted_session_permissions().await;
|
||||
let granted_turn_permissions = session.granted_turn_permissions().await;
|
||||
merge_permission_profiles(
|
||||
granted_session_permissions.as_ref(),
|
||||
granted_turn_permissions.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_codex_exe(turn: &TurnContext) -> Result<PathBuf, SandboxedFsError> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let current_exe =
|
||||
std::env::current_exe().map_err(|error| SandboxedFsError::ResolveExe {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
if is_codex_launcher(¤t_exe) {
|
||||
return Ok(current_exe);
|
||||
}
|
||||
if let Some(helper) = sibling_fs_helper_launcher(¤t_exe)
|
||||
&& helper.is_file()
|
||||
{
|
||||
return Ok(helper);
|
||||
}
|
||||
Ok(codex_windows_sandbox::resolve_current_exe_for_launch(
|
||||
&turn.config.codex_home,
|
||||
"codex.exe",
|
||||
))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = turn;
|
||||
let current_exe =
|
||||
std::env::current_exe().map_err(|error| SandboxedFsError::ResolveExe {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
if is_codex_launcher(¤t_exe) {
|
||||
return Ok(current_exe);
|
||||
}
|
||||
if let Some(helper) = sibling_fs_helper_launcher(¤t_exe)
|
||||
&& helper.is_file()
|
||||
{
|
||||
return Ok(helper);
|
||||
}
|
||||
Ok(current_exe)
|
||||
}
|
||||
}
|
||||
|
||||
fn sibling_fs_helper_launcher(current_exe: &Path) -> Option<PathBuf> {
|
||||
let bin_dir = current_exe.parent()?.parent()?;
|
||||
#[cfg(target_os = "windows")]
|
||||
let helper_name = "codex-write-config-schema.exe";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let helper_name = "codex-write-config-schema";
|
||||
Some(bin_dir.join(helper_name))
|
||||
}
|
||||
|
||||
fn is_codex_launcher(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.is_some_and(|name| {
|
||||
matches!(
|
||||
name,
|
||||
"codex" | "codex.exe" | "codex-exec" | "codex-exec.exe"
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -2,16 +2,16 @@ use async_trait::async_trait;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use codex_protocol::models::local_image_content_items_with_label_number;
|
||||
use codex_protocol::models::local_image_content_items_from_bytes_with_label_number;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_utils_image::PromptImageMode;
|
||||
use serde::Deserialize;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::original_image_detail::can_request_original_image_detail;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ViewImageToolCallEvent;
|
||||
use crate::sandboxed_fs;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -88,20 +88,6 @@ impl ToolHandler for ViewImageHandler {
|
||||
};
|
||||
|
||||
let abs_path = turn.resolve_path(Some(args.path));
|
||||
|
||||
let metadata = fs::metadata(&abs_path).await.map_err(|error| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"unable to locate image at `{}`: {error}",
|
||||
abs_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
if !metadata.is_file() {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"image path `{}` is not a file",
|
||||
abs_path.display()
|
||||
)));
|
||||
}
|
||||
let event_path = abs_path.clone();
|
||||
|
||||
let can_request_original_detail =
|
||||
@@ -114,9 +100,17 @@ impl ToolHandler for ViewImageHandler {
|
||||
PromptImageMode::ResizeToFit
|
||||
};
|
||||
let image_detail = use_original_detail.then_some(ImageDetail::Original);
|
||||
let image_bytes = sandboxed_fs::read_bytes(&session, &turn, &abs_path)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
FunctionCallError::RespondToModel(render_view_image_read_error(&abs_path, &error))
|
||||
})?;
|
||||
|
||||
let content = local_image_content_items_with_label_number(
|
||||
&abs_path, /*label_number*/ None, image_mode,
|
||||
let content = local_image_content_items_from_bytes_with_label_number(
|
||||
&abs_path,
|
||||
image_bytes,
|
||||
/*label_number*/ None,
|
||||
image_mode,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
@@ -142,3 +136,30 @@ impl ToolHandler for ViewImageHandler {
|
||||
Ok(FunctionToolOutput::from_content(content, Some(true)))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_view_image_read_error(
|
||||
path: &std::path::Path,
|
||||
error: &sandboxed_fs::SandboxedFsError,
|
||||
) -> String {
|
||||
let operation_message = error
|
||||
.operation_error_message()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| error.to_string());
|
||||
match error.operation_error_kind() {
|
||||
Some(codex_fs_ops::FsErrorKind::IsADirectory) => {
|
||||
format!("image path `{}` is not a file", path.display())
|
||||
}
|
||||
Some(codex_fs_ops::FsErrorKind::NotFound) => {
|
||||
format!(
|
||||
"unable to locate image at `{}`: {operation_message}",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
Some(_) | None => {
|
||||
format!(
|
||||
"unable to read image at `{}`: {operation_message}",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user