Add layered config.toml support to app server (#9510)

This PR adds support for chained (layered) config.toml file merging for
clients that use the app server interface. This feature already exists
for the TUI, but it does not work for GUI clients.

It does the following:
* Changes code paths for new thread, resume thread, and fork thread to
use the effective config based on the cwd.
* Updates the `config/read` API to accept an optional `cwd` parameter.
If specified, the API returns the effective config based on that cwd
path. Also optionally includes all layers including project config
files. If cwd is not specified, the API falls back on its older behavior
where it considers only the global (non-project) config files when
computing the effective config.

The changes in codex_message_processor.rs look deceptively large. They
mostly just involve moving existing blocks of code to a later point in
some functions so it can use the cwd to calculate the config.

This PR builds upon #9509 and should be reviewed and merged after that
PR.

Tested:
* Verified change with (dependent, as-yet-uncommitted) changes to IDE
Extension and confirmed correct behavior

The full fix requires additional changes in the IDE Extension code base,
but they depend on this PR.
This commit is contained in:
Eric Traut
2026-01-21 14:21:48 -08:00
committed by GitHub
parent fe641f759f
commit 2ca9a56528
7 changed files with 409 additions and 212 deletions

View File

@@ -155,6 +155,7 @@ use codex_core::protocol::ReviewRequest;
use codex_core::protocol::ReviewTarget as CoreReviewTarget;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::read_head_for_summary;
use codex_core::read_session_meta_line;
use codex_core::sandboxing::SandboxPermissions;
use codex_feedback::CodexFeedback;
use codex_login::ServerOptions as LoginServerOptions;
@@ -998,7 +999,7 @@ impl CodexMessageProcessor {
async fn refresh_token_if_requested(&self, do_refresh: bool) {
if do_refresh && let Err(err) = self.auth_manager.refresh_token().await {
tracing::warn!("failed to refresh token whilte getting account: {err}");
tracing::warn!("failed to refresh token while getting account: {err}");
}
}
@@ -1741,47 +1742,6 @@ impl CodexMessageProcessor {
developer_instructions,
} = params;
let overrides_requested = model.is_some()
|| model_provider.is_some()
|| cwd.is_some()
|| approval_policy.is_some()
|| sandbox.is_some()
|| request_overrides.is_some()
|| base_instructions.is_some()
|| developer_instructions.is_some();
let config = if overrides_requested {
let typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
cwd,
approval_policy,
sandbox,
base_instructions,
developer_instructions,
);
match derive_config_from_params(
&self.cli_overrides,
request_overrides,
typesafe_overrides,
)
.await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
}
} else {
self.config.as_ref().clone()
};
let thread_history = if let Some(history) = history {
if history.is_empty() {
self.send_invalid_request_error(
@@ -1856,6 +1816,38 @@ impl CodexMessageProcessor {
}
};
let history_cwd = thread_history.session_cwd();
let typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
cwd,
approval_policy,
sandbox,
base_instructions,
developer_instructions,
);
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = match derive_config_for_cwd(
&self.cli_overrides,
request_overrides,
typesafe_overrides,
history_cwd,
)
.await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let fallback_model_provider = config.model_provider_id.clone();
match self
@@ -1945,53 +1937,6 @@ impl CodexMessageProcessor {
developer_instructions,
} = params;
let overrides_requested = model.is_some()
|| model_provider.is_some()
|| cwd.is_some()
|| approval_policy.is_some()
|| sandbox.is_some()
|| cli_overrides.is_some()
|| base_instructions.is_some()
|| developer_instructions.is_some();
let config = if overrides_requested {
let overrides = self.build_thread_config_overrides(
model,
model_provider,
cwd,
approval_policy,
sandbox,
base_instructions,
developer_instructions,
);
// Persist windows sandbox feature.
let mut cli_overrides = cli_overrides.unwrap_or_default();
if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) {
cli_overrides.insert(
"features.experimental_windows_sandbox".to_string(),
serde_json::json!(true),
);
}
match derive_config_from_params(&self.cli_overrides, Some(cli_overrides), overrides)
.await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
}
} else {
self.config.as_ref().clone()
};
let rollout_path = if let Some(path) = path {
path
} else {
@@ -2034,6 +1979,58 @@ impl CodexMessageProcessor {
}
};
let history_cwd = match read_session_meta_line(&rollout_path).await {
Ok(meta_line) => Some(meta_line.meta.cwd),
Err(err) => {
let rollout_path = rollout_path.display();
warn!("failed to read session metadata from rollout {rollout_path}: {err}");
None
}
};
// Persist windows sandbox feature.
let mut cli_overrides = cli_overrides.unwrap_or_default();
if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) {
cli_overrides.insert(
"features.experimental_windows_sandbox".to_string(),
serde_json::json!(true),
);
}
let request_overrides = if cli_overrides.is_empty() {
None
} else {
Some(cli_overrides)
};
let typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
cwd,
approval_policy,
sandbox,
base_instructions,
developer_instructions,
);
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = match derive_config_for_cwd(
&self.cli_overrides,
request_overrides,
typesafe_overrides,
history_cwd,
)
.await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let fallback_model_provider = config.model_provider_id.clone();
let NewThread {
@@ -2654,68 +2651,6 @@ impl CodexMessageProcessor {
overrides,
} = params;
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = match overrides {
Some(overrides) => {
let NewConversationParams {
model,
model_provider,
profile,
cwd,
approval_policy,
sandbox: sandbox_mode,
config: request_overrides,
base_instructions,
developer_instructions,
compact_prompt,
include_apply_patch_tool,
} = overrides;
// Persist windows sandbox feature.
let mut request_overrides = request_overrides.unwrap_or_default();
if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) {
request_overrides.insert(
"features.experimental_windows_sandbox".to_string(),
serde_json::json!(true),
);
}
let typesafe_overrides = ConfigOverrides {
model,
config_profile: profile,
cwd: cwd.map(PathBuf::from),
approval_policy,
sandbox_mode,
model_provider,
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
base_instructions,
developer_instructions,
compact_prompt,
include_apply_patch_tool,
..Default::default()
};
derive_config_from_params(
&self.cli_overrides,
Some(request_overrides),
typesafe_overrides,
)
.await
}
None => Ok(self.config.as_ref().clone()),
};
let config = match config {
Ok(cfg) => cfg,
Err(err) => {
self.send_invalid_request_error(
request_id,
format!("error deriving config: {err}"),
)
.await;
return;
}
};
let thread_history = if let Some(path) = path {
match RolloutRecorder::get_rollout_history(&path).await {
Ok(initial_history) => initial_history,
@@ -2781,6 +2716,76 @@ impl CodexMessageProcessor {
}
};
let history_cwd = thread_history.session_cwd();
let (typesafe_overrides, request_overrides) = match overrides {
Some(overrides) => {
let NewConversationParams {
model,
model_provider,
profile,
cwd,
approval_policy,
sandbox: sandbox_mode,
config: request_overrides,
base_instructions,
developer_instructions,
compact_prompt,
include_apply_patch_tool,
} = overrides;
// Persist windows sandbox feature.
let mut request_overrides = request_overrides.unwrap_or_default();
if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) {
request_overrides.insert(
"features.experimental_windows_sandbox".to_string(),
serde_json::json!(true),
);
}
let typesafe_overrides = ConfigOverrides {
model,
config_profile: profile,
cwd: cwd.map(PathBuf::from),
approval_policy,
sandbox_mode,
model_provider,
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
base_instructions,
developer_instructions,
compact_prompt,
include_apply_patch_tool,
..Default::default()
};
(typesafe_overrides, Some(request_overrides))
}
None => (
ConfigOverrides {
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
..Default::default()
},
None,
),
};
let config = match derive_config_for_cwd(
&self.cli_overrides,
request_overrides,
typesafe_overrides,
history_cwd,
)
.await
{
Ok(cfg) => cfg,
Err(err) => {
self.send_invalid_request_error(
request_id,
format!("error deriving config: {err}"),
)
.await;
return;
}
};
match self
.thread_manager
.resume_thread_with_history(config, thread_history, self.auth_manager.clone())
@@ -2840,62 +2845,6 @@ impl CodexMessageProcessor {
} = params;
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = match overrides {
Some(overrides) => {
let NewConversationParams {
model,
model_provider,
profile,
cwd,
approval_policy,
sandbox: sandbox_mode,
config: cli_overrides,
base_instructions,
developer_instructions,
compact_prompt,
include_apply_patch_tool,
} = overrides;
// Persist windows sandbox feature.
let mut cli_overrides = cli_overrides.unwrap_or_default();
if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) {
cli_overrides.insert(
"features.experimental_windows_sandbox".to_string(),
serde_json::json!(true),
);
}
let overrides = ConfigOverrides {
model,
config_profile: profile,
cwd: cwd.map(PathBuf::from),
approval_policy,
sandbox_mode,
model_provider,
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
base_instructions,
developer_instructions,
compact_prompt,
include_apply_patch_tool,
..Default::default()
};
derive_config_from_params(&self.cli_overrides, Some(cli_overrides), overrides).await
}
None => Ok(self.config.as_ref().clone()),
};
let config = match config {
Ok(cfg) => cfg,
Err(err) => {
self.send_invalid_request_error(
request_id,
format!("error deriving config: {err}"),
)
.await;
return;
}
};
let rollout_path = if let Some(path) = path {
path
} else if let Some(conversation_id) = conversation_id {
@@ -2929,6 +2878,90 @@ impl CodexMessageProcessor {
return;
};
let history_cwd = match read_session_meta_line(&rollout_path).await {
Ok(meta_line) => Some(meta_line.meta.cwd),
Err(err) => {
let rollout_path = rollout_path.display();
warn!("failed to read session metadata from rollout {rollout_path}: {err}");
None
}
};
let (typesafe_overrides, request_overrides) = match overrides {
Some(overrides) => {
let NewConversationParams {
model,
model_provider,
profile,
cwd,
approval_policy,
sandbox: sandbox_mode,
config: cli_overrides,
base_instructions,
developer_instructions,
compact_prompt,
include_apply_patch_tool,
} = overrides;
// Persist windows sandbox feature.
let mut cli_overrides = cli_overrides.unwrap_or_default();
if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) {
cli_overrides.insert(
"features.experimental_windows_sandbox".to_string(),
serde_json::json!(true),
);
}
let request_overrides = if cli_overrides.is_empty() {
None
} else {
Some(cli_overrides)
};
let overrides = ConfigOverrides {
model,
config_profile: profile,
cwd: cwd.map(PathBuf::from),
approval_policy,
sandbox_mode,
model_provider,
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
base_instructions,
developer_instructions,
compact_prompt,
include_apply_patch_tool,
..Default::default()
};
(overrides, request_overrides)
}
None => (
ConfigOverrides {
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
..Default::default()
},
None,
),
};
let config = match derive_config_for_cwd(
&self.cli_overrides,
request_overrides,
typesafe_overrides,
history_cwd,
)
.await
{
Ok(cfg) => cfg,
Err(err) => {
self.send_invalid_request_error(
request_id,
format!("error deriving config: {err}"),
)
.await;
return;
}
};
let NewThread {
thread_id,
session_configured,
@@ -4051,6 +4084,31 @@ async fn derive_config_from_params(
.await
}
async fn derive_config_for_cwd(
cli_overrides: &[(String, TomlValue)],
request_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
cwd: Option<PathBuf>,
) -> std::io::Result<Config> {
let merged_cli_overrides = cli_overrides
.iter()
.cloned()
.chain(
request_overrides
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, json_to_toml(v))),
)
.collect::<Vec<_>>();
codex_core::config::ConfigBuilder::default()
.cli_overrides(merged_cli_overrides)
.harness_overrides(typesafe_overrides)
.fallback_cwd(cwd)
.build()
.await
}
pub(crate) async fn read_summary_from_rollout(
path: &Path,
fallback_provider: &str,