diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3955f79f10..31e1832507 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -662,6 +662,7 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", + "base64", "chrono", "clap", "codex-arg0", diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 89dc3951e7..d5c354446b 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -16,6 +16,7 @@ workspace = true [dependencies] anyhow = "1" +base64 = "0.22" chrono = "0.4.40" clap = { version = "4", features = ["derive"] } codex-arg0 = { path = "../arg0" } diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 31dd410991..f355259cc3 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -63,6 +63,10 @@ pub struct Cli { #[arg(long = "output-last-message")] pub last_message_file: Option, + /// Treat the prompt argument as base64-encoded text and decode it before use. + #[arg(long = "base64", default_value_t = false)] + pub base64: bool, + /// Initial instructions for the agent. If not provided as an argument (or /// if `-` is used), instructions are read from stdin. #[arg(value_name = "PROMPT")] diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 40b30f2bba..e794459d8f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -3,6 +3,8 @@ mod event_processor; mod event_processor_with_human_output; mod event_processor_with_json_output; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; use std::io::IsTerminal; use std::io::Read; use std::path::PathBuf; @@ -47,6 +49,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any last_message_file, json: json_mode, sandbox_mode: sandbox_mode_cli_arg, + base64: prompt_is_base64, prompt, config_overrides, } = cli; @@ -86,6 +89,18 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any } }; + let prompt = if prompt_is_base64 { + match decode_base64_prompt(&prompt) { + Ok(decoded) => decoded, + Err(err) => { + eprintln!("{err}"); + std::process::exit(1); + } + } + } else { + prompt + }; + let (stdout_with_ansi, stderr_with_ansi) = match color { cli::Color::Always => (true, true), cli::Color::Never => (false, false), @@ -278,3 +293,30 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any Ok(()) } + +fn decode_base64_prompt(encoded: &str) -> Result { + let sanitized: String = encoded + .chars() + .filter(|c| !c.is_ascii_whitespace()) + .collect(); + let decoded = STANDARD + .decode(sanitized.as_bytes()) + .map_err(|err| format!("Failed to decode base64 prompt: {err}"))?; + String::from_utf8(decoded).map_err(|err| format!("Base64 prompt was not valid UTF-8: {err}")) +} + +#[cfg(test)] +mod tests { + use super::decode_base64_prompt; + + #[test] + fn decode_base64_prompt_success() { + let encoded = "U29tZSBiYXNlNjQgdGV4dA=="; + assert_eq!(decode_base64_prompt(encoded).unwrap(), "Some base64 text"); + } + + #[test] + fn decode_base64_prompt_failure() { + assert!(decode_base64_prompt("not-base64").is_err()); + } +} diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index cb5f8ac778..57e06f371c 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -9,6 +9,10 @@ pub struct Cli { /// Optional user prompt to start the session. pub prompt: Option, + /// Treat the prompt argument as base64-encoded text and decode it before use. + #[arg(long = "base64", default_value_t = false)] + pub base64: bool, + /// Optional image(s) to attach to the initial prompt. #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] pub images: Vec, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7ce175286b..8d09cac70a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -4,6 +4,8 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] #![deny(clippy::disallowed_methods)] use app::App; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::CodexAuth; @@ -393,7 +395,21 @@ async fn run_ratatui_app( } } - let Cli { prompt, images, .. } = cli; + let Cli { + prompt, + base64: prompt_is_base64, + images, + .. + } = cli; + + let prompt = if prompt_is_base64 { + match prompt { + Some(value) => Some(decode_base64_prompt(&value)?), + None => None, + } + } else { + prompt + }; let app_result = App::run( &mut tui, @@ -413,6 +429,25 @@ async fn run_ratatui_app( app_result } +fn decode_base64_prompt(encoded: &str) -> std::io::Result { + let sanitized: String = encoded + .chars() + .filter(|c| !c.is_ascii_whitespace()) + .collect(); + let decoded = STANDARD.decode(sanitized.as_bytes()).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Failed to decode base64 prompt: {err}"), + ) + })?; + String::from_utf8(decoded).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Base64 prompt was not valid UTF-8: {err}"), + ) + }) +} + #[expect( clippy::print_stderr, reason = "TUI should no longer be displayed, so we can write to stderr." @@ -532,6 +567,17 @@ mod tests { use clap::Parser; use std::sync::Once; + #[test] + fn decode_base64_prompt_success() { + let encoded = "SGVsbG8gV09STEQh"; + assert_eq!(decode_base64_prompt(encoded).unwrap(), "Hello WORLD!"); + } + + #[test] + fn decode_base64_prompt_failure() { + assert!(decode_base64_prompt("not-base64").is_err()); + } + fn enable_debug_high_env() { static DEBUG_HIGH_ONCE: Once = Once::new(); DEBUG_HIGH_ONCE.call_once(|| {