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:
@@ -214,7 +214,7 @@ fn exited_review_mode_emits_results_and_finishes() {
|
||||
target_os = "macos",
|
||||
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() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
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
|
||||
/// 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() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
@@ -1099,7 +1099,7 @@ fn disabled_slash_command_while_task_running_snapshot() {
|
||||
assert_snapshot!(blob);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[tokio::test]
|
||||
async fn binary_size_transcript_snapshot() {
|
||||
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.
|
||||
|
||||
#[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,
|
||||
Err(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,
|
||||
Err(err) => {
|
||||
eprintln!("Error loading config.toml: {err}");
|
||||
|
||||
@@ -124,20 +124,19 @@ mod tests {
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use ratatui::style::Color;
|
||||
|
||||
fn test_config() -> Config {
|
||||
async fn test_config() -> Config {
|
||||
let overrides = ConfigOverrides {
|
||||
cwd: std::env::current_dir().ok(),
|
||||
..Default::default()
|
||||
};
|
||||
match Config::load_with_cli_overrides(vec![], overrides) {
|
||||
Ok(c) => c,
|
||||
Err(e) => panic!("load test config: {e}"),
|
||||
}
|
||||
Config::load_with_cli_overrides(vec![], overrides)
|
||||
.await
|
||||
.expect("load test config")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_commit_until_newline() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn no_commit_until_newline() {
|
||||
let cfg = test_config().await;
|
||||
let mut c = super::MarkdownStreamCollector::new(None);
|
||||
c.push_delta("Hello, world");
|
||||
let out = c.commit_complete_lines(&cfg);
|
||||
@@ -147,18 +146,18 @@ mod tests {
|
||||
assert_eq!(out2.len(), 1, "one completed line after newline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalize_commits_partial_line() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn finalize_commits_partial_line() {
|
||||
let cfg = test_config().await;
|
||||
let mut c = super::MarkdownStreamCollector::new(None);
|
||||
c.push_delta("Line without newline");
|
||||
let out = c.finalize_and_drain(&cfg);
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_stream_blockquote_simple_is_green() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn e2e_stream_blockquote_simple_is_green() {
|
||||
let cfg = test_config().await;
|
||||
let out = super::simulate_stream_markdown_for_tests(&["> Hello\n"], true, &cfg);
|
||||
assert_eq!(out.len(), 1);
|
||||
let l = &out[0];
|
||||
@@ -170,9 +169,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_stream_blockquote_nested_is_green() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn e2e_stream_blockquote_nested_is_green() {
|
||||
let cfg = test_config().await;
|
||||
let out =
|
||||
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.
|
||||
@@ -195,9 +194,9 @@ mod tests {
|
||||
assert_eq!(non_blank[1].style.fg, Some(Color::Green));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_stream_blockquote_with_list_items_is_green() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn e2e_stream_blockquote_with_list_items_is_green() {
|
||||
let cfg = test_config().await;
|
||||
let out =
|
||||
super::simulate_stream_markdown_for_tests(&["> - item 1\n> - item 2\n"], true, &cfg);
|
||||
assert_eq!(out.len(), 2);
|
||||
@@ -205,9 +204,9 @@ mod tests {
|
||||
assert_eq!(out[1].style.fg, Some(Color::Green));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_stream_nested_mixed_lists_ordered_marker_is_light_blue() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn e2e_stream_nested_mixed_lists_ordered_marker_is_light_blue() {
|
||||
let cfg = test_config().await;
|
||||
let md = [
|
||||
"1. First\n",
|
||||
" - Second level\n",
|
||||
@@ -237,9 +236,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_stream_blockquote_wrap_preserves_green_style() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn e2e_stream_blockquote_wrap_preserves_green_style() {
|
||||
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 out = super::simulate_stream_markdown_for_tests(&[long, "\n"], true, &cfg);
|
||||
// Wrap to a narrow width to force multiple output lines.
|
||||
@@ -273,9 +272,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_starts_on_new_line_when_following_paragraph() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn heading_starts_on_new_line_when_following_paragraph() {
|
||||
let cfg = test_config().await;
|
||||
|
||||
// Stream a paragraph line, then a heading on the next line.
|
||||
// Expect two distinct rendered lines: "Hello." and "Heading".
|
||||
@@ -330,9 +329,9 @@ mod tests {
|
||||
assert_eq!(line_to_string(&out2[1]), "## Heading");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_not_inlined_when_split_across_chunks() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn heading_not_inlined_when_split_across_chunks() {
|
||||
let cfg = test_config().await;
|
||||
|
||||
// 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
|
||||
@@ -413,18 +412,18 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_and_fences_commit_without_duplication() {
|
||||
#[tokio::test]
|
||||
async fn lists_and_fences_commit_without_duplication() {
|
||||
// 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
|
||||
assert_streamed_equals_full(&["```", "\nco", "de 1\ncode 2\n", "```\n"]);
|
||||
assert_streamed_equals_full(&["```", "\nco", "de 1\ncode 2\n", "```\n"]).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_boundary_safety_and_wide_chars() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn utf8_boundary_safety_and_wide_chars() {
|
||||
let cfg = test_config().await;
|
||||
|
||||
// Emoji (wide), CJK, control char, digit + combining macron sequences
|
||||
let input = "🙂🙂🙂\n汉字漢字\nA\u{0003}0\u{0304}\n";
|
||||
@@ -453,9 +452,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_stream_deep_nested_third_level_marker_is_light_blue() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn e2e_stream_deep_nested_third_level_marker_is_light_blue() {
|
||||
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 streamed = super::simulate_stream_markdown_for_tests(&[md], true, &cfg);
|
||||
let streamed_strs = lines_to_plain_strings(&streamed);
|
||||
@@ -503,9 +502,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_fenced_block_is_dropped_and_separator_preserved_before_heading() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn empty_fenced_block_is_dropped_and_separator_preserved_before_heading() {
|
||||
let cfg = test_config().await;
|
||||
// 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.
|
||||
let deltas = vec!["```bash\n```\n", "## Heading\n"]; // empty block and close in same commit
|
||||
@@ -522,9 +521,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_then_empty_fence_then_heading_keeps_heading_on_new_line() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn paragraph_then_empty_fence_then_heading_keeps_heading_on_new_line() {
|
||||
let cfg = test_config().await;
|
||||
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 texts = lines_to_plain_strings(&streamed);
|
||||
@@ -542,9 +541,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_list_with_split_dashes_matches_full_render() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn loose_list_with_split_dashes_matches_full_render() {
|
||||
let cfg = test_config().await;
|
||||
// Minimized failing sequence discovered by the helper: two chunks
|
||||
// that still reproduce the mismatch.
|
||||
let deltas = vec!["- item.\n\n", "-"];
|
||||
@@ -563,9 +562,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_vs_tight_list_items_streaming_matches_full() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn loose_vs_tight_list_items_streaming_matches_full() {
|
||||
let cfg = test_config().await;
|
||||
// Deltas extracted from the session log around 2025-08-27T00:33:18.216Z
|
||||
let deltas = vec![
|
||||
"\n\n",
|
||||
@@ -665,8 +664,8 @@ mod tests {
|
||||
}
|
||||
|
||||
// Targeted tests derived from fuzz findings. Each asserts streamed == full render.
|
||||
fn assert_streamed_equals_full(deltas: &[&str]) {
|
||||
let cfg = test_config();
|
||||
async fn assert_streamed_equals_full(deltas: &[&str]) {
|
||||
let cfg = test_config().await;
|
||||
let streamed = simulate_stream_markdown_for_tests(deltas, true, &cfg);
|
||||
let streamed_strs = lines_to_plain_strings(&streamed);
|
||||
let full: String = deltas.iter().copied().collect();
|
||||
@@ -676,28 +675,31 @@ mod tests {
|
||||
assert_eq!(streamed_strs, rendered_strs, "full:\n---\n{full}\n---");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzz_class_bullet_duplication_variant_1() {
|
||||
#[tokio::test]
|
||||
async fn fuzz_class_bullet_duplication_variant_1() {
|
||||
assert_streamed_equals_full(&[
|
||||
"aph.\n- let one\n- bull",
|
||||
"et two\n\n second paragraph \n",
|
||||
]);
|
||||
])
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzz_class_bullet_duplication_variant_2() {
|
||||
#[tokio::test]
|
||||
async fn fuzz_class_bullet_duplication_variant_2() {
|
||||
assert_streamed_equals_full(&[
|
||||
"- e\n c",
|
||||
"e\n- bullet two\n\n second paragraph in bullet two\n",
|
||||
]);
|
||||
])
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_html_block_then_text_matches_full() {
|
||||
#[tokio::test]
|
||||
async fn streaming_html_block_then_text_matches_full() {
|
||||
assert_streamed_equals_full(&[
|
||||
"HTML block:\n",
|
||||
"<div>inline block</div>\n",
|
||||
"more stuff\n",
|
||||
]);
|
||||
])
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,15 +91,14 @@ mod tests {
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
|
||||
fn test_config() -> Config {
|
||||
async fn test_config() -> Config {
|
||||
let overrides = ConfigOverrides {
|
||||
cwd: std::env::current_dir().ok(),
|
||||
..Default::default()
|
||||
};
|
||||
match Config::load_with_cli_overrides(vec![], overrides) {
|
||||
Ok(c) => c,
|
||||
Err(e) => panic!("load test config: {e}"),
|
||||
}
|
||||
Config::load_with_cli_overrides(vec![], overrides)
|
||||
.await
|
||||
.expect("load test config")
|
||||
}
|
||||
|
||||
fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec<String> {
|
||||
@@ -115,9 +114,9 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controller_loose_vs_tight_with_commit_ticks_matches_full() {
|
||||
let cfg = test_config();
|
||||
#[tokio::test]
|
||||
async fn controller_loose_vs_tight_with_commit_ticks_matches_full() {
|
||||
let cfg = test_config().await;
|
||||
let mut ctrl = StreamController::new(cfg.clone(), None);
|
||||
let mut lines = Vec::new();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user