use core::{
fmt,
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
use std::{
path::{Path, PathBuf},
process::Stdio,
};
use crate::auth::{Copied, may_refresh, mkdir_private, refresh_lock, write_private_atomic};
#[derive(Clone, Debug)]
pub struct Exec {
pub command: String,
pub codex_home: PathBuf,
pub tmp_root: PathBuf,
pub model: Option<String>,
pub sandbox: String,
pub timeout: Duration,
pub env: Vec<(String, String)>,
pub args: Vec<String>,
}
impl Exec {
pub fn new(
command: impl Into<String>,
codex_home: impl Into<PathBuf>,
tmp_root: impl Into<PathBuf>,
) -> Self {
Self {
command: command.into(),
codex_home: codex_home.into(),
tmp_root: tmp_root.into(),
model: None,
sandbox: "read-only".into(),
timeout: Duration::from_secs(300),
env: Vec::new(),
args: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct Input {
pub prompt: String,
pub images: Vec<(String, Vec<u8>)>,
}
#[derive(Debug)]
pub enum ExecError {
Login(std::io::Error),
Setup(&'static str, std::io::Error),
Spawn(std::io::Error),
}
impl fmt::Display for ExecError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Login(e) => write!(f, "Codex's auth.json isn't readable: {e}"),
Self::Setup(what, e) => write!(f, "codex run {what}: {e}"),
Self::Spawn(e) => write!(f, "codex didn't start: {e}"),
}
}
}
impl core::error::Error for ExecError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Login(e) | Self::Setup(_, e) | Self::Spawn(e) => Some(e),
}
}
}
#[derive(Debug)]
pub struct Run {
dir: RunDir,
pub code: Option<i32>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub last_message: Vec<u8>,
pub copied_back: std::io::Result<bool>,
}
impl Run {
pub fn home(&self) -> PathBuf {
self.dir.0.join("home")
}
pub fn failure(&self) -> Option<Failure> {
failure(&self.stdout, &self.stderr)
}
pub async fn generated_images(&self, max_bytes: u64) -> Vec<PathBuf> {
files_oldest_first(&self.home().join("generated_images"), max_bytes).await
}
pub async fn remove(mut self) -> std::io::Result<()> {
let p = core::mem::take(&mut self.dir.0);
match tokio::fs::remove_dir_all(&p).await {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
}
}
#[derive(Debug)]
struct RunDir(PathBuf);
impl Drop for RunDir {
fn drop(&mut self) {
if !self.0.as_os_str().is_empty() {
let _ = std::fs::remove_dir_all(&self.0);
}
}
}
fn copy_back_lock() -> &'static tokio::sync::Mutex<()> {
static L: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
L.get_or_init(tokio::sync::Mutex::default)
}
fn run_name() -> String {
static N: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
format!(
"run-{}-{nanos}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
)
}
fn safe_name(name: &str, i: usize) -> String {
Path::new(name)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.filter(|n| !n.starts_with('.'))
.unwrap_or_else(|| format!("image-{}", i + 1))
}
impl Exec {
fn argv(&self, dir: &Path, images: &[PathBuf]) -> Vec<String> {
let s = |p: &Path| p.to_string_lossy().into_owned();
let mut argv: Vec<String> = [
"exec",
"--ephemeral",
"--ignore-user-config",
"--ignore-rules",
"--skip-git-repo-check",
"--sandbox",
&self.sandbox,
"--color",
"never",
"--json",
"--cd",
]
.map(String::from)
.into();
argv.push(s(&dir.join("work")));
argv.push("--output-last-message".into());
argv.push(s(&dir.join("last.txt")));
if let Some(m) = &self.model {
argv.extend(["--model".into(), m.clone()]);
}
for p in images {
argv.push(format!("--image={}", s(p)));
}
argv.extend(self.args.iter().cloned());
argv.extend(["--".into(), "-".into()]);
argv
}
fn child_env(&self, dir: &Path) -> Vec<(String, String)> {
let s = |p: &Path| p.to_string_lossy().into_owned();
let mut extra: Vec<(String, String)> = Vec::new();
if !self.env.iter().any(|(k, _)| k == "LANG") {
extra.push(("LANG".into(), "C.UTF-8".into()));
}
extra.extend(
self.env
.iter()
.filter(|(k, _)| !matches!(k.as_str(), "HOME" | "CODEX_HOME" | "TMPDIR"))
.cloned(),
);
extra.extend([
("HOME".into(), s(dir)),
("CODEX_HOME".into(), s(&dir.join("home"))),
("TMPDIR".into(), s(dir)),
]);
crate::client::child_env(std::env::vars(), &extra)
}
pub async fn run(&self, input: &Input) -> Result<Run, ExecError> {
let real = self.codex_home.join("auth.json");
let read_login = || async { tokio::fs::read(&real).await.map_err(ExecError::Login) };
let mut orig = read_login().await?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
let _alone = if may_refresh(&orig, now) {
let g = refresh_lock().lock().await;
orig = read_login().await?;
Some(g)
} else {
None
};
let setup = |what| move |e| ExecError::Setup(what, e);
mkdir_private(&self.tmp_root)
.await
.map_err(setup("directory"))?;
let dir = RunDir(self.tmp_root.join(run_name()));
for d in ["", "home", "work", "refs"] {
tokio::fs::DirBuilder::new()
.mode(0o700)
.create(dir.0.join(d))
.await
.map_err(setup("directory"))?;
}
let ours = dir.0.join("home/auth.json");
write_private_atomic(&ours, &orig)
.await
.map_err(setup("login"))?;
let mut images = Vec::with_capacity(input.images.len());
for (i, (name, data)) in input.images.iter().enumerate() {
let p = dir.0.join("refs").join(safe_name(name, i));
write_private_atomic(&p, data)
.await
.map_err(setup("image"))?;
images.push(p);
}
let out = run_capture(
&self.command,
&self.argv(&dir.0, &images),
&dir.0.join("work"),
&self.child_env(&dir.0),
input.prompt.as_bytes(),
self.timeout,
)
.await
.map_err(ExecError::Spawn)?;
let copied_back = {
let _g = copy_back_lock().lock().await;
crate::auth::copy_back(&Copied::new(real.clone(), ours, orig)).await
};
let last_message = tokio::fs::read(dir.0.join("last.txt"))
.await
.unwrap_or_default();
Ok(Run {
dir,
code: out.0,
stdout: out.1,
stderr: out.2,
last_message,
copied_back,
})
}
}
async fn run_capture(
command: &str,
argv: &[String],
cwd: &Path,
env: &[(String, String)],
input: &[u8],
timeout: Duration,
) -> std::io::Result<(Option<i32>, Vec<u8>, Vec<u8>)> {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let mut child = tokio::process::Command::new(command)
.args(argv)
.current_dir(cwd)
.env_clear()
.envs(env.iter().map(|(k, v)| (k, v)))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
let data = input.to_vec();
tokio::spawn(async move {
let _ = stdin.write_all(&data).await;
});
}
let read = |r: Option<Box<dyn tokio::io::AsyncRead + Send + Unpin>>| {
tokio::spawn(async move {
let mut b = Vec::new();
if let Some(mut r) = r {
let _ = r.read_to_end(&mut b).await;
}
b
})
};
let t_out = read(child.stdout.take().map(|o| Box::new(o) as _));
let t_err = read(child.stderr.take().map(|e| Box::new(e) as _));
let code = if let Ok(s) = tokio::time::timeout(timeout, child.wait()).await {
Some(s?.code().unwrap_or(-1))
} else {
let _ = child.kill().await;
None
};
let grace = if code.is_some() {
timeout
} else {
Duration::from_secs(1)
};
let collect = |t: tokio::task::JoinHandle<Vec<u8>>| async move {
let abort = t.abort_handle();
tokio::time::timeout(grace, t).await.map_or_else(
|_| {
abort.abort();
Vec::new()
},
Result::unwrap_or_default,
)
};
let out = collect(t_out).await;
let err = collect(t_err).await;
Ok((code, out, err))
}
async fn files_oldest_first(dir: &Path, max_bytes: u64) -> Vec<PathBuf> {
let mut files = Vec::new();
let mut dirs = vec![dir.to_path_buf()];
while let Some(d) = dirs.pop() {
let Ok(mut rd) = tokio::fs::read_dir(&d).await else {
continue;
};
while let Ok(Some(e)) = rd.next_entry().await {
let Ok(ft) = e.file_type().await else {
continue;
};
if ft.is_dir() {
dirs.push(e.path());
} else if ft.is_file() {
match e.metadata().await {
Ok(m) if m.len() <= max_bytes => {
files.push((m.modified().unwrap_or(std::time::UNIX_EPOCH), e.path()));
}
_ => {}
}
}
}
}
files.sort();
files.into_iter().map(|(_, p)| p).collect()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Failure {
LoginDead,
LoginRefused(String),
Said(String),
}
impl fmt::Display for Failure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::LoginDead => f.write_str(
"Codex's ChatGPT login is no longer valid (its refresh token was used up or \
revoked): run `codex login` again",
),
Self::LoginRefused(m) => write!(f, "Codex's ChatGPT login was refused ({m})"),
Self::Said(m) => f.write_str(m),
}
}
}
pub fn failure(stdout: &[u8], stderr: &[u8]) -> Option<Failure> {
const DEAD: [&str; 6] = [
"refresh_token_reused",
"refresh token was already used",
"refresh_token_expired",
"refresh_token_invalidated",
"Please log out and sign in again",
"Please try signing in again",
];
let err = String::from_utf8_lossy(stderr);
let out = String::from_utf8_lossy(stdout);
if DEAD.iter().any(|d| err.contains(d) || out.contains(d)) {
return Some(Failure::LoginDead);
}
let mut said = None;
for line in out.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let m = match v["type"].as_str() {
Some("turn.failed") => v["error"]["message"].as_str(),
Some("error") => v["message"].as_str(),
_ => None,
};
if let Some(m) = m.filter(|m| !m.starts_with("Reconnecting...")) {
said = Some(m.to_owned());
}
}
said.map(|m| {
if m.contains("401") {
Failure::LoginRefused(m)
} else {
Failure::Said(m)
}
})
}
pub fn image_prompt(
brief: &str,
count: u64,
aspect_ratio: Option<&str>,
with_images: bool,
) -> String {
use fmt::Write as _;
let mut t = format!(
"Use your image generation tool to create exactly {count} image{} from the brief below",
if count == 1 { "" } else { "s" }
);
if let Some(a) = aspect_ratio {
let _ = write!(t, ", aspect ratio {a} (width:height)");
}
if with_images {
t.push_str(", using the attached image(s) as the reference the brief refers to");
}
t.push_str(
". Generate each image separately. Do nothing else: no shell commands, no reading or \
writing files, no web searches, no questions. When the images are made, reply with \
only the word done.\n\nBrief:\n",
);
t.push_str(brief);
t
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_prompt_says_what() {
let t = image_prompt("a red apple", 2, Some("16:9"), true);
assert!(t.contains("exactly 2 images") && t.contains("16:9") && t.contains("attached"));
assert!(t.ends_with("Brief:\na red apple"), "{t}");
let t = image_prompt("x", 1, None, false);
assert!(
t.contains("exactly 1 image ") && !t.contains("attached"),
"{t}"
);
}
#[test]
fn failure_says_why() {
let out = br#"{"type":"thread.started","thread_id":"t"}
{"type":"error","message":"Reconnecting... 2/5 (workspace routing discovery unauthorized (401))"}
{"type":"turn.failed","error":{"message":"workspace routing discovery unauthorized (401)"}}"#;
let err = b"ERROR codex_login::auth::manager: Failed to refresh token: Your access token \
could not be refreshed because your refresh token was already used. Please log out and sign in again.";
assert_eq!(failure(out, err), Some(Failure::LoginDead));
assert!(Failure::LoginDead.to_string().contains("codex login"));
assert_eq!(
failure(out, b""),
Some(Failure::LoginRefused(
"workspace routing discovery unauthorized (401)".into()
))
);
let out = br#"{"type":"turn.failed","error":{"message":"You've hit your usage limit."}}"#;
assert_eq!(
failure(out, b""),
Some(Failure::Said("You've hit your usage limit.".into()))
);
assert_eq!(failure(b"not json", b"noise"), None);
}
#[test]
fn names_and_args() {
assert_eq!(safe_name("../../etc/x.png", 0), "x.png");
assert_eq!(safe_name("..", 1), "image-2");
assert_eq!(safe_name(".hidden", 2), "image-3");
let mut e = Exec::new("codex", "/h", "/t");
e.model = Some("m".into());
let a = e.argv(Path::new("/t/r"), &[PathBuf::from("/t/r/refs/a.png")]);
assert_eq!(a[0], "exec");
assert!(a.windows(2).any(|w| w == ["--sandbox", "read-only"]));
assert!(a.windows(2).any(|w| w == ["--cd", "/t/r/work"]));
assert!(a.windows(2).any(|w| w == ["--model", "m"]));
assert!(a.contains(&"--image=/t/r/refs/a.png".to_owned()));
assert!(a.ends_with(&["--".into(), "-".into()]));
e.env = vec![
("PATH".into(), "/bin".into()),
("HOME".into(), "/nope".into()),
];
let env = e.child_env(Path::new("/t/r"));
let get = |k: &str| {
env.iter()
.filter(|(n, _)| n == k)
.map(|(_, v)| v.as_str())
.collect::<Vec<_>>()
};
assert_eq!(get("HOME"), ["/t/r"]);
assert_eq!(get("CODEX_HOME"), ["/t/r/home"]);
assert_eq!(get("PATH"), ["/bin"]);
assert_eq!(get("LANG"), ["C.UTF-8"]);
}
#[tokio::test]
async fn oldest_first_regular_files_only() {
let d = tempfile::tempdir().unwrap();
let g = d.path().join("t1");
tokio::fs::create_dir_all(&g).await.unwrap();
tokio::fs::write(g.join("a.png"), b"1").await.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
tokio::fs::write(g.join("b.jpg"), b"2").await.unwrap();
tokio::fs::write(g.join("big.png"), b"0123456789")
.await
.unwrap();
std::os::unix::fs::symlink("/etc/passwd", g.join("link.png")).unwrap();
let names: Vec<_> = files_oldest_first(d.path(), 4)
.await
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, ["a.png", "b.jpg"]);
assert_eq!(
files_oldest_first(&d.path().join("none"), 4).await,
Vec::<PathBuf>::new()
);
}
}