add(core): managed config (#3868)
## Summary
- Factor `load_config_as_toml` into `core::config_loader` so config
loading is reusable across callers.
- Layer `~/.codex/config.toml`, optional `~/.codex/managed_config.toml`,
and macOS managed preferences (base64) with recursive table merging and
scoped threads per source.
## Config Flow
```
Managed prefs (macOS profile: com.openai.codex/config_toml_base64)
▲
│
~/.codex/managed_config.toml │ (optional file-based override)
▲
│
~/.codex/config.toml (user-defined settings)
```
- The loader searches under the resolved `CODEX_HOME` directory
(defaults to `~/.codex`).
- Managed configs let administrators ship fleet-wide overrides via
device profiles which is useful for enforcing certain settings like
sandbox or approval defaults.
- For nested hash tables: overlays merge recursively. Child tables are
merged key-by-key, while scalar or array values replace the prior layer
entirely. This lets admins add or tweak individual fields without
clobbering unrelated user settings.
This commit is contained in:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -1035,6 +1035,7 @@ dependencies = [
|
|||||||
"codex-protocol",
|
"codex-protocol",
|
||||||
"codex-rmcp-client",
|
"codex-rmcp-client",
|
||||||
"codex-utils-string",
|
"codex-utils-string",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
"core_test_support",
|
"core_test_support",
|
||||||
"dirs",
|
"dirs",
|
||||||
"dunce",
|
"dunce",
|
||||||
|
|||||||
@@ -500,7 +500,7 @@ impl CodexMessageProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_user_saved_config(&self, request_id: RequestId) {
|
async fn get_user_saved_config(&self, request_id: RequestId) {
|
||||||
let toml_value = match load_config_as_toml(&self.config.codex_home) {
|
let toml_value = match load_config_as_toml(&self.config.codex_home).await {
|
||||||
Ok(val) => val,
|
Ok(val) => val,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let error = JSONRPCErrorError {
|
let error = JSONRPCErrorError {
|
||||||
@@ -653,18 +653,19 @@ impl CodexMessageProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) {
|
async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) {
|
||||||
let config = match derive_config_from_params(params, self.codex_linux_sandbox_exe.clone()) {
|
let config =
|
||||||
Ok(config) => config,
|
match derive_config_from_params(params, self.codex_linux_sandbox_exe.clone()).await {
|
||||||
Err(err) => {
|
Ok(config) => config,
|
||||||
let error = JSONRPCErrorError {
|
Err(err) => {
|
||||||
code: INVALID_REQUEST_ERROR_CODE,
|
let error = JSONRPCErrorError {
|
||||||
message: format!("error deriving config: {err}"),
|
code: INVALID_REQUEST_ERROR_CODE,
|
||||||
data: None,
|
message: format!("error deriving config: {err}"),
|
||||||
};
|
data: None,
|
||||||
self.outgoing.send_error(request_id, error).await;
|
};
|
||||||
return;
|
self.outgoing.send_error(request_id, error).await;
|
||||||
}
|
return;
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
match self.conversation_manager.new_conversation(config).await {
|
match self.conversation_manager.new_conversation(config).await {
|
||||||
Ok(conversation_id) => {
|
Ok(conversation_id) => {
|
||||||
@@ -752,7 +753,7 @@ impl CodexMessageProcessor {
|
|||||||
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
|
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
|
||||||
let config = match params.overrides {
|
let config = match params.overrides {
|
||||||
Some(overrides) => {
|
Some(overrides) => {
|
||||||
derive_config_from_params(overrides, self.codex_linux_sandbox_exe.clone())
|
derive_config_from_params(overrides, self.codex_linux_sandbox_exe.clone()).await
|
||||||
}
|
}
|
||||||
None => Ok(self.config.as_ref().clone()),
|
None => Ok(self.config.as_ref().clone()),
|
||||||
};
|
};
|
||||||
@@ -1320,7 +1321,7 @@ async fn apply_bespoke_event_handling(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn derive_config_from_params(
|
async fn derive_config_from_params(
|
||||||
params: NewConversationParams,
|
params: NewConversationParams,
|
||||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||||
) -> std::io::Result<Config> {
|
) -> std::io::Result<Config> {
|
||||||
@@ -1358,7 +1359,7 @@ fn derive_config_from_params(
|
|||||||
.map(|(k, v)| (k, json_to_toml(v)))
|
.map(|(k, v)| (k, json_to_toml(v)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Config::load_with_cli_overrides(cli_overrides, overrides)
|
Config::load_with_cli_overrides(cli_overrides, overrides).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_patch_approval_response(
|
async fn on_patch_approval_response(
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ pub async fn run_main(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let config = Config::load_with_cli_overrides(cli_kv_overrides, ConfigOverrides::default())
|
let config = Config::load_with_cli_overrides(cli_kv_overrides, ConfigOverrides::default())
|
||||||
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
|
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ pub async fn run_apply_command(
|
|||||||
.parse_overrides()
|
.parse_overrides()
|
||||||
.map_err(anyhow::Error::msg)?,
|
.map_err(anyhow::Error::msg)?,
|
||||||
ConfigOverrides::default(),
|
ConfigOverrides::default(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
init_chatgpt_token_from_auth(&config.codex_home).await?;
|
init_chatgpt_token_from_auth(&config.codex_home).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ async fn run_command_under_sandbox(
|
|||||||
codex_linux_sandbox_exe,
|
codex_linux_sandbox_exe,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// In practice, this should be `std::env::current_dir()` because this CLI
|
// In practice, this should be `std::env::current_dir()` because this CLI
|
||||||
// does not support `--cwd`, but let's use the config value for consistency.
|
// does not support `--cwd`, but let's use the config value for consistency.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! {
|
pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! {
|
||||||
let config = load_config_or_exit(cli_config_overrides);
|
let config = load_config_or_exit(cli_config_overrides).await;
|
||||||
|
|
||||||
match login_with_chatgpt(config.codex_home).await {
|
match login_with_chatgpt(config.codex_home).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -44,7 +44,7 @@ pub async fn run_login_with_api_key(
|
|||||||
cli_config_overrides: CliConfigOverrides,
|
cli_config_overrides: CliConfigOverrides,
|
||||||
api_key: String,
|
api_key: String,
|
||||||
) -> ! {
|
) -> ! {
|
||||||
let config = load_config_or_exit(cli_config_overrides);
|
let config = load_config_or_exit(cli_config_overrides).await;
|
||||||
|
|
||||||
match login_with_api_key(&config.codex_home, &api_key) {
|
match login_with_api_key(&config.codex_home, &api_key) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -91,7 +91,7 @@ pub async fn run_login_with_device_code(
|
|||||||
issuer_base_url: Option<String>,
|
issuer_base_url: Option<String>,
|
||||||
client_id: Option<String>,
|
client_id: Option<String>,
|
||||||
) -> ! {
|
) -> ! {
|
||||||
let config = load_config_or_exit(cli_config_overrides);
|
let config = load_config_or_exit(cli_config_overrides).await;
|
||||||
let mut opts = ServerOptions::new(
|
let mut opts = ServerOptions::new(
|
||||||
config.codex_home,
|
config.codex_home,
|
||||||
client_id.unwrap_or(CLIENT_ID.to_string()),
|
client_id.unwrap_or(CLIENT_ID.to_string()),
|
||||||
@@ -112,7 +112,7 @@ pub async fn run_login_with_device_code(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
|
pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
|
||||||
let config = load_config_or_exit(cli_config_overrides);
|
let config = load_config_or_exit(cli_config_overrides).await;
|
||||||
|
|
||||||
match CodexAuth::from_codex_home(&config.codex_home) {
|
match CodexAuth::from_codex_home(&config.codex_home) {
|
||||||
Ok(Some(auth)) => match auth.mode {
|
Ok(Some(auth)) => match auth.mode {
|
||||||
@@ -143,7 +143,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! {
|
pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! {
|
||||||
let config = load_config_or_exit(cli_config_overrides);
|
let config = load_config_or_exit(cli_config_overrides).await;
|
||||||
|
|
||||||
match logout(&config.codex_home) {
|
match logout(&config.codex_home) {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
@@ -161,7 +161,7 @@ pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config {
|
async fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config {
|
||||||
let cli_overrides = match cli_config_overrides.parse_overrides() {
|
let cli_overrides = match cli_config_overrides.parse_overrides() {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -171,7 +171,7 @@ fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let config_overrides = ConfigOverrides::default();
|
let config_overrides = ConfigOverrides::default();
|
||||||
match Config::load_with_cli_overrides(cli_overrides, config_overrides) {
|
match Config::load_with_cli_overrides(cli_overrides, config_overrides).await {
|
||||||
Ok(config) => config,
|
Ok(config) => config,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Error loading configuration: {e}");
|
eprintln!("Error loading configuration: {e}");
|
||||||
|
|||||||
@@ -113,22 +113,22 @@ impl McpCli {
|
|||||||
|
|
||||||
match subcommand {
|
match subcommand {
|
||||||
McpSubcommand::List(args) => {
|
McpSubcommand::List(args) => {
|
||||||
run_list(&config_overrides, args)?;
|
run_list(&config_overrides, args).await?;
|
||||||
}
|
}
|
||||||
McpSubcommand::Get(args) => {
|
McpSubcommand::Get(args) => {
|
||||||
run_get(&config_overrides, args)?;
|
run_get(&config_overrides, args).await?;
|
||||||
}
|
}
|
||||||
McpSubcommand::Add(args) => {
|
McpSubcommand::Add(args) => {
|
||||||
run_add(&config_overrides, args)?;
|
run_add(&config_overrides, args).await?;
|
||||||
}
|
}
|
||||||
McpSubcommand::Remove(args) => {
|
McpSubcommand::Remove(args) => {
|
||||||
run_remove(&config_overrides, args)?;
|
run_remove(&config_overrides, args).await?;
|
||||||
}
|
}
|
||||||
McpSubcommand::Login(args) => {
|
McpSubcommand::Login(args) => {
|
||||||
run_login(&config_overrides, args).await?;
|
run_login(&config_overrides, args).await?;
|
||||||
}
|
}
|
||||||
McpSubcommand::Logout(args) => {
|
McpSubcommand::Logout(args) => {
|
||||||
run_logout(&config_overrides, args)?;
|
run_logout(&config_overrides, args).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ impl McpCli {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> {
|
async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> {
|
||||||
// Validate any provided overrides even though they are not currently applied.
|
// Validate any provided overrides even though they are not currently applied.
|
||||||
config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
||||||
|
|
||||||
@@ -162,6 +162,7 @@ fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<(
|
|||||||
|
|
||||||
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
||||||
let mut servers = load_global_mcp_servers(&codex_home)
|
let mut servers = load_global_mcp_servers(&codex_home)
|
||||||
|
.await
|
||||||
.with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;
|
.with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;
|
||||||
|
|
||||||
let new_entry = McpServerConfig {
|
let new_entry = McpServerConfig {
|
||||||
@@ -184,7 +185,7 @@ fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> {
|
async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> {
|
||||||
config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
||||||
|
|
||||||
let RemoveArgs { name } = remove_args;
|
let RemoveArgs { name } = remove_args;
|
||||||
@@ -193,6 +194,7 @@ fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) ->
|
|||||||
|
|
||||||
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
||||||
let mut servers = load_global_mcp_servers(&codex_home)
|
let mut servers = load_global_mcp_servers(&codex_home)
|
||||||
|
.await
|
||||||
.with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;
|
.with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;
|
||||||
|
|
||||||
let removed = servers.remove(&name).is_some();
|
let removed = servers.remove(&name).is_some();
|
||||||
@@ -214,6 +216,7 @@ fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) ->
|
|||||||
async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) -> Result<()> {
|
async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) -> Result<()> {
|
||||||
let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
||||||
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
|
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
|
||||||
|
.await
|
||||||
.context("failed to load configuration")?;
|
.context("failed to load configuration")?;
|
||||||
|
|
||||||
if !config.use_experimental_use_rmcp_client {
|
if !config.use_experimental_use_rmcp_client {
|
||||||
@@ -238,9 +241,10 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutArgs) -> Result<()> {
|
async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutArgs) -> Result<()> {
|
||||||
let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
||||||
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
|
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
|
||||||
|
.await
|
||||||
.context("failed to load configuration")?;
|
.context("failed to load configuration")?;
|
||||||
|
|
||||||
let LogoutArgs { name } = logout_args;
|
let LogoutArgs { name } = logout_args;
|
||||||
@@ -264,9 +268,10 @@ fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutArgs) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Result<()> {
|
async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Result<()> {
|
||||||
let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
||||||
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
|
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
|
||||||
|
.await
|
||||||
.context("failed to load configuration")?;
|
.context("failed to load configuration")?;
|
||||||
|
|
||||||
let mut entries: Vec<_> = config.mcp_servers.iter().collect();
|
let mut entries: Vec<_> = config.mcp_servers.iter().collect();
|
||||||
@@ -424,9 +429,10 @@ fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Resul
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<()> {
|
async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<()> {
|
||||||
let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
|
||||||
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
|
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
|
||||||
|
.await
|
||||||
.context("failed to load configuration")?;
|
.context("failed to load configuration")?;
|
||||||
|
|
||||||
let Some(server) = config.mcp_servers.get(&get_args.name) else {
|
let Some(server) = config.mcp_servers.get(&get_args.name) else {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
|
|||||||
Ok(cmd)
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn add_and_remove_server_updates_global_config() -> Result<()> {
|
async fn add_and_remove_server_updates_global_config() -> Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
|
|
||||||
let mut add_cmd = codex_command(codex_home.path())?;
|
let mut add_cmd = codex_command(codex_home.path())?;
|
||||||
@@ -24,7 +24,7 @@ fn add_and_remove_server_updates_global_config() -> Result<()> {
|
|||||||
.success()
|
.success()
|
||||||
.stdout(contains("Added global MCP server 'docs'."));
|
.stdout(contains("Added global MCP server 'docs'."));
|
||||||
|
|
||||||
let servers = load_global_mcp_servers(codex_home.path())?;
|
let servers = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
assert_eq!(servers.len(), 1);
|
assert_eq!(servers.len(), 1);
|
||||||
let docs = servers.get("docs").expect("server should exist");
|
let docs = servers.get("docs").expect("server should exist");
|
||||||
match &docs.transport {
|
match &docs.transport {
|
||||||
@@ -43,7 +43,7 @@ fn add_and_remove_server_updates_global_config() -> Result<()> {
|
|||||||
.success()
|
.success()
|
||||||
.stdout(contains("Removed global MCP server 'docs'."));
|
.stdout(contains("Removed global MCP server 'docs'."));
|
||||||
|
|
||||||
let servers = load_global_mcp_servers(codex_home.path())?;
|
let servers = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
assert!(servers.is_empty());
|
assert!(servers.is_empty());
|
||||||
|
|
||||||
let mut remove_again_cmd = codex_command(codex_home.path())?;
|
let mut remove_again_cmd = codex_command(codex_home.path())?;
|
||||||
@@ -53,14 +53,14 @@ fn add_and_remove_server_updates_global_config() -> Result<()> {
|
|||||||
.success()
|
.success()
|
||||||
.stdout(contains("No MCP server named 'docs' found."));
|
.stdout(contains("No MCP server named 'docs' found."));
|
||||||
|
|
||||||
let servers = load_global_mcp_servers(codex_home.path())?;
|
let servers = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
assert!(servers.is_empty());
|
assert!(servers.is_empty());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn add_with_env_preserves_key_order_and_values() -> Result<()> {
|
async fn add_with_env_preserves_key_order_and_values() -> Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
|
|
||||||
let mut add_cmd = codex_command(codex_home.path())?;
|
let mut add_cmd = codex_command(codex_home.path())?;
|
||||||
@@ -80,7 +80,7 @@ fn add_with_env_preserves_key_order_and_values() -> Result<()> {
|
|||||||
.assert()
|
.assert()
|
||||||
.success();
|
.success();
|
||||||
|
|
||||||
let servers = load_global_mcp_servers(codex_home.path())?;
|
let servers = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
let envy = servers.get("envy").expect("server should exist");
|
let envy = servers.get("envy").expect("server should exist");
|
||||||
let env = match &envy.transport {
|
let env = match &envy.transport {
|
||||||
McpServerTransportConfig::Stdio { env: Some(env), .. } => env,
|
McpServerTransportConfig::Stdio { env: Some(env), .. } => env,
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ async-trait = { workspace = true }
|
|||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
chrono = { workspace = true, features = ["serde"] }
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
|
codex-app-server-protocol = { workspace = true }
|
||||||
codex-apply-patch = { workspace = true }
|
codex-apply-patch = { workspace = true }
|
||||||
codex-file-search = { workspace = true }
|
codex-file-search = { workspace = true }
|
||||||
codex-mcp-client = { workspace = true }
|
codex-mcp-client = { workspace = true }
|
||||||
codex-rmcp-client = { workspace = true }
|
|
||||||
codex-protocol = { workspace = true }
|
|
||||||
codex-app-server-protocol = { workspace = true }
|
|
||||||
codex-otel = { workspace = true, features = ["otel"] }
|
codex-otel = { workspace = true, features = ["otel"] }
|
||||||
|
codex-protocol = { workspace = true }
|
||||||
|
codex-rmcp-client = { workspace = true }
|
||||||
codex-utils-string = { workspace = true }
|
codex-utils-string = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
dunce = { workspace = true }
|
dunce = { workspace = true }
|
||||||
@@ -76,6 +76,9 @@ wildmatch = { workspace = true }
|
|||||||
landlock = { workspace = true }
|
landlock = { workspace = true }
|
||||||
seccompiler = { workspace = true }
|
seccompiler = { workspace = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
core-foundation = "0.9"
|
||||||
|
|
||||||
# Build OpenSSL from source for musl builds.
|
# Build OpenSSL from source for musl builds.
|
||||||
[target.x86_64-unknown-linux-musl.dependencies]
|
[target.x86_64-unknown-linux-musl.dependencies]
|
||||||
openssl-sys = { workspace = true, features = ["vendored"] }
|
openssl-sys = { workspace = true, features = ["vendored"] }
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
use crate::config_loader::LoadedConfigLayers;
|
||||||
|
pub use crate::config_loader::load_config_as_toml;
|
||||||
|
use crate::config_loader::load_config_layers_with_overrides;
|
||||||
|
use crate::config_loader::merge_toml_values;
|
||||||
use crate::config_profile::ConfigProfile;
|
use crate::config_profile::ConfigProfile;
|
||||||
use crate::config_types::DEFAULT_OTEL_ENVIRONMENT;
|
use crate::config_types::DEFAULT_OTEL_ENVIRONMENT;
|
||||||
use crate::config_types::History;
|
use crate::config_types::History;
|
||||||
@@ -212,50 +216,38 @@ pub struct Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
/// Load configuration with *generic* CLI overrides (`-c key=value`) applied
|
pub async fn load_with_cli_overrides(
|
||||||
/// **in between** the values parsed from `config.toml` and the
|
|
||||||
/// strongly-typed overrides specified via [`ConfigOverrides`].
|
|
||||||
///
|
|
||||||
/// The precedence order is therefore: `config.toml` < `-c` overrides <
|
|
||||||
/// `ConfigOverrides`.
|
|
||||||
pub fn load_with_cli_overrides(
|
|
||||||
cli_overrides: Vec<(String, TomlValue)>,
|
cli_overrides: Vec<(String, TomlValue)>,
|
||||||
overrides: ConfigOverrides,
|
overrides: ConfigOverrides,
|
||||||
) -> std::io::Result<Self> {
|
) -> std::io::Result<Self> {
|
||||||
// Resolve the directory that stores Codex state (e.g. ~/.codex or the
|
|
||||||
// value of $CODEX_HOME) so we can embed it into the resulting
|
|
||||||
// `Config` instance.
|
|
||||||
let codex_home = find_codex_home()?;
|
let codex_home = find_codex_home()?;
|
||||||
|
|
||||||
// Step 1: parse `config.toml` into a generic JSON value.
|
let root_value = load_resolved_config(
|
||||||
let mut root_value = load_config_as_toml(&codex_home)?;
|
&codex_home,
|
||||||
|
cli_overrides,
|
||||||
|
crate::config_loader::LoaderOverrides::default(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Step 2: apply the `-c` overrides.
|
|
||||||
for (path, value) in cli_overrides.into_iter() {
|
|
||||||
apply_toml_override(&mut root_value, &path, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: deserialize into `ConfigToml` so that Serde can enforce the
|
|
||||||
// correct types.
|
|
||||||
let cfg: ConfigToml = root_value.try_into().map_err(|e| {
|
let cfg: ConfigToml = root_value.try_into().map_err(|e| {
|
||||||
tracing::error!("Failed to deserialize overridden config: {e}");
|
tracing::error!("Failed to deserialize overridden config: {e}");
|
||||||
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
|
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Step 4: merge with the strongly-typed overrides.
|
|
||||||
Self::load_from_base_config_with_overrides(cfg, overrides, codex_home)
|
Self::load_from_base_config_with_overrides(cfg, overrides, codex_home)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_config_as_toml_with_cli_overrides(
|
pub async fn load_config_as_toml_with_cli_overrides(
|
||||||
codex_home: &Path,
|
codex_home: &Path,
|
||||||
cli_overrides: Vec<(String, TomlValue)>,
|
cli_overrides: Vec<(String, TomlValue)>,
|
||||||
) -> std::io::Result<ConfigToml> {
|
) -> std::io::Result<ConfigToml> {
|
||||||
let mut root_value = load_config_as_toml(codex_home)?;
|
let root_value = load_resolved_config(
|
||||||
|
codex_home,
|
||||||
for (path, value) in cli_overrides.into_iter() {
|
cli_overrides,
|
||||||
apply_toml_override(&mut root_value, &path, value);
|
crate::config_loader::LoaderOverrides::default(),
|
||||||
}
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let cfg: ConfigToml = root_value.try_into().map_err(|e| {
|
let cfg: ConfigToml = root_value.try_into().map_err(|e| {
|
||||||
tracing::error!("Failed to deserialize overridden config: {e}");
|
tracing::error!("Failed to deserialize overridden config: {e}");
|
||||||
@@ -265,33 +257,40 @@ pub fn load_config_as_toml_with_cli_overrides(
|
|||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read `CODEX_HOME/config.toml` and return it as a generic TOML value. Returns
|
async fn load_resolved_config(
|
||||||
/// an empty TOML table when the file does not exist.
|
codex_home: &Path,
|
||||||
pub fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
|
cli_overrides: Vec<(String, TomlValue)>,
|
||||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
overrides: crate::config_loader::LoaderOverrides,
|
||||||
match std::fs::read_to_string(&config_path) {
|
) -> std::io::Result<TomlValue> {
|
||||||
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
|
let layers = load_config_layers_with_overrides(codex_home, overrides).await?;
|
||||||
Ok(val) => Ok(val),
|
Ok(apply_overlays(layers, cli_overrides))
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to parse config.toml: {e}");
|
|
||||||
Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
||||||
tracing::info!("config.toml not found, using defaults");
|
|
||||||
Ok(TomlValue::Table(Default::default()))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to read config.toml: {e}");
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_global_mcp_servers(
|
fn apply_overlays(
|
||||||
|
layers: LoadedConfigLayers,
|
||||||
|
cli_overrides: Vec<(String, TomlValue)>,
|
||||||
|
) -> TomlValue {
|
||||||
|
let LoadedConfigLayers {
|
||||||
|
mut base,
|
||||||
|
managed_config,
|
||||||
|
managed_preferences,
|
||||||
|
} = layers;
|
||||||
|
|
||||||
|
for (path, value) in cli_overrides.into_iter() {
|
||||||
|
apply_toml_override(&mut base, &path, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
for overlay in [managed_config, managed_preferences].into_iter().flatten() {
|
||||||
|
merge_toml_values(&mut base, &overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_global_mcp_servers(
|
||||||
codex_home: &Path,
|
codex_home: &Path,
|
||||||
) -> std::io::Result<BTreeMap<String, McpServerConfig>> {
|
) -> std::io::Result<BTreeMap<String, McpServerConfig>> {
|
||||||
let root_value = load_config_as_toml(codex_home)?;
|
let root_value = load_config_as_toml(codex_home).await?;
|
||||||
let Some(servers_value) = root_value.get("mcp_servers") else {
|
let Some(servers_value) = root_value.get("mcp_servers") else {
|
||||||
return Ok(BTreeMap::new());
|
return Ok(BTreeMap::new());
|
||||||
};
|
};
|
||||||
@@ -1329,18 +1328,18 @@ exclude_slash_tmp = true
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn load_global_mcp_servers_returns_empty_if_missing() -> anyhow::Result<()> {
|
async fn load_global_mcp_servers_returns_empty_if_missing() -> anyhow::Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
|
|
||||||
let servers = load_global_mcp_servers(codex_home.path())?;
|
let servers = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
assert!(servers.is_empty());
|
assert!(servers.is_empty());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn write_global_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
|
async fn write_global_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
|
|
||||||
let mut servers = BTreeMap::new();
|
let mut servers = BTreeMap::new();
|
||||||
@@ -1359,7 +1358,7 @@ exclude_slash_tmp = true
|
|||||||
|
|
||||||
write_global_mcp_servers(codex_home.path(), &servers)?;
|
write_global_mcp_servers(codex_home.path(), &servers)?;
|
||||||
|
|
||||||
let loaded = load_global_mcp_servers(codex_home.path())?;
|
let loaded = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
assert_eq!(loaded.len(), 1);
|
assert_eq!(loaded.len(), 1);
|
||||||
let docs = loaded.get("docs").expect("docs entry");
|
let docs = loaded.get("docs").expect("docs entry");
|
||||||
match &docs.transport {
|
match &docs.transport {
|
||||||
@@ -1375,14 +1374,47 @@ exclude_slash_tmp = true
|
|||||||
|
|
||||||
let empty = BTreeMap::new();
|
let empty = BTreeMap::new();
|
||||||
write_global_mcp_servers(codex_home.path(), &empty)?;
|
write_global_mcp_servers(codex_home.path(), &empty)?;
|
||||||
let loaded = load_global_mcp_servers(codex_home.path())?;
|
let loaded = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
assert!(loaded.is_empty());
|
assert!(loaded.is_empty());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn load_global_mcp_servers_accepts_legacy_ms_field() -> anyhow::Result<()> {
|
async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> {
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
let managed_path = codex_home.path().join("managed_config.toml");
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
codex_home.path().join(CONFIG_TOML_FILE),
|
||||||
|
"model = \"base\"\n",
|
||||||
|
)?;
|
||||||
|
std::fs::write(&managed_path, "model = \"managed_config\"\n")?;
|
||||||
|
|
||||||
|
let overrides = crate::config_loader::LoaderOverrides {
|
||||||
|
managed_config_path: Some(managed_path),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
managed_preferences_base64: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let root_value = load_resolved_config(
|
||||||
|
codex_home.path(),
|
||||||
|
vec![("model".to_string(), TomlValue::String("cli".to_string()))],
|
||||||
|
overrides,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let cfg: ConfigToml = root_value.try_into().map_err(|e| {
|
||||||
|
tracing::error!("Failed to deserialize overridden config: {e}");
|
||||||
|
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
assert_eq!(cfg.model.as_deref(), Some("managed_config"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_global_mcp_servers_accepts_legacy_ms_field() -> anyhow::Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
|
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
|
||||||
|
|
||||||
@@ -1396,15 +1428,15 @@ startup_timeout_ms = 2500
|
|||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let servers = load_global_mcp_servers(codex_home.path())?;
|
let servers = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
let docs = servers.get("docs").expect("docs entry");
|
let docs = servers.get("docs").expect("docs entry");
|
||||||
assert_eq!(docs.startup_timeout_sec, Some(Duration::from_millis(2500)));
|
assert_eq!(docs.startup_timeout_sec, Some(Duration::from_millis(2500)));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn write_global_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
|
async fn write_global_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
|
|
||||||
let servers = BTreeMap::from([(
|
let servers = BTreeMap::from([(
|
||||||
@@ -1439,7 +1471,7 @@ ZIG_VAR = "3"
|
|||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
|
|
||||||
let loaded = load_global_mcp_servers(codex_home.path())?;
|
let loaded = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
let docs = loaded.get("docs").expect("docs entry");
|
let docs = loaded.get("docs").expect("docs entry");
|
||||||
match &docs.transport {
|
match &docs.transport {
|
||||||
McpServerTransportConfig::Stdio { command, args, env } => {
|
McpServerTransportConfig::Stdio { command, args, env } => {
|
||||||
@@ -1457,8 +1489,8 @@ ZIG_VAR = "3"
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn write_global_mcp_servers_serializes_streamable_http() -> anyhow::Result<()> {
|
async fn write_global_mcp_servers_serializes_streamable_http() -> anyhow::Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
|
|
||||||
let mut servers = BTreeMap::from([(
|
let mut servers = BTreeMap::from([(
|
||||||
@@ -1486,7 +1518,7 @@ startup_timeout_sec = 2.0
|
|||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
|
|
||||||
let loaded = load_global_mcp_servers(codex_home.path())?;
|
let loaded = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
let docs = loaded.get("docs").expect("docs entry");
|
let docs = loaded.get("docs").expect("docs entry");
|
||||||
match &docs.transport {
|
match &docs.transport {
|
||||||
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
|
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
|
||||||
@@ -1518,7 +1550,7 @@ url = "https://example.com/mcp"
|
|||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
|
|
||||||
let loaded = load_global_mcp_servers(codex_home.path())?;
|
let loaded = load_global_mcp_servers(codex_home.path()).await?;
|
||||||
let docs = loaded.get("docs").expect("docs entry");
|
let docs = loaded.get("docs").expect("docs entry");
|
||||||
match &docs.transport {
|
match &docs.transport {
|
||||||
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
|
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
|
||||||
|
|||||||
118
codex-rs/core/src/config_loader/macos.rs
Normal file
118
codex-rs/core/src/config_loader/macos.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
use std::io;
|
||||||
|
use toml::Value as TomlValue;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod native {
|
||||||
|
use super::*;
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::prelude::BASE64_STANDARD;
|
||||||
|
use core_foundation::base::TCFType;
|
||||||
|
use core_foundation::string::CFString;
|
||||||
|
use core_foundation::string::CFStringRef;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
use tokio::task;
|
||||||
|
|
||||||
|
pub(crate) async fn load_managed_admin_config_layer(
|
||||||
|
override_base64: Option<&str>,
|
||||||
|
) -> io::Result<Option<TomlValue>> {
|
||||||
|
if let Some(encoded) = override_base64 {
|
||||||
|
let trimmed = encoded.trim();
|
||||||
|
return if trimmed.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
parse_managed_preferences_base64(trimmed).map(Some)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOAD_ERROR: &str = "Failed to load managed preferences configuration";
|
||||||
|
|
||||||
|
match task::spawn_blocking(load_managed_admin_config).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(join_err) => {
|
||||||
|
if join_err.is_cancelled() {
|
||||||
|
tracing::error!("Managed preferences load task was cancelled");
|
||||||
|
} else {
|
||||||
|
tracing::error!("Managed preferences load task failed: {join_err}");
|
||||||
|
}
|
||||||
|
Err(io::Error::other(LOAD_ERROR))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn load_managed_admin_config() -> io::Result<Option<TomlValue>> {
|
||||||
|
#[link(name = "CoreFoundation", kind = "framework")]
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn CFPreferencesCopyAppValue(
|
||||||
|
key: CFStringRef,
|
||||||
|
application_id: CFStringRef,
|
||||||
|
) -> *mut c_void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex";
|
||||||
|
const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64";
|
||||||
|
|
||||||
|
let application_id = CFString::new(MANAGED_PREFERENCES_APPLICATION_ID);
|
||||||
|
let key = CFString::new(MANAGED_PREFERENCES_CONFIG_KEY);
|
||||||
|
|
||||||
|
let value_ref = unsafe {
|
||||||
|
CFPreferencesCopyAppValue(
|
||||||
|
key.as_concrete_TypeRef(),
|
||||||
|
application_id.as_concrete_TypeRef(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if value_ref.is_null() {
|
||||||
|
tracing::debug!(
|
||||||
|
"Managed preferences for {} key {} not found",
|
||||||
|
MANAGED_PREFERENCES_APPLICATION_ID,
|
||||||
|
MANAGED_PREFERENCES_CONFIG_KEY
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) };
|
||||||
|
let contents = value.to_string();
|
||||||
|
let trimmed = contents.trim();
|
||||||
|
|
||||||
|
parse_managed_preferences_base64(trimmed).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse_managed_preferences_base64(encoded: &str) -> io::Result<TomlValue> {
|
||||||
|
let decoded = BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| {
|
||||||
|
tracing::error!("Failed to decode managed preferences as base64: {err}");
|
||||||
|
io::Error::new(io::ErrorKind::InvalidData, err)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let decoded_str = String::from_utf8(decoded).map_err(|err| {
|
||||||
|
tracing::error!("Managed preferences base64 contents were not valid UTF-8: {err}");
|
||||||
|
io::Error::new(io::ErrorKind::InvalidData, err)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match toml::from_str::<TomlValue>(&decoded_str) {
|
||||||
|
Ok(TomlValue::Table(parsed)) => Ok(TomlValue::Table(parsed)),
|
||||||
|
Ok(other) => {
|
||||||
|
tracing::error!(
|
||||||
|
"Managed preferences TOML must have a table at the root, found {other:?}",
|
||||||
|
);
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"managed preferences root must be a table",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("Failed to parse managed preferences TOML: {err}");
|
||||||
|
Err(io::Error::new(io::ErrorKind::InvalidData, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub(crate) use native::load_managed_admin_config_layer;
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
pub(crate) async fn load_managed_admin_config_layer(
|
||||||
|
_override_base64: Option<&str>,
|
||||||
|
) -> io::Result<Option<TomlValue>> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
311
codex-rs/core/src/config_loader/mod.rs
Normal file
311
codex-rs/core/src/config_loader/mod.rs
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
mod macos;
|
||||||
|
|
||||||
|
use crate::config::CONFIG_TOML_FILE;
|
||||||
|
use macos::load_managed_admin_config_layer;
|
||||||
|
use std::io;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::fs;
|
||||||
|
use toml::Value as TomlValue;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
const CODEX_MANAGED_CONFIG_SYSTEM_PATH: &str = "/etc/codex/managed_config.toml";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct LoadedConfigLayers {
|
||||||
|
pub base: TomlValue,
|
||||||
|
pub managed_config: Option<TomlValue>,
|
||||||
|
pub managed_preferences: Option<TomlValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct LoaderOverrides {
|
||||||
|
pub managed_config_path: Option<PathBuf>,
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub managed_preferences_base64: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration layering pipeline (top overrides bottom):
|
||||||
|
//
|
||||||
|
// +-------------------------+
|
||||||
|
// | Managed preferences (*) |
|
||||||
|
// +-------------------------+
|
||||||
|
// ^
|
||||||
|
// |
|
||||||
|
// +-------------------------+
|
||||||
|
// | managed_config.toml |
|
||||||
|
// +-------------------------+
|
||||||
|
// ^
|
||||||
|
// |
|
||||||
|
// +-------------------------+
|
||||||
|
// | config.toml (base) |
|
||||||
|
// +-------------------------+
|
||||||
|
//
|
||||||
|
// (*) Only available on macOS via managed device profiles.
|
||||||
|
|
||||||
|
pub async fn load_config_as_toml(codex_home: &Path) -> io::Result<TomlValue> {
|
||||||
|
load_config_as_toml_with_overrides(codex_home, LoaderOverrides::default()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_empty_table() -> TomlValue {
|
||||||
|
TomlValue::Table(Default::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load_config_layers_with_overrides(
|
||||||
|
codex_home: &Path,
|
||||||
|
overrides: LoaderOverrides,
|
||||||
|
) -> io::Result<LoadedConfigLayers> {
|
||||||
|
load_config_layers_internal(codex_home, overrides).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_config_as_toml_with_overrides(
|
||||||
|
codex_home: &Path,
|
||||||
|
overrides: LoaderOverrides,
|
||||||
|
) -> io::Result<TomlValue> {
|
||||||
|
let layers = load_config_layers_internal(codex_home, overrides).await?;
|
||||||
|
Ok(apply_managed_layers(layers))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_config_layers_internal(
|
||||||
|
codex_home: &Path,
|
||||||
|
overrides: LoaderOverrides,
|
||||||
|
) -> io::Result<LoadedConfigLayers> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let LoaderOverrides {
|
||||||
|
managed_config_path,
|
||||||
|
managed_preferences_base64,
|
||||||
|
} = overrides;
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let LoaderOverrides {
|
||||||
|
managed_config_path,
|
||||||
|
} = overrides;
|
||||||
|
|
||||||
|
let managed_config_path =
|
||||||
|
managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home));
|
||||||
|
|
||||||
|
let user_config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||||
|
let user_config = read_config_from_path(&user_config_path, true).await?;
|
||||||
|
let managed_config = read_config_from_path(&managed_config_path, false).await?;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let managed_preferences =
|
||||||
|
load_managed_admin_config_layer(managed_preferences_base64.as_deref()).await?;
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let managed_preferences = load_managed_admin_config_layer(None).await?;
|
||||||
|
|
||||||
|
Ok(LoadedConfigLayers {
|
||||||
|
base: user_config.unwrap_or_else(default_empty_table),
|
||||||
|
managed_config,
|
||||||
|
managed_preferences,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_config_from_path(
|
||||||
|
path: &Path,
|
||||||
|
log_missing_as_info: bool,
|
||||||
|
) -> io::Result<Option<TomlValue>> {
|
||||||
|
match fs::read_to_string(path).await {
|
||||||
|
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
|
||||||
|
Ok(value) => Ok(Some(value)),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("Failed to parse {}: {err}", path.display());
|
||||||
|
Err(io::Error::new(io::ErrorKind::InvalidData, err))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||||
|
if log_missing_as_info {
|
||||||
|
tracing::info!("{} not found, using defaults", path.display());
|
||||||
|
} else {
|
||||||
|
tracing::debug!("{} not found", path.display());
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("Failed to read {}: {err}", path.display());
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge config `overlay` into `base`, giving `overlay` precedence.
|
||||||
|
pub(crate) fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) {
|
||||||
|
if let TomlValue::Table(overlay_table) = overlay
|
||||||
|
&& let TomlValue::Table(base_table) = base
|
||||||
|
{
|
||||||
|
for (key, value) in overlay_table {
|
||||||
|
if let Some(existing) = base_table.get_mut(key) {
|
||||||
|
merge_toml_values(existing, value);
|
||||||
|
} else {
|
||||||
|
base_table.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*base = overlay.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn managed_config_default_path(codex_home: &Path) -> PathBuf {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let _ = codex_home;
|
||||||
|
PathBuf::from(CODEX_MANAGED_CONFIG_SYSTEM_PATH)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
codex_home.join("managed_config.toml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_managed_layers(layers: LoadedConfigLayers) -> TomlValue {
|
||||||
|
let LoadedConfigLayers {
|
||||||
|
mut base,
|
||||||
|
managed_config,
|
||||||
|
managed_preferences,
|
||||||
|
} = layers;
|
||||||
|
|
||||||
|
for overlay in [managed_config, managed_preferences].into_iter().flatten() {
|
||||||
|
merge_toml_values(&mut base, &overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn merges_managed_config_layer_on_top() {
|
||||||
|
let tmp = tempdir().expect("tempdir");
|
||||||
|
let managed_path = tmp.path().join("managed_config.toml");
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
tmp.path().join(CONFIG_TOML_FILE),
|
||||||
|
r#"foo = 1
|
||||||
|
|
||||||
|
[nested]
|
||||||
|
value = "base"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("write base");
|
||||||
|
std::fs::write(
|
||||||
|
&managed_path,
|
||||||
|
r#"foo = 2
|
||||||
|
|
||||||
|
[nested]
|
||||||
|
value = "managed_config"
|
||||||
|
extra = true
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("write managed config");
|
||||||
|
|
||||||
|
let overrides = LoaderOverrides {
|
||||||
|
managed_config_path: Some(managed_path),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
managed_preferences_base64: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let loaded = load_config_as_toml_with_overrides(tmp.path(), overrides)
|
||||||
|
.await
|
||||||
|
.expect("load config");
|
||||||
|
let table = loaded.as_table().expect("top-level table expected");
|
||||||
|
|
||||||
|
assert_eq!(table.get("foo"), Some(&TomlValue::Integer(2)));
|
||||||
|
let nested = table
|
||||||
|
.get("nested")
|
||||||
|
.and_then(|v| v.as_table())
|
||||||
|
.expect("nested");
|
||||||
|
assert_eq!(
|
||||||
|
nested.get("value"),
|
||||||
|
Some(&TomlValue::String("managed_config".to_string()))
|
||||||
|
);
|
||||||
|
assert_eq!(nested.get("extra"), Some(&TomlValue::Boolean(true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn returns_empty_when_all_layers_missing() {
|
||||||
|
let tmp = tempdir().expect("tempdir");
|
||||||
|
let managed_path = tmp.path().join("managed_config.toml");
|
||||||
|
let overrides = LoaderOverrides {
|
||||||
|
managed_config_path: Some(managed_path),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
managed_preferences_base64: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let layers = load_config_layers_with_overrides(tmp.path(), overrides)
|
||||||
|
.await
|
||||||
|
.expect("load layers");
|
||||||
|
let base_table = layers.base.as_table().expect("base table expected");
|
||||||
|
assert!(
|
||||||
|
base_table.is_empty(),
|
||||||
|
"expected empty base layer when configs missing"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
layers.managed_config.is_none(),
|
||||||
|
"managed config layer should be absent when file missing"
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
let loaded = load_config_as_toml(tmp.path()).await.expect("load config");
|
||||||
|
let table = loaded.as_table().expect("top-level table expected");
|
||||||
|
assert!(
|
||||||
|
table.is_empty(),
|
||||||
|
"expected empty table when configs missing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn managed_preferences_take_highest_precedence() {
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
let managed_payload = r#"
|
||||||
|
[nested]
|
||||||
|
value = "managed"
|
||||||
|
flag = false
|
||||||
|
"#;
|
||||||
|
let encoded = base64::prelude::BASE64_STANDARD.encode(managed_payload.as_bytes());
|
||||||
|
let tmp = tempdir().expect("tempdir");
|
||||||
|
let managed_path = tmp.path().join("managed_config.toml");
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
tmp.path().join(CONFIG_TOML_FILE),
|
||||||
|
r#"[nested]
|
||||||
|
value = "base"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("write base");
|
||||||
|
std::fs::write(
|
||||||
|
&managed_path,
|
||||||
|
r#"[nested]
|
||||||
|
value = "managed_config"
|
||||||
|
flag = true
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("write managed config");
|
||||||
|
|
||||||
|
let overrides = LoaderOverrides {
|
||||||
|
managed_config_path: Some(managed_path),
|
||||||
|
managed_preferences_base64: Some(encoded),
|
||||||
|
};
|
||||||
|
|
||||||
|
let loaded = load_config_as_toml_with_overrides(tmp.path(), overrides)
|
||||||
|
.await
|
||||||
|
.expect("load config");
|
||||||
|
let nested = loaded
|
||||||
|
.get("nested")
|
||||||
|
.and_then(|v| v.as_table())
|
||||||
|
.expect("nested table");
|
||||||
|
assert_eq!(
|
||||||
|
nested.get("value"),
|
||||||
|
Some(&TomlValue::String("managed".to_string()))
|
||||||
|
);
|
||||||
|
assert_eq!(nested.get("flag"), Some(&TomlValue::Boolean(false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ pub use codex_conversation::CodexConversation;
|
|||||||
mod command_safety;
|
mod command_safety;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod config_edit;
|
pub mod config_edit;
|
||||||
|
pub mod config_loader;
|
||||||
pub mod config_profile;
|
pub mod config_profile;
|
||||||
pub mod config_types;
|
pub mod config_types;
|
||||||
mod conversation_history;
|
mod conversation_history;
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?;
|
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?;
|
||||||
|
|
||||||
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"));
|
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool {
|
|||||||
impl CodexToolCallParam {
|
impl CodexToolCallParam {
|
||||||
/// Returns the initial user prompt to start the Codex conversation and the
|
/// Returns the initial user prompt to start the Codex conversation and the
|
||||||
/// effective Config object generated from the supplied parameters.
|
/// effective Config object generated from the supplied parameters.
|
||||||
pub fn into_config(
|
pub async fn into_config(
|
||||||
self,
|
self,
|
||||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||||
) -> std::io::Result<(String, codex_core::config::Config)> {
|
) -> std::io::Result<(String, codex_core::config::Config)> {
|
||||||
@@ -172,7 +172,8 @@ impl CodexToolCallParam {
|
|||||||
.map(|(k, v)| (k, json_to_toml(v)))
|
.map(|(k, v)| (k, json_to_toml(v)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let cfg = codex_core::config::Config::load_with_cli_overrides(cli_overrides, overrides)?;
|
let cfg =
|
||||||
|
codex_core::config::Config::load_with_cli_overrides(cli_overrides, overrides).await?;
|
||||||
|
|
||||||
Ok((prompt, cfg))
|
Ok((prompt, cfg))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ pub async fn run_main(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let config = Config::load_with_cli_overrides(cli_kv_overrides, ConfigOverrides::default())
|
let config = Config::load_with_cli_overrides(cli_kv_overrides, ConfigOverrides::default())
|
||||||
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
|
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -342,7 +342,10 @@ impl MessageProcessor {
|
|||||||
async fn handle_tool_call_codex(&self, id: RequestId, arguments: Option<serde_json::Value>) {
|
async fn handle_tool_call_codex(&self, id: RequestId, arguments: Option<serde_json::Value>) {
|
||||||
let (initial_prompt, config): (String, Config) = match arguments {
|
let (initial_prompt, config): (String, Config) = match arguments {
|
||||||
Some(json_val) => match serde_json::from_value::<CodexToolCallParam>(json_val) {
|
Some(json_val) => match serde_json::from_value::<CodexToolCallParam>(json_val) {
|
||||||
Ok(tool_cfg) => match tool_cfg.into_config(self.codex_linux_sandbox_exe.clone()) {
|
Ok(tool_cfg) => match tool_cfg
|
||||||
|
.into_config(self.codex_linux_sandbox_exe.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(cfg) => cfg,
|
Ok(cfg) => cfg,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let result = CallToolResult {
|
let result = CallToolResult {
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ fn exited_review_mode_emits_results_and_finishes() {
|
|||||||
target_os = "macos",
|
target_os = "macos",
|
||||||
ignore = "system configuration APIs are blocked under macOS seatbelt"
|
ignore = "system configuration APIs are blocked under macOS seatbelt"
|
||||||
)]
|
)]
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test]
|
||||||
async fn helpers_are_available_and_do_not_panic() {
|
async fn helpers_are_available_and_do_not_panic() {
|
||||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||||
let tx = AppEventSender::new(tx_raw);
|
let tx = AppEventSender::new(tx_raw);
|
||||||
@@ -911,7 +911,7 @@ fn review_custom_prompt_escape_navigates_back_then_dismisses() {
|
|||||||
|
|
||||||
/// Opening base-branch picker from the review popup, pressing Esc returns to the
|
/// Opening base-branch picker from the review popup, pressing Esc returns to the
|
||||||
/// parent popup, pressing Esc again dismisses all panels (back to normal mode).
|
/// parent popup, pressing Esc again dismisses all panels (back to normal mode).
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test]
|
||||||
async fn review_branch_picker_escape_navigates_back_then_dismisses() {
|
async fn review_branch_picker_escape_navigates_back_then_dismisses() {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||||
|
|
||||||
@@ -1099,7 +1099,7 @@ fn disabled_slash_command_while_task_running_snapshot() {
|
|||||||
assert_snapshot!(blob);
|
assert_snapshot!(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test]
|
||||||
async fn binary_size_transcript_snapshot() {
|
async fn binary_size_transcript_snapshot() {
|
||||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ pub async fn run_main(
|
|||||||
// Load configuration and support CLI overrides.
|
// Load configuration and support CLI overrides.
|
||||||
|
|
||||||
#[allow(clippy::print_stderr)]
|
#[allow(clippy::print_stderr)]
|
||||||
match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides) {
|
match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides).await {
|
||||||
Ok(config) => config,
|
Ok(config) => config,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("Error loading configuration: {err}");
|
eprintln!("Error loading configuration: {err}");
|
||||||
@@ -182,7 +182,7 @@ pub async fn run_main(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides) {
|
match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides).await {
|
||||||
Ok(config_toml) => config_toml,
|
Ok(config_toml) => config_toml,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("Error loading config.toml: {err}");
|
eprintln!("Error loading config.toml: {err}");
|
||||||
|
|||||||
@@ -124,20 +124,19 @@ mod tests {
|
|||||||
use codex_core::config::ConfigOverrides;
|
use codex_core::config::ConfigOverrides;
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
|
|
||||||
fn test_config() -> Config {
|
async fn test_config() -> Config {
|
||||||
let overrides = ConfigOverrides {
|
let overrides = ConfigOverrides {
|
||||||
cwd: std::env::current_dir().ok(),
|
cwd: std::env::current_dir().ok(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
match Config::load_with_cli_overrides(vec![], overrides) {
|
Config::load_with_cli_overrides(vec![], overrides)
|
||||||
Ok(c) => c,
|
.await
|
||||||
Err(e) => panic!("load test config: {e}"),
|
.expect("load test config")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn no_commit_until_newline() {
|
async fn no_commit_until_newline() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
let mut c = super::MarkdownStreamCollector::new(None);
|
let mut c = super::MarkdownStreamCollector::new(None);
|
||||||
c.push_delta("Hello, world");
|
c.push_delta("Hello, world");
|
||||||
let out = c.commit_complete_lines(&cfg);
|
let out = c.commit_complete_lines(&cfg);
|
||||||
@@ -147,18 +146,18 @@ mod tests {
|
|||||||
assert_eq!(out2.len(), 1, "one completed line after newline");
|
assert_eq!(out2.len(), 1, "one completed line after newline");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn finalize_commits_partial_line() {
|
async fn finalize_commits_partial_line() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
let mut c = super::MarkdownStreamCollector::new(None);
|
let mut c = super::MarkdownStreamCollector::new(None);
|
||||||
c.push_delta("Line without newline");
|
c.push_delta("Line without newline");
|
||||||
let out = c.finalize_and_drain(&cfg);
|
let out = c.finalize_and_drain(&cfg);
|
||||||
assert_eq!(out.len(), 1);
|
assert_eq!(out.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn e2e_stream_blockquote_simple_is_green() {
|
async fn e2e_stream_blockquote_simple_is_green() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
let out = super::simulate_stream_markdown_for_tests(&["> Hello\n"], true, &cfg);
|
let out = super::simulate_stream_markdown_for_tests(&["> Hello\n"], true, &cfg);
|
||||||
assert_eq!(out.len(), 1);
|
assert_eq!(out.len(), 1);
|
||||||
let l = &out[0];
|
let l = &out[0];
|
||||||
@@ -170,9 +169,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn e2e_stream_blockquote_nested_is_green() {
|
async fn e2e_stream_blockquote_nested_is_green() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
let out =
|
let out =
|
||||||
super::simulate_stream_markdown_for_tests(&["> Level 1\n>> Level 2\n"], true, &cfg);
|
super::simulate_stream_markdown_for_tests(&["> Level 1\n>> Level 2\n"], true, &cfg);
|
||||||
// Filter out any blank lines that may be inserted at paragraph starts.
|
// Filter out any blank lines that may be inserted at paragraph starts.
|
||||||
@@ -195,9 +194,9 @@ mod tests {
|
|||||||
assert_eq!(non_blank[1].style.fg, Some(Color::Green));
|
assert_eq!(non_blank[1].style.fg, Some(Color::Green));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn e2e_stream_blockquote_with_list_items_is_green() {
|
async fn e2e_stream_blockquote_with_list_items_is_green() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
let out =
|
let out =
|
||||||
super::simulate_stream_markdown_for_tests(&["> - item 1\n> - item 2\n"], true, &cfg);
|
super::simulate_stream_markdown_for_tests(&["> - item 1\n> - item 2\n"], true, &cfg);
|
||||||
assert_eq!(out.len(), 2);
|
assert_eq!(out.len(), 2);
|
||||||
@@ -205,9 +204,9 @@ mod tests {
|
|||||||
assert_eq!(out[1].style.fg, Some(Color::Green));
|
assert_eq!(out[1].style.fg, Some(Color::Green));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn e2e_stream_nested_mixed_lists_ordered_marker_is_light_blue() {
|
async fn e2e_stream_nested_mixed_lists_ordered_marker_is_light_blue() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
let md = [
|
let md = [
|
||||||
"1. First\n",
|
"1. First\n",
|
||||||
" - Second level\n",
|
" - Second level\n",
|
||||||
@@ -237,9 +236,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn e2e_stream_blockquote_wrap_preserves_green_style() {
|
async fn e2e_stream_blockquote_wrap_preserves_green_style() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
let long = "> This is a very long quoted line that should wrap across multiple columns to verify style preservation.";
|
let long = "> This is a very long quoted line that should wrap across multiple columns to verify style preservation.";
|
||||||
let out = super::simulate_stream_markdown_for_tests(&[long, "\n"], true, &cfg);
|
let out = super::simulate_stream_markdown_for_tests(&[long, "\n"], true, &cfg);
|
||||||
// Wrap to a narrow width to force multiple output lines.
|
// Wrap to a narrow width to force multiple output lines.
|
||||||
@@ -273,9 +272,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn heading_starts_on_new_line_when_following_paragraph() {
|
async fn heading_starts_on_new_line_when_following_paragraph() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
|
|
||||||
// Stream a paragraph line, then a heading on the next line.
|
// Stream a paragraph line, then a heading on the next line.
|
||||||
// Expect two distinct rendered lines: "Hello." and "Heading".
|
// Expect two distinct rendered lines: "Hello." and "Heading".
|
||||||
@@ -330,9 +329,9 @@ mod tests {
|
|||||||
assert_eq!(line_to_string(&out2[1]), "## Heading");
|
assert_eq!(line_to_string(&out2[1]), "## Heading");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn heading_not_inlined_when_split_across_chunks() {
|
async fn heading_not_inlined_when_split_across_chunks() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
|
|
||||||
// Paragraph without trailing newline, then a chunk that starts with the newline
|
// Paragraph without trailing newline, then a chunk that starts with the newline
|
||||||
// and the heading text, then a final newline. The collector should first commit
|
// and the heading text, then a final newline. The collector should first commit
|
||||||
@@ -413,18 +412,18 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn lists_and_fences_commit_without_duplication() {
|
async fn lists_and_fences_commit_without_duplication() {
|
||||||
// List case
|
// List case
|
||||||
assert_streamed_equals_full(&["- a\n- ", "b\n- c\n"]);
|
assert_streamed_equals_full(&["- a\n- ", "b\n- c\n"]).await;
|
||||||
|
|
||||||
// Fenced code case: stream in small chunks
|
// Fenced code case: stream in small chunks
|
||||||
assert_streamed_equals_full(&["```", "\nco", "de 1\ncode 2\n", "```\n"]);
|
assert_streamed_equals_full(&["```", "\nco", "de 1\ncode 2\n", "```\n"]).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn utf8_boundary_safety_and_wide_chars() {
|
async fn utf8_boundary_safety_and_wide_chars() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
|
|
||||||
// Emoji (wide), CJK, control char, digit + combining macron sequences
|
// Emoji (wide), CJK, control char, digit + combining macron sequences
|
||||||
let input = "🙂🙂🙂\n汉字漢字\nA\u{0003}0\u{0304}\n";
|
let input = "🙂🙂🙂\n汉字漢字\nA\u{0003}0\u{0304}\n";
|
||||||
@@ -453,9 +452,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn e2e_stream_deep_nested_third_level_marker_is_light_blue() {
|
async fn e2e_stream_deep_nested_third_level_marker_is_light_blue() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
let md = "1. First\n - Second level\n 1. Third level (ordered)\n - Fourth level (bullet)\n - Fifth level to test indent consistency\n";
|
let md = "1. First\n - Second level\n 1. Third level (ordered)\n - Fourth level (bullet)\n - Fifth level to test indent consistency\n";
|
||||||
let streamed = super::simulate_stream_markdown_for_tests(&[md], true, &cfg);
|
let streamed = super::simulate_stream_markdown_for_tests(&[md], true, &cfg);
|
||||||
let streamed_strs = lines_to_plain_strings(&streamed);
|
let streamed_strs = lines_to_plain_strings(&streamed);
|
||||||
@@ -503,9 +502,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn empty_fenced_block_is_dropped_and_separator_preserved_before_heading() {
|
async fn empty_fenced_block_is_dropped_and_separator_preserved_before_heading() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
// An empty fenced code block followed by a heading should not render the fence,
|
// An empty fenced code block followed by a heading should not render the fence,
|
||||||
// but should preserve a blank separator line so the heading starts on a new line.
|
// but should preserve a blank separator line so the heading starts on a new line.
|
||||||
let deltas = vec!["```bash\n```\n", "## Heading\n"]; // empty block and close in same commit
|
let deltas = vec!["```bash\n```\n", "## Heading\n"]; // empty block and close in same commit
|
||||||
@@ -522,9 +521,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn paragraph_then_empty_fence_then_heading_keeps_heading_on_new_line() {
|
async fn paragraph_then_empty_fence_then_heading_keeps_heading_on_new_line() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
let deltas = vec!["Para.\n", "```\n```\n", "## Title\n"]; // empty fence block in one commit
|
let deltas = vec!["Para.\n", "```\n```\n", "## Title\n"]; // empty fence block in one commit
|
||||||
let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg);
|
let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg);
|
||||||
let texts = lines_to_plain_strings(&streamed);
|
let texts = lines_to_plain_strings(&streamed);
|
||||||
@@ -542,9 +541,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn loose_list_with_split_dashes_matches_full_render() {
|
async fn loose_list_with_split_dashes_matches_full_render() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
// Minimized failing sequence discovered by the helper: two chunks
|
// Minimized failing sequence discovered by the helper: two chunks
|
||||||
// that still reproduce the mismatch.
|
// that still reproduce the mismatch.
|
||||||
let deltas = vec!["- item.\n\n", "-"];
|
let deltas = vec!["- item.\n\n", "-"];
|
||||||
@@ -563,9 +562,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn loose_vs_tight_list_items_streaming_matches_full() {
|
async fn loose_vs_tight_list_items_streaming_matches_full() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
// Deltas extracted from the session log around 2025-08-27T00:33:18.216Z
|
// Deltas extracted from the session log around 2025-08-27T00:33:18.216Z
|
||||||
let deltas = vec![
|
let deltas = vec![
|
||||||
"\n\n",
|
"\n\n",
|
||||||
@@ -665,8 +664,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Targeted tests derived from fuzz findings. Each asserts streamed == full render.
|
// Targeted tests derived from fuzz findings. Each asserts streamed == full render.
|
||||||
fn assert_streamed_equals_full(deltas: &[&str]) {
|
async fn assert_streamed_equals_full(deltas: &[&str]) {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
let streamed = simulate_stream_markdown_for_tests(deltas, true, &cfg);
|
let streamed = simulate_stream_markdown_for_tests(deltas, true, &cfg);
|
||||||
let streamed_strs = lines_to_plain_strings(&streamed);
|
let streamed_strs = lines_to_plain_strings(&streamed);
|
||||||
let full: String = deltas.iter().copied().collect();
|
let full: String = deltas.iter().copied().collect();
|
||||||
@@ -676,28 +675,31 @@ mod tests {
|
|||||||
assert_eq!(streamed_strs, rendered_strs, "full:\n---\n{full}\n---");
|
assert_eq!(streamed_strs, rendered_strs, "full:\n---\n{full}\n---");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn fuzz_class_bullet_duplication_variant_1() {
|
async fn fuzz_class_bullet_duplication_variant_1() {
|
||||||
assert_streamed_equals_full(&[
|
assert_streamed_equals_full(&[
|
||||||
"aph.\n- let one\n- bull",
|
"aph.\n- let one\n- bull",
|
||||||
"et two\n\n second paragraph \n",
|
"et two\n\n second paragraph \n",
|
||||||
]);
|
])
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn fuzz_class_bullet_duplication_variant_2() {
|
async fn fuzz_class_bullet_duplication_variant_2() {
|
||||||
assert_streamed_equals_full(&[
|
assert_streamed_equals_full(&[
|
||||||
"- e\n c",
|
"- e\n c",
|
||||||
"e\n- bullet two\n\n second paragraph in bullet two\n",
|
"e\n- bullet two\n\n second paragraph in bullet two\n",
|
||||||
]);
|
])
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn streaming_html_block_then_text_matches_full() {
|
async fn streaming_html_block_then_text_matches_full() {
|
||||||
assert_streamed_equals_full(&[
|
assert_streamed_equals_full(&[
|
||||||
"HTML block:\n",
|
"HTML block:\n",
|
||||||
"<div>inline block</div>\n",
|
"<div>inline block</div>\n",
|
||||||
"more stuff\n",
|
"more stuff\n",
|
||||||
]);
|
])
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,15 +91,14 @@ mod tests {
|
|||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use codex_core::config::ConfigOverrides;
|
use codex_core::config::ConfigOverrides;
|
||||||
|
|
||||||
fn test_config() -> Config {
|
async fn test_config() -> Config {
|
||||||
let overrides = ConfigOverrides {
|
let overrides = ConfigOverrides {
|
||||||
cwd: std::env::current_dir().ok(),
|
cwd: std::env::current_dir().ok(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
match Config::load_with_cli_overrides(vec![], overrides) {
|
Config::load_with_cli_overrides(vec![], overrides)
|
||||||
Ok(c) => c,
|
.await
|
||||||
Err(e) => panic!("load test config: {e}"),
|
.expect("load test config")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec<String> {
|
fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec<String> {
|
||||||
@@ -115,9 +114,9 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn controller_loose_vs_tight_with_commit_ticks_matches_full() {
|
async fn controller_loose_vs_tight_with_commit_ticks_matches_full() {
|
||||||
let cfg = test_config();
|
let cfg = test_config().await;
|
||||||
let mut ctrl = StreamController::new(cfg.clone(), None);
|
let mut ctrl = StreamController::new(cfg.clone(), None);
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user