diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7af7180607..192e7b19da 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2701,6 +2701,7 @@ dependencies = [ "codex-exec-server", "codex-extension-api", "codex-features", + "codex-home", "codex-login", "codex-model-provider-info", "codex-models-manager", @@ -3070,6 +3071,17 @@ dependencies = [ "codex-protocol", ] +[[package]] +name = "codex-home" +version = "0.0.0" +dependencies = [ + "codex-extension-api", + "codex-utils-absolute-path", + "pretty_assertions", + "tempfile", + "tokio", +] + [[package]] name = "codex-hooks" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0e3aba3288..6f384eff72 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -37,6 +37,7 @@ members = [ "core-api", "core-plugins", "core-skills", + "home", "hooks", "secrets", "exec", @@ -169,6 +170,7 @@ codex-execpolicy = { path = "execpolicy" } codex-extension-api = { path = "ext/extension-api" } codex-goal-extension = { path = "ext/goal" } codex-guardian = { path = "ext/guardian" } +codex-home = { path = "home" } codex-image-generation-extension = { path = "ext/image-generation" } codex-external-agent-migration = { path = "external-agent-migration" } codex-external-agent-sessions = { path = "external-agent-sessions" } diff --git a/codex-rs/core-api/Cargo.toml b/codex-rs/core-api/Cargo.toml index 998da39ce7..3812b56ecb 100644 --- a/codex-rs/core-api/Cargo.toml +++ b/codex-rs/core-api/Cargo.toml @@ -22,6 +22,7 @@ codex-core = { workspace = true } codex-extension-api = { workspace = true } codex-exec-server = { workspace = true } codex-features = { workspace = true } +codex-home = { workspace = true } codex-login = { workspace = true } codex-model-provider-info = { workspace = true } codex-models-manager = { workspace = true } diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index fa9169df13..ed3ac2bfe8 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -55,6 +55,7 @@ pub use codex_extension_api::GlobalInstructionsFuture; pub use codex_extension_api::empty_extension_registry; pub use codex_features::Feature; pub use codex_features::Features; +pub use codex_home::CodexHomeInstructionsContributor; pub use codex_login::AuthManager; pub use codex_login::default_client::set_default_originator; pub use codex_model_provider_info::OPENAI_PROVIDER_ID; diff --git a/codex-rs/home/BUILD.bazel b/codex-rs/home/BUILD.bazel new file mode 100644 index 0000000000..0534af975e --- /dev/null +++ b/codex-rs/home/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "home", + crate_name = "codex_home", +) diff --git a/codex-rs/home/Cargo.toml b/codex-rs/home/Cargo.toml new file mode 100644 index 0000000000..9f97201c0a --- /dev/null +++ b/codex-rs/home/Cargo.toml @@ -0,0 +1,22 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-home" +version.workspace = true + +[lib] +name = "codex_home" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-extension-api = { workspace = true } +codex-utils-absolute-path = { workspace = true } +tokio = { workspace = true, features = ["fs"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/home/src/lib.rs b/codex-rs/home/src/lib.rs new file mode 100644 index 0000000000..7688044f81 --- /dev/null +++ b/codex-rs/home/src/lib.rs @@ -0,0 +1,76 @@ +use std::io; + +use codex_extension_api::GlobalInstruction; +use codex_extension_api::GlobalInstructions; +use codex_extension_api::GlobalInstructionsContributor; +use codex_extension_api::GlobalInstructionsFuture; +use codex_utils_absolute_path::AbsolutePathBuf; + +pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md"; +pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md"; + +/// Loads global instructions from a configured Codex home directory. +pub struct CodexHomeInstructionsContributor { + codex_home: AbsolutePathBuf, +} + +impl CodexHomeInstructionsContributor { + pub fn new(codex_home: AbsolutePathBuf) -> Self { + Self { codex_home } + } + + async fn load(&self) -> Result { + let mut warnings = Vec::new(); + let mut read_errors = Vec::new(); + for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] { + let path = self.codex_home.join(candidate); + let data = match tokio::fs::read(path.as_path()).await { + Ok(data) => data, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) if err.kind() == io::ErrorKind::IsADirectory => continue, + Err(err) => { + let warning = format!( + "Failed to read global AGENTS.md instructions from `{}`: {err}", + path.display() + ); + warnings.push(warning.clone()); + read_errors.push(warning); + continue; + } + }; + if let Err(err) = std::str::from_utf8(&data) { + warnings.push(format!( + "Global AGENTS.md instructions from `{}` contain invalid UTF-8: {err}. Invalid byte sequences were replaced.", + path.display(), + )); + } + let contents = String::from_utf8_lossy(&data); + let trimmed = contents.trim(); + if !trimmed.is_empty() { + return Ok(GlobalInstructions { + instructions: vec![GlobalInstruction::new(trimmed.to_string(), Some(path))], + warnings, + }); + } + } + + if !read_errors.is_empty() { + return Err(read_errors.join("; ")); + } + + Ok(GlobalInstructions { + instructions: Vec::new(), + warnings, + }) + } +} + +impl GlobalInstructionsContributor for CodexHomeInstructionsContributor { + fn contribute(&self) -> GlobalInstructionsFuture<'_> { + Box::pin(self.load()) + } +} + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/codex-rs/home/src/lib_tests.rs b/codex-rs/home/src/lib_tests.rs new file mode 100644 index 0000000000..7fac68ac0e --- /dev/null +++ b/codex-rs/home/src/lib_tests.rs @@ -0,0 +1,166 @@ +use std::fs; + +use codex_extension_api::GlobalInstruction; +use codex_extension_api::GlobalInstructions; +use codex_extension_api::GlobalInstructionsContributor; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +use super::CodexHomeInstructionsContributor; + +fn contributor(temp: &TempDir) -> CodexHomeInstructionsContributor { + CodexHomeInstructionsContributor::new( + AbsolutePathBuf::try_from(temp.path().to_path_buf()).expect("absolute temp path"), + ) +} + +#[tokio::test] +async fn loads_override_before_default() { + let temp = TempDir::new().expect("temp dir"); + fs::write(temp.path().join("AGENTS.md"), "default").expect("write default"); + fs::write(temp.path().join("AGENTS.override.md"), "override").expect("write override"); + + let actual = contributor(&temp).contribute().await.expect("load"); + + assert_eq!( + actual, + GlobalInstructions { + instructions: vec![GlobalInstruction::new( + "override", + Some( + AbsolutePathBuf::try_from(temp.path().join("AGENTS.override.md")) + .expect("absolute override path") + ), + )], + warnings: Vec::new(), + } + ); +} + +#[tokio::test] +async fn falls_back_from_empty_override_to_default() { + let temp = TempDir::new().expect("temp dir"); + fs::write(temp.path().join("AGENTS.override.md"), " \n").expect("write override"); + fs::write(temp.path().join("AGENTS.md"), "default").expect("write default"); + + let actual = contributor(&temp).contribute().await.expect("load"); + + assert_eq!( + actual, + GlobalInstructions { + instructions: vec![GlobalInstruction::new( + "default", + Some( + AbsolutePathBuf::try_from(temp.path().join("AGENTS.md")) + .expect("absolute default path") + ), + )], + warnings: Vec::new(), + } + ); +} + +#[tokio::test] +async fn falls_back_from_directory_override_to_default() { + let temp = TempDir::new().expect("temp dir"); + fs::create_dir(temp.path().join("AGENTS.override.md")).expect("create override directory"); + fs::write(temp.path().join("AGENTS.md"), "default").expect("write default"); + + let actual = contributor(&temp).contribute().await.expect("load"); + + assert_eq!( + actual.instructions, + vec![GlobalInstruction::new( + "default", + Some( + AbsolutePathBuf::try_from(temp.path().join("AGENTS.md")) + .expect("absolute default path") + ), + )] + ); +} + +#[tokio::test] +async fn whitespace_only_files_return_empty_instructions() { + let temp = TempDir::new().expect("temp dir"); + fs::write(temp.path().join("AGENTS.override.md"), " \n").expect("write override"); + fs::write(temp.path().join("AGENTS.md"), "\t").expect("write default"); + + let actual = contributor(&temp).contribute().await.expect("load"); + + assert_eq!(actual, GlobalInstructions::default()); +} + +#[tokio::test] +async fn loads_default_without_an_execution_environment() { + let temp = TempDir::new().expect("temp dir"); + fs::write(temp.path().join("AGENTS.md"), "default").expect("write default"); + + let actual = contributor(&temp).contribute().await.expect("load"); + + assert_eq!( + actual.instructions, + vec![GlobalInstruction::new( + "default", + Some( + AbsolutePathBuf::try_from(temp.path().join("AGENTS.md")) + .expect("absolute default path") + ), + )] + ); +} + +#[tokio::test] +async fn missing_files_return_empty_instructions() { + let temp = TempDir::new().expect("temp dir"); + + let actual = contributor(&temp).contribute().await.expect("load"); + + assert_eq!(actual, Default::default()); +} + +#[tokio::test] +async fn invalid_utf8_is_lossy_and_warns() { + let temp = TempDir::new().expect("temp dir"); + let path = temp.path().join("AGENTS.md"); + let data = vec![ + b'g', b'l', b'o', b'b', b'a', b'l', 0xff, b' ', b'd', b'o', b'c', + ]; + fs::write(&path, &data).expect("write default"); + let utf8_error = std::str::from_utf8(&data).expect_err("fixture should be invalid UTF-8"); + + let actual = contributor(&temp).contribute().await.expect("load"); + + assert_eq!( + actual, + GlobalInstructions { + instructions: vec![GlobalInstruction::new( + "global\u{fffd} doc", + Some(AbsolutePathBuf::try_from(path.clone()).expect("absolute default path")), + )], + warnings: vec![format!( + "Global AGENTS.md instructions from `{}` contain invalid UTF-8: {utf8_error}. Invalid byte sequences were replaced.", + path.display() + )], + } + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn unreadable_candidates_fail_instead_of_deleting_the_previous_snapshot() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().expect("temp dir"); + let path = temp.path().join("AGENTS.override.md"); + symlink(&path, &path).expect("create symlink loop"); + + let error = contributor(&temp) + .contribute() + .await + .expect_err("unreadable candidate should fail"); + + assert!(error.contains("Failed to read global AGENTS.md instructions")); + assert!(error.contains(&path.display().to_string())); +}