Compare commits

...

1 Commits

Author SHA1 Message Date
Michael Bolin
9c1e039140 feat: introduce sandbox-aware filesystem abstraction 2026-03-17 15:42:41 -07:00
15 changed files with 753 additions and 20 deletions

14
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -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 }

View File

@@ -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<Arg0PathEntryGuard> {
};
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.

View File

@@ -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 }

View File

@@ -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

View File

@@ -113,6 +113,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;

View File

@@ -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(&current_exe) {
return Ok(current_exe);
}
if let Some(helper) = sibling_fs_helper_launcher(&current_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(&current_exe) {
return Ok(current_exe);
}
if let Some(helper) = sibling_fs_helper_launcher(&current_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"
)
})
}

View File

@@ -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()
)
}
}
}

View File

@@ -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(()));

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "fs-ops",
crate_name = "codex_fs_ops",
)

View File

@@ -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 }

229
codex-rs/fs-ops/src/lib.rs Normal file
View File

@@ -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<ErrorKind> 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<i32>,
}
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<std::io::Error> 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<Item = OsString>,
) -> Result<FsCommand, String> {
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<Item = OsString>) -> 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);
}
}

View File

@@ -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<usize>,
mode: PromptImageMode,
) -> Vec<ContentItem> {
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<u8>,
label_number: Option<usize>,
mode: PromptImageMode,
) -> Vec<ContentItem> {
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<usize>,
image_result: Result<codex_utils_image::EncodedImage, ImageProcessingError>,
) -> Vec<ContentItem> {
match image_result {
Ok(image) => {
let mut items = Vec::with_capacity(3);
if let Some(label_number) = label_number {

View File

@@ -62,9 +62,16 @@ pub fn load_for_prompt(
mode: PromptImageMode,
) -> Result<EncodedImage, ImageProcessingError> {
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<u8>,
mode: PromptImageMode,
) -> Result<EncodedImage, ImageProcessingError> {
let path_buf = path.to_path_buf();
let key = ImageCacheKey {
digest: sha1_digest(&file_bytes),
mode,