From d8fc5dd63dff4a04403dd148319f2c273ed9f61c Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 17 Mar 2026 14:27:32 -0700 Subject: [PATCH] feat: introduce sandbox-aware filesystem abstraction --- codex-rs/Cargo.lock | 14 ++ codex-rs/Cargo.toml | 2 + codex-rs/arg0/Cargo.toml | 1 + codex-rs/arg0/src/lib.rs | 11 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/sandboxed_fs.rs | 234 ++++++++++++++++++ .../core/src/tools/handlers/view_image.rs | 57 +++-- codex-rs/core/tests/suite/view_image.rs | 110 ++++++++ codex-rs/fs-ops/BUILD.bazel | 6 + codex-rs/fs-ops/Cargo.toml | 22 ++ codex-rs/fs-ops/src/lib.rs | 229 +++++++++++++++++ codex-rs/protocol/src/models.rs | 24 +- codex-rs/utils/image/src/lib.rs | 9 +- 14 files changed, 701 insertions(+), 20 deletions(-) create mode 100644 codex-rs/core/src/sandboxed_fs.rs create mode 100644 codex-rs/fs-ops/BUILD.bazel create mode 100644 codex-rs/fs-ops/Cargo.toml create mode 100644 codex-rs/fs-ops/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 84f911bd44..32d0414ebb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1557,6 +1557,7 @@ version = "0.0.0" dependencies = [ "anyhow", "codex-apply-patch", + "codex-fs-ops", "codex-linux-sandbox", "codex-shell-escalation", "codex-utils-home-dir", @@ -1843,6 +1844,7 @@ dependencies = [ "codex-connectors", "codex-execpolicy", "codex-file-search", + "codex-fs-ops", "codex-git", "codex-hooks", "codex-keyring-store", @@ -2063,6 +2065,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-fs-ops" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-git" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index b1e0fcf371..b7018679f4 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -11,6 +11,7 @@ members = [ "apply-patch", "arg0", "feedback", + "fs-ops", "codex-backend-openapi-models", "cloud-requirements", "cloud-tasks", @@ -107,6 +108,7 @@ codex-exec = { path = "exec" } codex-execpolicy = { path = "execpolicy" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } codex-feedback = { path = "feedback" } +codex-fs-ops = { path = "fs-ops" } codex-file-search = { path = "file-search" } codex-git = { path = "utils/git" } codex-hooks = { path = "hooks" } diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index abe7277d94..10c31eac5e 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] anyhow = { workspace = true } codex-apply-patch = { workspace = true } +codex-fs-ops = { workspace = true } codex-linux-sandbox = { workspace = true } codex-shell-escalation = { workspace = true } codex-utils-home-dir = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index d8dbaaa30d..aee33e9649 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -4,6 +4,7 @@ use std::path::Path; use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; +use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1; use codex_utils_home_dir::find_codex_home; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -105,6 +106,16 @@ pub fn arg0_dispatch() -> Option { }; std::process::exit(exit_code); } + if argv1 == CODEX_CORE_FS_OPS_ARG1 { + let exit_code = match codex_fs_ops::run_from_args(args) { + Ok(()) => 0, + Err(err) => { + eprintln!("Error: failed to run fs helper: {err}"); + 1 + } + }; + std::process::exit(exit_code); + } // This modifies the environment, which is not thread-safe, so do this // before creating any threads/the Tokio runtime. diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index ef6b8a0132..ee297aa53b 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -37,6 +37,7 @@ codex-config = { workspace = true } codex-shell-command = { workspace = true } codex-skills = { workspace = true } codex-execpolicy = { workspace = true } +codex-fs-ops = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } codex-hooks = { workspace = true } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e02a346545..04989ee8da 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -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; diff --git a/codex-rs/core/src/sandboxed_fs.rs b/codex-rs/core/src/sandboxed_fs.rs new file mode 100644 index 0000000000..c438abd5af --- /dev/null +++ b/codex-rs/core/src/sandboxed_fs.rs @@ -0,0 +1,234 @@ +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, + turn: &Arc, + path: &Path, +) -> Result, 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, + turn: &Arc, + path: &Path, +) -> Result { + 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, + turn: &Arc, + operation: &str, + path: &Path, +) -> Result { + let exe = resolve_codex_exe(&turn.config.codex_home)?; + 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 { + 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(codex_home: &Path) -> Result { + #[cfg(target_os = "windows")] + { + Ok(codex_windows_sandbox::resolve_current_exe_for_launch( + codex_home, + "codex.exe", + )) + } + #[cfg(not(target_os = "windows"))] + { + let _ = codex_home; + std::env::current_exe().map_err(|error| SandboxedFsError::ResolveExe { + message: error.to_string(), + }) + } +} diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 5757aeb4e1..b6613b8aad 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -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() + ) + } + } +} diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index a3e341f192..1ce0d12dcc 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -3,6 +3,7 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_core::CodexAuth; +use codex_core::config::Constrained; use codex_core::features::Feature; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; @@ -13,9 +14,15 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses; @@ -1244,6 +1251,109 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn view_image_tool_respects_filesystem_sandbox() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let sandbox_policy_for_config = SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: Vec::new(), + }, + network_access: false, + }; + let mut builder = test_codex().with_config({ + let sandbox_policy_for_config = sandbox_policy_for_config.clone(); + move |config| { + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config.permissions.file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Read, + }, + ]); + } + }); + let TestCodex { + codex, + config, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let outside_dir = tempfile::tempdir()?; + let abs_path = outside_dir.path().join("blocked.png"); + let image = ImageBuffer::from_pixel(256, 128, Rgba([10u8, 20, 30, 255])); + image.save(&abs_path)?; + + let call_id = "view-image-sandbox-denied"; + let arguments = serde_json::json!({ "path": abs_path }).to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "view_image", &arguments), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + let mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please attach the outside image".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: config.permissions.sandbox_policy.get().clone(), + model: session_model, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let request = mock.single_request(); + assert!( + request.inputs_of_type("input_image").is_empty(), + "sandbox-denied image should not produce an input_image message" + ); + let output_text = request + .function_call_output_content_and_success(call_id) + .and_then(|(content, _)| content) + .expect("output text present"); + let expected_prefix = format!("unable to read image at `{}`:", abs_path.display()); + assert!( + output_text.starts_with(&expected_prefix), + "expected sandbox denial prefix `{expected_prefix}` but got `{output_text}`" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/fs-ops/BUILD.bazel b/codex-rs/fs-ops/BUILD.bazel new file mode 100644 index 0000000000..87fe4d5517 --- /dev/null +++ b/codex-rs/fs-ops/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "fs-ops", + crate_name = "codex_fs_ops", +) diff --git a/codex-rs/fs-ops/Cargo.toml b/codex-rs/fs-ops/Cargo.toml new file mode 100644 index 0000000000..2b7495f0d2 --- /dev/null +++ b/codex-rs/fs-ops/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "codex-fs-ops" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_fs_ops" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/fs-ops/src/lib.rs b/codex-rs/fs-ops/src/lib.rs new file mode 100644 index 0000000000..32d5c2cf6f --- /dev/null +++ b/codex-rs/fs-ops/src/lib.rs @@ -0,0 +1,229 @@ +use anyhow::Context; +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use serde::Deserialize; +use serde::Serialize; +use std::ffi::OsString; +use std::io::ErrorKind; +use std::io::Write; +use std::path::PathBuf; + +/// Special argv[1] flag used when the Codex executable self-invokes to run the +/// internal sandbox-backed filesystem helper path. +pub const CODEX_CORE_FS_OPS_ARG1: &str = "--codex-run-as-fs-ops"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FsCommand { + ReadBytes { path: PathBuf }, + ReadText { path: PathBuf }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FsErrorKind { + NotFound, + PermissionDenied, + IsADirectory, + InvalidData, + Other, +} + +impl From for FsErrorKind { + fn from(value: ErrorKind) -> Self { + match value { + ErrorKind::NotFound => Self::NotFound, + ErrorKind::PermissionDenied => Self::PermissionDenied, + ErrorKind::IsADirectory => Self::IsADirectory, + ErrorKind::InvalidData => Self::InvalidData, + _ => Self::Other, + } + } +} + +impl FsErrorKind { + pub fn to_io_error_kind(&self) -> ErrorKind { + match self { + Self::NotFound => ErrorKind::NotFound, + Self::PermissionDenied => ErrorKind::PermissionDenied, + Self::IsADirectory => ErrorKind::IsADirectory, + Self::InvalidData => ErrorKind::InvalidData, + Self::Other => ErrorKind::Other, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FsError { + pub kind: FsErrorKind, + pub message: String, + pub raw_os_error: Option, +} + +impl std::fmt::Display for FsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl FsError { + pub fn to_io_error(&self) -> std::io::Error { + if let Some(raw_os_error) = self.raw_os_error { + std::io::Error::from_raw_os_error(raw_os_error) + } else { + std::io::Error::new(self.kind.to_io_error_kind(), self.message.clone()) + } + } +} + +impl From for FsError { + fn from(error: std::io::Error) -> Self { + Self { + kind: error.kind().into(), + message: error.to_string(), + raw_os_error: error.raw_os_error(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum FsPayload { + Bytes { base64: String }, + Text { text: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum FsResponse { + Success { payload: FsPayload }, + Error { error: FsError }, +} + +pub fn parse_command_from_args( + mut args: impl Iterator, +) -> Result { + let Some(operation) = args.next() else { + return Err("missing operation".to_string()); + }; + let Some(operation) = operation.to_str() else { + return Err("operation must be valid UTF-8".to_string()); + }; + let Some(path) = args.next() else { + return Err(format!("missing path for operation `{operation}`")); + }; + if args.next().is_some() { + return Err(format!( + "unexpected extra arguments for operation `{operation}`" + )); + } + + let path = PathBuf::from(path); + match operation { + "read_bytes" => Ok(FsCommand::ReadBytes { path }), + "read_text" => Ok(FsCommand::ReadText { path }), + _ => Err(format!( + "unsupported filesystem operation `{operation}`; expected one of `read_bytes`, `read_text`" + )), + } +} + +pub fn execute(command: FsCommand) -> FsResponse { + match command { + FsCommand::ReadBytes { path } => match std::fs::read(&path) { + Ok(bytes) => FsResponse::Success { + payload: FsPayload::Bytes { + base64: BASE64_STANDARD.encode(bytes), + }, + }, + Err(error) => FsResponse::Error { + error: error.into(), + }, + }, + FsCommand::ReadText { path } => match std::fs::read_to_string(&path) { + Ok(text) => FsResponse::Success { + payload: FsPayload::Text { text }, + }, + Err(error) => FsResponse::Error { + error: error.into(), + }, + }, + } +} + +pub fn write_response(stdout: &mut impl Write, response: &FsResponse) -> Result<()> { + serde_json::to_writer(&mut *stdout, response).context("failed to serialize fs response")?; + writeln!(stdout).context("failed to terminate fs response with newline")?; + Ok(()) +} + +pub fn run_from_args(args: impl Iterator) -> Result<()> { + let command = parse_command_from_args(args).map_err(anyhow::Error::msg)?; + let response = execute(command); + let mut stdout = std::io::stdout().lock(); + write_response(&mut stdout, &response) +} + +#[cfg(test)] +mod tests { + use super::FsCommand; + use super::FsErrorKind; + use super::FsPayload; + use super::FsResponse; + use super::execute; + use super::parse_command_from_args; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn parse_read_bytes_command() { + let command = parse_command_from_args( + ["read_bytes", "/tmp/example.png"] + .into_iter() + .map(Into::into), + ) + .expect("command should parse"); + + assert_eq!( + command, + FsCommand::ReadBytes { + path: "/tmp/example.png".into(), + } + ); + } + + #[test] + fn read_text_returns_text_payload() { + let tempdir = tempdir().expect("tempdir"); + let path = tempdir.path().join("note.txt"); + std::fs::write(&path, "hello").expect("write test file"); + + let response = execute(FsCommand::ReadText { path }); + + assert_eq!( + response, + FsResponse::Success { + payload: FsPayload::Text { + text: "hello".to_string(), + }, + } + ); + } + + #[test] + fn read_bytes_reports_directory_error() { + let tempdir = tempdir().expect("tempdir"); + + let response = execute(FsCommand::ReadBytes { + path: tempdir.path().to_path_buf(), + }); + + let FsResponse::Error { error } = response else { + panic!("expected error response"); + }; + #[cfg(target_os = "windows")] + assert_eq!(error.kind, FsErrorKind::PermissionDenied); + #[cfg(not(target_os = "windows"))] + assert_eq!(error.kind, FsErrorKind::IsADirectory); + } +} diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 1e5a7445e3..f60f431132 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -3,6 +3,7 @@ use std::path::Path; use codex_utils_image::PromptImageMode; use codex_utils_image::load_for_prompt; +use codex_utils_image::load_for_prompt_from_bytes; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; @@ -947,7 +948,28 @@ pub fn local_image_content_items_with_label_number( label_number: Option, mode: PromptImageMode, ) -> Vec { - match load_for_prompt(path, mode) { + local_image_content_items_for_load_result(path, label_number, load_for_prompt(path, mode)) +} + +pub fn local_image_content_items_from_bytes_with_label_number( + path: &std::path::Path, + bytes: Vec, + label_number: Option, + mode: PromptImageMode, +) -> Vec { + local_image_content_items_for_load_result( + path, + label_number, + load_for_prompt_from_bytes(path, bytes, mode), + ) +} + +fn local_image_content_items_for_load_result( + path: &std::path::Path, + label_number: Option, + image_result: Result, +) -> Vec { + match image_result { Ok(image) => { let mut items = Vec::with_capacity(3); if let Some(label_number) = label_number { diff --git a/codex-rs/utils/image/src/lib.rs b/codex-rs/utils/image/src/lib.rs index d0aba2f608..89547fa3a7 100644 --- a/codex-rs/utils/image/src/lib.rs +++ b/codex-rs/utils/image/src/lib.rs @@ -62,9 +62,16 @@ pub fn load_for_prompt( mode: PromptImageMode, ) -> Result { let path_buf = path.to_path_buf(); - let file_bytes = read_file_bytes(path, &path_buf)?; + load_for_prompt_from_bytes(path, file_bytes, mode) +} +pub fn load_for_prompt_from_bytes( + path: &Path, + file_bytes: Vec, + mode: PromptImageMode, +) -> Result { + let path_buf = path.to_path_buf(); let key = ImageCacheKey { digest: sha1_digest(&file_bytes), mode,