Skip to content

Commit 4dddda6

Browse files
authored
Preseed auth and trust settings for codex CLI. (warpdotdev#9376)
## Description <!-- Please remember to add your design buddy onto the PR for review, if it contains any UI changes! --> This PR seeds the trust and auth config files for the codex harness, so that we don't get interactive dialogs re: trusting project folders or setting up auth when running in an autonomous cloud agent context. This is handled similarly to the Claude Code and Gemini settings configs. Of note: - Codex doesn't currently support recursively trusting repos, we manually add trust to the `config.toml` for both the working dir and any children git repos of that dir. This is relevant in the cloud agent case since we create a `workspace/` dir and clone all environment repos into that dir, but we want to make sure they're trusted as well. - We currently hardcode the OpenAI base URL to the US endpoint—this is a temporary stopgap to unblock dogfood testing because the staging API key hits this endpoint, but the longer-term solution will be to have a new dedicated `ManagedSecret` type that can take in both an OpenAI API key and optionally a base URL to use it with. ## Testing <!-- How did you test this change? What automated tests did you add? If you didn't add any new tests, what's your justification for not adding any? If you're not sure whether you should add a test, check our testing policy: https://www.notion.so/warpdev/How-We-Code-at-Warp-257fe43d556e4b3c8dfd42f70004cc72#1f97825450504baa9c5fd87a737daa09 --> Added unit tests that cover setting up the settings files and making sure that we don't clobber any existing settings. Tested manually after removing all of my local codex config to make sure that we don't get popups and can run queries correctly: https://github.com/user-attachments/assets/3417234f-f0f2-40c6-bb66-bbfa524276ca (Loom is having an incident but the show must go on, hence the QuickTime video) ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode
1 parent cca4346 commit 4dddda6

4 files changed

Lines changed: 545 additions & 8 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ tikv-jemallocator = { version = "0.6", optional = true, features = [
199199
"override_allocator_on_supported_platforms",
200200
] }
201201
toml = "0.8.13"
202+
toml_edit.workspace = true
202203
tracing.workspace = true
203204
ui_components.workspace = true
204205
unicase = "2.7.0"

app/src/ai/agent_sdk/driver/harness/codex.rs

Lines changed: 217 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ use std::sync::Arc;
66
use anyhow::{Context, Result};
77
use async_trait::async_trait;
88
use parking_lot::Mutex;
9+
use serde::{Deserialize, Serialize};
10+
use serde_json::{Map, Value};
911
use tempfile::NamedTempFile;
1012
use warp_cli::agent::Harness;
1113
use warp_managed_secrets::ManagedSecretValue;
@@ -20,6 +22,7 @@ use crate::terminal::CLIAgent;
2022

2123
use super::super::terminal::{CommandHandle, TerminalDriver};
2224
use super::super::{AgentDriver, AgentDriverError};
25+
use super::json_utils::read_json_file_or_default;
2326
use super::{write_temp_file, HarnessRunner, ResumePayload, SavePoint, ThirdPartyHarness};
2427

2528
pub(crate) struct CodexHarness;
@@ -46,11 +49,11 @@ impl ThirdPartyHarness for CodexHarness {
4649

4750
fn prepare_environment_config(
4851
&self,
49-
_working_dir: &Path,
52+
working_dir: &Path,
5053
system_prompt: Option<&str>,
51-
_secrets: &HashMap<String, ManagedSecretValue>,
54+
secrets: &HashMap<String, ManagedSecretValue>,
5255
) -> Result<(), AgentDriverError> {
53-
prepare_codex_environment_config(system_prompt).map_err(|error| {
56+
prepare_codex_environment_config(working_dir, system_prompt, secrets).map_err(|error| {
5457
AgentDriverError::HarnessConfigSetupFailed {
5558
harness: self.cli_agent().command_prefix().to_owned(),
5659
error,
@@ -213,14 +216,41 @@ impl HarnessRunner for CodexHarnessRunner {
213216

214217
const CODEX_CONFIG_DIR: &str = ".codex";
215218
const CODEX_AGENTS_OVERRIDE_FILE_NAME: &str = "AGENTS.override.md";
219+
const CODEX_AUTH_FILE_NAME: &str = "auth.json";
220+
const CODEX_CONFIG_TOML_FILE_NAME: &str = "config.toml";
221+
const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
222+
const CODEX_AUTH_MODE_API_KEY: &str = "apikey";
223+
/// Lowercase string Codex's `TrustLevel` enum serializes to (codex
224+
/// `protocol/src/config_types.rs::TrustLevel`).
225+
const CODEX_TRUST_LEVEL_TRUSTED: &str = "trusted";
226+
/// Top-level config key codex reads to override the built-in `openai` provider's base URL
227+
/// (codex `core/src/config/mod.rs`).
228+
const CODEX_OPENAI_BASE_URL_KEY: &str = "openai_base_url";
229+
/// US data-residency endpoint. Our OpenAI keys are issued under a US-residency project,
230+
/// which rejects requests to the global host with `401 incorrect_hostname`.
231+
/// TODO(REMOTE-1509): plumb a region-tagged auth secret instead of hardcoding the URL.
232+
const CODEX_OPENAI_BASE_URL: &str = "https://us.api.openai.com/v1";
216233

217-
fn prepare_codex_environment_config(system_prompt: Option<&str>) -> Result<()> {
218-
let Some(prompt) = system_prompt else {
219-
return Ok(());
220-
};
234+
fn prepare_codex_environment_config(
235+
working_dir: &Path,
236+
system_prompt: Option<&str>,
237+
secrets: &HashMap<String, ManagedSecretValue>,
238+
) -> Result<()> {
221239
let home_dir =
222240
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
223-
write_codex_agents_override(&home_dir.join(CODEX_CONFIG_DIR), prompt)
241+
let codex_dir = home_dir.join(CODEX_CONFIG_DIR);
242+
243+
if let Some(prompt) = system_prompt {
244+
write_codex_agents_override(&codex_dir, prompt)?;
245+
}
246+
247+
match resolve_openai_api_key(secrets) {
248+
Some(api_key) => prepare_codex_auth(&codex_dir.join(CODEX_AUTH_FILE_NAME), &api_key)?,
249+
None => log::info!("No OPENAI_API_KEY available; skipping Codex auth.json seed"),
250+
}
251+
252+
prepare_codex_config_toml(&codex_dir.join(CODEX_CONFIG_TOML_FILE_NAME), working_dir)?;
253+
Ok(())
224254
}
225255

226256
fn write_codex_agents_override(codex_dir: &Path, system_prompt: &str) -> Result<()> {
@@ -241,3 +271,182 @@ fn write_codex_agents_override(codex_dir: &Path, system_prompt: &str) -> Result<
241271
)
242272
})
243273
}
274+
275+
/// Mirrors the subset of Codex's `AuthDotJson` (codex `login/src/auth/storage.rs`) that we
276+
/// need to seed. Unknown fields (`tokens`, `last_refresh`, `agent_identity`, ...) are
277+
/// preserved via `extra` so we don't clobber an existing login.
278+
#[derive(Default, Deserialize, Serialize, Debug)]
279+
struct CodexAuthDotJson {
280+
#[serde(default, skip_serializing_if = "Option::is_none")]
281+
auth_mode: Option<String>,
282+
#[serde(
283+
rename = "OPENAI_API_KEY",
284+
default,
285+
skip_serializing_if = "Option::is_none"
286+
)]
287+
openai_api_key: Option<String>,
288+
#[serde(flatten)]
289+
extra: Map<String, Value>,
290+
}
291+
292+
fn prepare_codex_auth(auth_path: &Path, api_key: &str) -> Result<()> {
293+
let mut auth: CodexAuthDotJson = read_json_file_or_default(auth_path)?;
294+
auth.openai_api_key = Some(api_key.to_owned());
295+
if auth.auth_mode.is_none() {
296+
auth.auth_mode = Some(CODEX_AUTH_MODE_API_KEY.to_owned());
297+
}
298+
write_codex_auth_json(auth_path, &auth)
299+
}
300+
301+
/// Write Codex's `auth.json` with restrictive (0o600) permissions, mirroring how
302+
/// codex sets up this file itself.
303+
fn write_codex_auth_json(path: &Path, auth: &CodexAuthDotJson) -> Result<()> {
304+
if let Some(parent) = path.parent() {
305+
fs::create_dir_all(parent)
306+
.with_context(|| format!("Failed to create {}", parent.display()))?;
307+
}
308+
let bytes = serde_json::to_vec_pretty(auth).context("Failed to serialize Codex auth.json")?;
309+
310+
#[cfg(unix)]
311+
{
312+
use std::io::Write as _;
313+
use std::os::unix::fs::OpenOptionsExt;
314+
let mut file = fs::OpenOptions::new()
315+
.write(true)
316+
.create(true)
317+
.truncate(true)
318+
.mode(0o600)
319+
.open(path)
320+
.with_context(|| format!("Failed to open {} for writing", path.display()))?;
321+
file.write_all(&bytes)
322+
.with_context(|| format!("Failed to write {}", path.display()))?;
323+
}
324+
#[cfg(not(unix))]
325+
fs::write(path, &bytes).with_context(|| format!("Failed to write {}", path.display()))?;
326+
327+
Ok(())
328+
}
329+
330+
/// Returns the OpenAI API key for Codex auth, preferring the `OPENAI_API_KEY` env
331+
/// var so the seeded `auth.json` matches the credential the launched Codex process
332+
/// will see. [`AgentDriver::new`] skips a managed `OPENAI_API_KEY` secret when the
333+
/// env var is already set, so we mirror that precedence here.
334+
fn resolve_openai_api_key(secrets: &HashMap<String, ManagedSecretValue>) -> Option<String> {
335+
if let Ok(value) = std::env::var(OPENAI_API_KEY_ENV) {
336+
let trimmed = value.trim();
337+
if !trimmed.is_empty() {
338+
return Some(trimmed.to_owned());
339+
}
340+
}
341+
if let Some(ManagedSecretValue::RawValue { value }) = secrets.get(OPENAI_API_KEY_ENV) {
342+
let trimmed = value.trim();
343+
if !trimmed.is_empty() {
344+
return Some(trimmed.to_owned());
345+
}
346+
}
347+
None
348+
}
349+
350+
/// Edit `~/.codex/config.toml` via `toml_edit` to seed the harness defaults
351+
/// while preserving anything that might already exist there. We handle:
352+
/// - project trust: for a working dir and all of its git repo subdirectories,
353+
/// set the projects to `trusted`.
354+
/// - base URL: set `openai_base_url = "<US data-residency endpoint>"` so we
355+
/// hit the regional host our API keys require.
356+
fn prepare_codex_config_toml(config_toml_path: &Path, working_dir: &Path) -> Result<()> {
357+
let existing = match fs::read_to_string(config_toml_path) {
358+
Ok(content) => content,
359+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
360+
Err(e) => {
361+
return Err(anyhow::Error::from(e).context(format!(
362+
"Failed to read Codex config.toml at {}",
363+
config_toml_path.display()
364+
)));
365+
}
366+
};
367+
let mut doc: toml_edit::DocumentMut = existing.parse().with_context(|| {
368+
format!(
369+
"Failed to parse Codex config.toml at {}",
370+
config_toml_path.display()
371+
)
372+
})?;
373+
374+
set_codex_openai_base_url(&mut doc, CODEX_OPENAI_BASE_URL);
375+
376+
let canonical = working_dir.canonicalize().with_context(|| {
377+
format!(
378+
"Failed to canonicalize Codex working dir at {}",
379+
working_dir.display()
380+
)
381+
})?;
382+
let project_key = canonical.to_string_lossy().into_owned();
383+
set_codex_project_trust_level(&mut doc, &project_key, CODEX_TRUST_LEVEL_TRUSTED);
384+
385+
// Codex's trust check is not recursive (see openai/codex#19426) -- since we
386+
// clone the git repos into workspace/ for cloud agents, we usually have git
387+
// repo children that we also want to trust.
388+
for child_repo in find_child_git_repos(&canonical) {
389+
let key = child_repo.to_string_lossy().into_owned();
390+
set_codex_project_trust_level(&mut doc, &key, CODEX_TRUST_LEVEL_TRUSTED);
391+
}
392+
393+
if let Some(parent) = config_toml_path.parent() {
394+
fs::create_dir_all(parent).with_context(|| {
395+
format!("Failed to create Codex config dir at {}", parent.display())
396+
})?;
397+
}
398+
fs::write(config_toml_path, doc.to_string()).with_context(|| {
399+
format!(
400+
"Failed to write Codex config.toml at {}",
401+
config_toml_path.display()
402+
)
403+
})
404+
}
405+
406+
/// Set the top-level `openai_base_url` key, overwriting any existing value.
407+
fn set_codex_openai_base_url(doc: &mut toml_edit::DocumentMut, base_url: &str) {
408+
doc[CODEX_OPENAI_BASE_URL_KEY] = toml_edit::value(base_url);
409+
}
410+
411+
/// Return immediate subdirectories of `dir` that contain a `.git`.
412+
fn find_child_git_repos(dir: &Path) -> Vec<std::path::PathBuf> {
413+
let Ok(entries) = fs::read_dir(dir) else {
414+
return Vec::new();
415+
};
416+
entries
417+
.flatten()
418+
.filter_map(|entry| {
419+
let path = entry.path();
420+
(path.is_dir() && path.join(".git").exists()).then_some(path)
421+
})
422+
.collect()
423+
}
424+
425+
/// Insert/update `[projects."<project_key>"] trust_level = <trust_level>`.
426+
///
427+
/// Codex itself always writes `projects` as an explicit table, so we don't
428+
/// handle the inline-table form here.
429+
fn set_codex_project_trust_level(
430+
doc: &mut toml_edit::DocumentMut,
431+
project_key: &str,
432+
trust_level: &str,
433+
) {
434+
if !doc.contains_table("projects") {
435+
let mut projects_tbl = toml_edit::Table::new();
436+
projects_tbl.set_implicit(true);
437+
doc.insert("projects", toml_edit::Item::Table(projects_tbl));
438+
}
439+
let proj_tbl = doc["projects"]
440+
.as_table_mut()
441+
.expect("projects table inserted above")
442+
.entry(project_key)
443+
.or_insert_with(toml_edit::table)
444+
.as_table_mut()
445+
.expect("project entry is a table");
446+
proj_tbl.set_implicit(false);
447+
proj_tbl["trust_level"] = toml_edit::value(trust_level);
448+
}
449+
450+
#[cfg(test)]
451+
#[path = "codex_tests.rs"]
452+
mod tests;

0 commit comments

Comments
 (0)