Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/ai/agent/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3516,6 +3516,7 @@ pub enum AIAgentHarness {
Oz,
ClaudeCode,
Gemini,
Codex,
Unknown,
}

Expand Down
243 changes: 243 additions & 0 deletions app/src/ai/agent_sdk/driver/harness/codex.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::Arc;

use anyhow::{Context, Result};
use async_trait::async_trait;
use parking_lot::Mutex;
use tempfile::NamedTempFile;
use warp_cli::agent::Harness;
use warp_managed_secrets::ManagedSecretValue;
use warpui::{ModelHandle, ModelSpawner};

use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::harness_support::HarnessSupportClient;
use crate::server::server_api::ServerApi;
use crate::terminal::model::block::BlockId;
use crate::terminal::CLIAgent;

use super::super::terminal::{CommandHandle, TerminalDriver};
use super::super::{AgentDriver, AgentDriverError};
use super::{write_temp_file, HarnessRunner, ResumePayload, SavePoint, ThirdPartyHarness};

pub(crate) struct CodexHarness;

/// Format slug sent to the server when creating a Codex conversation.
const CODEX_CLI_FORMAT: &str = "codex_cli";
/// Slash command Codex's TUI recognises as a graceful shutdown.
const CODEX_EXIT_COMMAND: &str = "/exit";

#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl ThirdPartyHarness for CodexHarness {
fn harness(&self) -> Harness {
Harness::Codex
}

fn cli_agent(&self) -> CLIAgent {
CLIAgent::Codex
}

fn install_docs_url(&self) -> Option<&'static str> {
Some("https://developers.openai.com/codex/cli")
}

fn prepare_environment_config(
&self,
_working_dir: &Path,
system_prompt: Option<&str>,
_secrets: &HashMap<String, ManagedSecretValue>,
) -> Result<(), AgentDriverError> {
prepare_codex_environment_config(system_prompt).map_err(|error| {
AgentDriverError::HarnessConfigSetupFailed {
harness: self.cli_agent().command_prefix().to_owned(),
error,
}
})
}

fn build_runner(
&self,
prompt: &str,
system_prompt: Option<&str>,
_resumption_prompt: Option<&str>,
working_dir: &Path,
_task_id: Option<AmbientAgentTaskId>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
_resume: Option<ResumePayload>,
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
// TODO(REMOTE-1503): support resume for Codex.
let client: Arc<dyn HarnessSupportClient> = server_api;
Ok(Box::new(CodexHarnessRunner::new(
self.cli_agent().command_prefix(),
prompt,
system_prompt,
working_dir,
client,
terminal_driver,
)?))
}
}

/// Build the shell command that launches the Codex TUI.
///
/// `--dangerously-bypass-approvals-and-sandbox` disables both the sandbox and approval
/// prompts so the agent can run autonomously.
fn codex_command(cli_name: &str, prompt_path: &str) -> String {
format!("{cli_name} --dangerously-bypass-approvals-and-sandbox \"$(cat '{prompt_path}')\"")
}

enum CodexRunnerState {
Preexec,
Running {
conversation_id: AIConversationId,
block_id: BlockId,
},
}

struct CodexHarnessRunner {
command: String,
/// Held so the temp file is cleaned up when the runner is dropped.
_temp_prompt_file: NamedTempFile,
client: Arc<dyn HarnessSupportClient>,
terminal_driver: ModelHandle<TerminalDriver>,
state: Mutex<CodexRunnerState>,
}

impl CodexHarnessRunner {
fn new(
cli_command: &str,
prompt: &str,
_system_prompt: Option<&str>,
_working_dir: &Path,
client: Arc<dyn HarnessSupportClient>,
terminal_driver: ModelHandle<TerminalDriver>,
) -> Result<Self, AgentDriverError> {
let temp_file = write_temp_file("oz_prompt_", prompt)?;
let prompt_path = temp_file.path().display().to_string();

Ok(Self {
command: codex_command(cli_command, &prompt_path),
_temp_prompt_file: temp_file,
client,
terminal_driver,
state: Mutex::new(CodexRunnerState::Preexec),
})
}
}

#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl HarnessRunner for CodexHarnessRunner {
async fn start(
&self,
foreground: &ModelSpawner<AgentDriver>,
) -> Result<CommandHandle, AgentDriverError> {
let conversation_id = self
.client
.create_external_conversation(CODEX_CLI_FORMAT)
.await
.map_err(|e| {
log::error!("Failed to create external conversation: {e}");
AgentDriverError::ConfigBuildFailed(e)
})?;
log::info!("Created external conversation {conversation_id}");

let command = self.command.clone();
let terminal_driver = self.terminal_driver.clone();
let command_handle = foreground
.spawn(move |_, ctx| {
terminal_driver.update(ctx, |driver, ctx| driver.execute_command(&command, ctx))
})
.await??
.await?;

*self.state.lock() = CodexRunnerState::Running {
conversation_id,
block_id: command_handle.block_id().clone(),
};

Ok(command_handle)
}

async fn exit(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
log::info!("Sending /exit to Codex CLI");
let terminal_driver = self.terminal_driver.clone();
foreground
.spawn(move |_, ctx| {
terminal_driver.update(ctx, |driver, ctx| {
driver.send_text_to_cli(CODEX_EXIT_COMMAND.to_string(), ctx);
});
})
.await
.map_err(|_| anyhow::anyhow!("Agent driver dropped while sending /exit"))
}

async fn save_conversation(
&self,
save_point: SavePoint,
foreground: &ModelSpawner<AgentDriver>,
) -> Result<()> {
if matches!(save_point, SavePoint::Periodic)
&& !super::has_running_cli_agent(&self.terminal_driver, foreground).await
{
log::debug!("Will not save conversation, Codex not in progress");
return Ok(());
}

let (conversation_id, block_id) = match &*self.state.lock() {
CodexRunnerState::Preexec => {
log::warn!("save_conversation called before start");
return Ok(());
}
CodexRunnerState::Running {
conversation_id,
block_id,
} => (*conversation_id, block_id.clone()),
};

// TODO(REMOTE-1504) Also save the conversation transcript.
super::upload_current_block_snapshot(
foreground,
&self.terminal_driver,
self.client.as_ref(),
conversation_id,
block_id,
)
.await
}
}

const CODEX_CONFIG_DIR: &str = ".codex";
const CODEX_AGENTS_OVERRIDE_FILE_NAME: &str = "AGENTS.override.md";

fn prepare_codex_environment_config(system_prompt: Option<&str>) -> Result<()> {
let Some(prompt) = system_prompt else {
return Ok(());
};
let home_dir =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
write_codex_agents_override(&home_dir.join(CODEX_CONFIG_DIR), prompt)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [CRITICAL] This stages Warp's trusted system prompt as global AGENTS guidance, but Codex appends project AGENTS files after global guidance, so repo-controlled instructions can override it while the harness is running with approvals and sandboxing disabled; pass the prompt through Codex developer/system instruction config (-c developer_instructions=... or a temp model_instructions_file) instead of AGENTS.override.md.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

developer_instructions is also promising—might switch to this in a future PR (since a lot of the infrastructure for adding to the config files is introduced in #9376)

}

fn write_codex_agents_override(codex_dir: &Path, system_prompt: &str) -> Result<()> {
fs::create_dir_all(codex_dir).with_context(|| {
format!(
"Failed to create Codex config dir at {}",
codex_dir.display()
)
})?;

// Note: this currently works because we are only doing this for cloud agents; if we enable
// this for local runs we'll want to make sure we don't clobber any existing file overrides.
let prompt_path = codex_dir.join(CODEX_AGENTS_OVERRIDE_FILE_NAME);
fs::write(&prompt_path, system_prompt).with_context(|| {
format!(
"Failed to write Codex system prompt to {}",
prompt_path.display()
)
})
}
3 changes: 3 additions & 0 deletions app/src/ai/agent_sdk/driver/harness/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ use super::{

mod claude_code;
pub(crate) mod claude_transcript;
mod codex;
mod gemini;
mod json_utils;

pub(crate) use claude_code::ClaudeHarness;
use claude_transcript::ClaudeResumeInfo;
use codex::CodexHarness;
use gemini::GeminiHarness;

/// Harness-agnostic payload describing how to resume an existing conversation.
Expand Down Expand Up @@ -167,6 +169,7 @@ pub(crate) fn harness_kind(harness: Harness) -> Result<HarnessKind, AgentDriverE
Harness::Claude => Ok(HarnessKind::ThirdParty(Box::new(ClaudeHarness))),
Harness::OpenCode => Ok(HarnessKind::Unsupported(Harness::OpenCode)),
Harness::Gemini => Ok(HarnessKind::ThirdParty(Box::new(GeminiHarness))),
Harness::Codex => Ok(HarnessKind::ThirdParty(Box::new(CodexHarness))),
Harness::Unknown => Err(AgentDriverError::InvalidRuntimeState),
}
}
Expand Down
1 change: 1 addition & 0 deletions app/src/ai/agent_sdk/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,7 @@ fn resolve_orchestration_harness_label() -> &'static str {
Some(Harness::Claude) => "claude",
Some(Harness::OpenCode) => "opencode",
Some(Harness::Gemini) => "gemini",
Some(Harness::Codex) => "codex",
Some(Harness::Unknown) | None => "unknown",
}
}
Expand Down
1 change: 1 addition & 0 deletions app/src/ai/ambient_agents/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ pub(crate) fn harness_from_name(name: &str) -> Harness {
"claude" => Harness::Claude,
"opencode" => Harness::OpenCode,
"gemini" => Harness::Gemini,
"codex" => Harness::Codex,
"oz" => Harness::Oz,
other => {
log::warn!("Unknown harness config name: {other:?}; treating as Unknown");
Expand Down
2 changes: 1 addition & 1 deletion app/src/ai/blocklist/history_model/conversation_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ pub async fn load_conversation_from_server(
}
}
}
AIAgentHarness::ClaudeCode | AIAgentHarness::Gemini => {
AIAgentHarness::ClaudeCode | AIAgentHarness::Gemini | AIAgentHarness::Codex => {
if !FeatureFlag::AgentHarness.is_enabled() {
log::warn!("Ignoring non-Oz conversation {conversation_id}: AgentHarness flag is disabled");
return None;
Expand Down
6 changes: 5 additions & 1 deletion app/src/ai/harness_display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use warp_cli::agent::Harness;

use crate::ai::agent::conversation::AIAgentHarness;
use crate::ai::blocklist::CLAUDE_ORANGE;
use crate::terminal::cli_agent::GEMINI_BLUE;
use crate::terminal::cli_agent::{GEMINI_BLUE, OPENAI_COLOR};
use crate::ui_components::icons::Icon;

/// User-visible display name for a [`Harness`].
Expand All @@ -19,6 +19,7 @@ pub fn display_name(harness: Harness) -> &'static str {
Harness::Claude => "Claude Code",
Harness::OpenCode => "OpenCode",
Harness::Gemini => "Gemini CLI",
Harness::Codex => "Codex",
Harness::Unknown => "Unknown",
}
}
Expand All @@ -30,6 +31,7 @@ pub fn icon_for(harness: Harness) -> Icon {
Harness::Claude => Icon::ClaudeLogo,
Harness::OpenCode => Icon::OpenCodeLogo,
Harness::Gemini => Icon::GeminiLogo,
Harness::Codex => Icon::OpenAILogo,
Harness::Unknown => Icon::HelpCircle,
}
}
Expand All @@ -42,6 +44,7 @@ pub fn brand_color(harness: Harness) -> Option<ColorU> {
Harness::Claude => Some(CLAUDE_ORANGE),
Harness::OpenCode => None,
Harness::Gemini => Some(GEMINI_BLUE),
Harness::Codex => Some(OPENAI_COLOR),
Harness::Unknown => None,
}
}
Expand All @@ -54,6 +57,7 @@ impl From<AIAgentHarness> for Harness {
AIAgentHarness::Oz => Harness::Oz,
AIAgentHarness::ClaudeCode => Harness::Claude,
AIAgentHarness::Gemini => Harness::Gemini,
AIAgentHarness::Codex => Harness::Codex,
AIAgentHarness::Unknown => Harness::Unknown,
}
}
Expand Down
1 change: 1 addition & 0 deletions app/src/pane_group/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3779,6 +3779,7 @@ impl PaneGroup {
let harness = match cli_conversation.metadata.harness {
AIAgentHarness::ClaudeCode => Some(Harness::Claude),
AIAgentHarness::Gemini => Some(Harness::Gemini),
AIAgentHarness::Codex => Some(Harness::Codex),
AIAgentHarness::Oz => None,
AIAgentHarness::Unknown => Some(Harness::Unknown),
};
Expand Down
5 changes: 4 additions & 1 deletion app/src/pane_group/pane/local_harness_launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ pub(super) fn build_local_opencode_child_command(prompt: &str) -> String {

fn local_child_task_config(harness: Harness) -> Option<AgentConfigSnapshot> {
match harness {
Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Unknown => None,
Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Codex | Harness::Unknown => {
None
}
Harness::Claude => Some(AgentConfigSnapshot {
harness: Some(HarnessConfig::from_harness_type(harness)),
..Default::default()
Expand Down Expand Up @@ -87,6 +89,7 @@ pub(super) async fn prepare_local_harness_child_launch(
let command = match harness {
Harness::Oz => unreachable!("normalize_local_child_harness filters out Oz"),
Harness::Unknown => unreachable!("normalize_local_child_harness filters out Unknown"),
Harness::Codex => unreachable!("normalize_local_child_harness filters out Codex"),
Harness::Claude => {
let working_dir = startup_directory
.or_else(|| std::env::current_dir().ok())
Expand Down
1 change: 1 addition & 0 deletions app/src/server/server_api/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2267,6 +2267,7 @@ fn convert_harness(harness: warp_graphql::ai::AgentHarness) -> AIAgentHarness {
warp_graphql::ai::AgentHarness::Oz => AIAgentHarness::Oz,
warp_graphql::ai::AgentHarness::ClaudeCode => AIAgentHarness::ClaudeCode,
warp_graphql::ai::AgentHarness::Gemini => AIAgentHarness::Gemini,
warp_graphql::ai::AgentHarness::Codex => AIAgentHarness::Codex,
warp_graphql::ai::AgentHarness::Other(value) => {
report_error!(anyhow!(
"Invalid AgentHarness '{value}'. Make sure to update client GraphQL types!"
Expand Down
2 changes: 1 addition & 1 deletion app/src/terminal/cli_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ pub(crate) const GEMINI_BLUE: ColorU = ColorU {
};

/// OpenAI brand color (dark gray/black)
const OPENAI_COLOR: ColorU = ColorU {
pub(crate) const OPENAI_COLOR: ColorU = ColorU {
r: 0,
g: 0,
b: 0,
Expand Down
1 change: 1 addition & 0 deletions app/src/terminal/view/ambient_agent/harness_selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ fn build_menu_items(
item_for(Harness::Oz),
item_for(Harness::Claude),
item_for(Harness::Gemini),
item_for(Harness::Codex),
]
}

Expand Down
1 change: 1 addition & 0 deletions app/src/terminal/view/ambient_agent/view_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ impl TerminalView {
Harness::Claude => matches!(cli_agent, CLIAgent::Claude),
Harness::OpenCode => matches!(cli_agent, CLIAgent::OpenCode),
Harness::Gemini => matches!(cli_agent, CLIAgent::Gemini),
Harness::Codex => matches!(cli_agent, CLIAgent::Codex),
Harness::Unknown => false,
}
}
Expand Down
Loading
Loading