unified-agent-api-codex 0.3.5

Async wrapper around the Codex CLI for programmatic prompting
Documentation
#![cfg(unix)]

use std::{
    fs,
    os::unix::fs::PermissionsExt,
    path::{Path, PathBuf},
};

use codex::{CodexAuthMethod, CodexAuthStatus, CodexClient, CodexLogoutStatus};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct EnvSnapshot {
    #[serde(rename = "CODEX_BINARY")]
    codex_binary: Option<String>,
    #[serde(rename = "CODEX_HOME")]
    codex_home: Option<String>,
}

#[derive(Debug, Deserialize)]
struct Invocation {
    binary: String,
    argv: Vec<String>,
    env: EnvSnapshot,
}

#[tokio::test]
async fn applies_env_overrides_across_spawn_sites() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempfile::tempdir()?;
    let log_path = temp.path().join("invocations.jsonl");
    let fake_codex = write_fake_codex(&log_path)?;
    let codex_home = temp.path().join("codex_home");

    let client = CodexClient::builder()
        .binary(&fake_codex)
        .codex_home(&codex_home)
        .mirror_stdout(false)
        .quiet(true)
        .build();

    let prompt = "hello";
    let exec_output = client.send_prompt(prompt).await?;
    assert_eq!(exec_output, "exec-ok");

    let status = client.login_status().await?;
    assert_eq!(status, CodexAuthStatus::LoggedIn(CodexAuthMethod::ChatGpt));

    let logout = client.logout().await?;
    assert_eq!(logout, CodexLogoutStatus::LoggedOut);

    let login_child = client.spawn_login_process()?;
    let login_output = login_child.wait_with_output().await?;
    assert!(login_output.status.success());

    assert!(codex_home.is_dir());
    assert!(codex_home.join("conversations").is_dir());
    assert!(codex_home.join("logs").is_dir());

    let invocations = read_invocations(&log_path)?;
    assert_eq!(invocations.len(), 4);

    let expected_binary = fake_codex.to_string_lossy().to_string();
    let expected_home = codex_home.to_string_lossy().to_string();

    for invocation in &invocations {
        assert_eq!(invocation.binary, expected_binary);
        assert_eq!(
            invocation.env.codex_binary.as_deref(),
            Some(expected_binary.as_str())
        );
        assert_eq!(
            invocation.env.codex_home.as_deref(),
            Some(expected_home.as_str())
        );
    }

    let exec_invocation = find_invocation(&invocations, |inv| {
        inv.argv.first().map(|arg| arg == "exec").unwrap_or(false)
    });
    assert!(
        exec_invocation.argv.contains(&prompt.to_string()),
        "prompt missing from exec args: {:?}",
        exec_invocation.argv
    );

    let login_status_invocation = find_invocation(&invocations, |inv| {
        inv.argv.len() >= 2 && inv.argv[0] == "login" && inv.argv[1] == "status"
    });
    assert_eq!(login_status_invocation.argv[0], "login");
    assert_eq!(login_status_invocation.argv[1], "status");

    let logout_invocation = find_invocation(&invocations, |inv| inv.argv == ["logout"]);
    assert_eq!(logout_invocation.argv, ["logout"]);

    let login_invocation = find_invocation(&invocations, |inv| inv.argv == ["login"]);
    assert_eq!(login_invocation.argv, ["login"]);

    Ok(())
}

fn write_fake_codex(log_path: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let script_path = log_path
        .parent()
        .unwrap_or_else(|| Path::new("."))
        .join("fake_codex.sh");
    let script = format!(
        r#"#!/usr/bin/env bash
set -euo pipefail

LOG_PATH="{log}"

python3 - "$LOG_PATH" "$0" "$@" <<'PY'
import json
import os
import sys

log_path = sys.argv[1]
binary = sys.argv[2]
argv = sys.argv[3:]

entry = {{
    'binary': binary,
    'argv': argv,
    'env': {{
        'CODEX_BINARY': os.environ.get('CODEX_BINARY'),
        'CODEX_HOME': os.environ.get('CODEX_HOME'),
    }},
}}

with open(log_path, 'a', encoding='utf-8') as handle:
    handle.write(json.dumps(entry))
    handle.write('\n')
PY

if [[ $# -ge 2 && $1 == "login" && $2 == "status" ]]; then
  echo "Logged in using ChatGPT"
elif [[ $# -ge 1 && $1 == "logout" ]]; then
  echo "Successfully logged out"
elif [[ $# -ge 1 && $1 == "login" ]]; then
  echo "Login helper"
elif [[ $# -ge 1 && $1 == "exec" ]]; then
  echo "exec-ok"
else
  echo "unknown command: $@" >&2
  exit 1
fi
"#,
        log = log_path.display()
    );

    fs::write(&script_path, script)?;
    let mut permissions = fs::metadata(&script_path)?.permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(&script_path, permissions)?;
    Ok(script_path)
}

fn read_invocations(log_path: &Path) -> Result<Vec<Invocation>, Box<dyn std::error::Error>> {
    let content = fs::read_to_string(log_path)?;
    let mut invocations = Vec::new();
    for line in content.lines() {
        if line.trim().is_empty() {
            continue;
        }
        invocations.push(serde_json::from_str(line)?);
    }
    Ok(invocations)
}

fn find_invocation<F>(invocations: &[Invocation], predicate: F) -> &Invocation
where
    F: Fn(&Invocation) -> bool,
{
    invocations
        .iter()
        .find(|inv| predicate(inv))
        .unwrap_or_else(|| panic!("missing invocation matching predicate"))
}