diff --git a/codex-rs/core/src/config/agent_roles.rs b/codex-rs/core/src/config/agent_roles.rs index b1d28cf8388b..898ddef8cc54 100644 --- a/codex-rs/core/src/config/agent_roles.rs +++ b/codex-rs/core/src/config/agent_roles.rs @@ -33,7 +33,8 @@ pub(crate) async fn load_agent_roles( for layer in layers { let mut layer_roles: BTreeMap = BTreeMap::new(); let mut declared_role_files = BTreeSet::new(); - let agents_toml = match agents_toml_from_layer(&layer.config) { + let config_folder = layer.config_folder(); + let agents_toml = match agents_toml_from_layer(&layer.config, config_folder.as_deref()) { Ok(agents_toml) => agents_toml, Err(err) => { push_agent_role_warning(startup_warnings, err); @@ -169,11 +170,16 @@ fn merge_missing_role_fields(role: &mut AgentRoleConfig, fallback: &AgentRoleCon .or(fallback.nickname_candidates.clone()); } -fn agents_toml_from_layer(layer_toml: &TomlValue) -> std::io::Result> { +fn agents_toml_from_layer( + layer_toml: &TomlValue, + config_base_dir: Option<&Path>, +) -> std::io::Result> { let Some(agents_toml) = layer_toml.get("agents") else { return Ok(None); }; + // AbsolutePathBufGuard resolves relative paths while it remains in scope. + let _guard = config_base_dir.map(AbsolutePathBufGuard::new); agents_toml .clone() .try_into() diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 3dddde05d101..74d7910ee65e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4045,6 +4045,63 @@ nickname_candidates = ["Hypatia", "Noether"] Ok(()) } +#[tokio::test] +async fn agent_role_relative_config_file_resolves_from_config_layer() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let role_config_path = codex_home.path().join("agents").join("researcher.toml"); + tokio::fs::create_dir_all( + role_config_path + .parent() + .expect("role config should have a parent directory"), + ) + .await?; + tokio::fs::write( + &role_config_path, + "developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"", + ) + .await?; + let layer_config = toml::from_str( + r#"[agents.researcher] +description = "Research role" +config_file = "./agents/researcher.toml" +"#, + ) + .expect("agent role layer config should parse"); + let config_layer_stack = crate::config_loader::ConfigLayerStack::new( + vec![crate::config_loader::ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: codex_home.path().join(CONFIG_TOML_FILE).abs(), + }, + layer_config, + )], + Default::default(), + crate::config_loader::ConfigRequirementsToml::default(), + ) + .map_err(std::io::Error::other)?; + + let config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + ConfigToml::default(), + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + config_layer_stack, + ) + .await?; + + assert_eq!( + config + .agent_roles + .get("researcher") + .and_then(|role| role.config_file.as_ref()), + Some(&role_config_path) + ); + + Ok(()) +} + #[tokio::test] async fn agent_role_file_metadata_overrides_config_toml_metadata() -> std::io::Result<()> { let codex_home = TempDir::new()?;