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:
Fouad Matin
2025-10-03 13:02:26 -07:00
committed by GitHub
parent 9823de3cc6
commit a5b7675e42
21 changed files with 676 additions and 194 deletions

View File

@@ -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();

View File

@@ -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}");

View File

@@ -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;
}
}

View File

@@ -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();