@@ -6,6 +6,8 @@ use std::sync::Arc;
66use anyhow:: { Context , Result } ;
77use async_trait:: async_trait;
88use parking_lot:: Mutex ;
9+ use serde:: { Deserialize , Serialize } ;
10+ use serde_json:: { Map , Value } ;
911use tempfile:: NamedTempFile ;
1012use warp_cli:: agent:: Harness ;
1113use warp_managed_secrets:: ManagedSecretValue ;
@@ -20,6 +22,7 @@ use crate::terminal::CLIAgent;
2022
2123use super :: super :: terminal:: { CommandHandle , TerminalDriver } ;
2224use super :: super :: { AgentDriver , AgentDriverError } ;
25+ use super :: json_utils:: read_json_file_or_default;
2326use super :: { write_temp_file, HarnessRunner , ResumePayload , SavePoint , ThirdPartyHarness } ;
2427
2528pub ( 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
214217const CODEX_CONFIG_DIR : & str = ".codex" ;
215218const 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
226256fn 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