Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/app-server-protocol/src/protocol/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ server_notification_definitions! {
ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification),
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification),
ConfigWarning => "configWarning" (v2::ConfigWarningNotification),

/// Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.
WindowsWorldWritableWarning => "windows/worldWritableWarning" (v2::WindowsWorldWritableWarningNotification),
Expand Down
10 changes: 10 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2107,6 +2107,16 @@ pub struct DeprecationNoticeNotification {
pub details: Option<String>,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigWarningNotification {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize it is not easy to do, but it would probably be most helpful if we could include the file (or files) that caused the issue and then helped the user open them in VS Code so they can make the necessary fixes and then reload?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I thought about that. I didn't want to make this PR more complicated. Could be a follow-on PR.

/// Concise summary of the warning.
pub summary: String,
/// Optional extra guidance or error details.
pub details: Option<String>,
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
42 changes: 38 additions & 4 deletions codex-rs/app-server/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#![deny(clippy::print_stdout, clippy::print_stderr)]

use codex_common::CliConfigOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config_loader::LoaderOverrides;
use std::io::ErrorKind;
Expand All @@ -10,7 +11,9 @@ use std::path::PathBuf;
use crate::message_processor::MessageProcessor;
use crate::outgoing_message::OutgoingMessage;
use crate::outgoing_message::OutgoingMessageSender;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCMessage;
use codex_core::check_execpolicy_for_warnings;
use codex_feedback::CodexFeedback;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
Expand Down Expand Up @@ -82,14 +85,38 @@ pub async fn run_main(
)
})?;
let loader_overrides_for_config_api = loader_overrides.clone();
let config = ConfigBuilder::default()
let mut config_warnings = Vec::new();
let config = match ConfigBuilder::default()
.cli_overrides(cli_kv_overrides.clone())
.loader_overrides(loader_overrides)
.build()
.await
.map_err(|e| {
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
})?;
{
Ok(config) => config,
Err(err) => {
let message = ConfigWarningNotification {
summary: "Invalid configuration; using defaults.".to_string(),
details: Some(err.to_string()),
};
config_warnings.push(message);
Config::load_default_with_cli_overrides(cli_kv_overrides.clone()).map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidData,
format!("error loading default config after config error: {e}"),
)
})?
}
};

if let Ok(Some(err)) =
check_execpolicy_for_warnings(&config.features, &config.config_layer_stack).await
{
let message = ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
};
config_warnings.push(message);
}

let feedback = CodexFeedback::new();

Expand Down Expand Up @@ -127,6 +154,12 @@ pub async fn run_main(
.with(otel_logger_layer)
.with(otel_tracing_layer)
.try_init();
for warning in &config_warnings {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If warnings is non-empty, should we start up or should we just exit?

Admittedly, this would be more palatable if we were able to help the user find the errant config.toml and fix it...

Copy link
Collaborator Author

@etraut-openai etraut-openai Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should definitely start up. The current code just exits, and that leaves the user with a broken experience in the VSCE (just a spinner with no indication of a failure).

match &warning.details {
Some(details) => error!("{} {}", warning.summary, details),
None => error!("{}", warning.summary),
}
}

// Task: process incoming messages.
let processor_handle = tokio::spawn({
Expand All @@ -140,6 +173,7 @@ pub async fn run_main(
cli_overrides,
loader_overrides,
feedback.clone(),
config_warnings,
);
async move {
while let Some(msg) = incoming_rx.recv().await {
Expand Down
15 changes: 15 additions & 0 deletions codex-rs/app-server/src/message_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_core::AuthManager;
use codex_core::ThreadManager;
use codex_core::config::Config;
Expand All @@ -34,6 +36,7 @@ pub(crate) struct MessageProcessor {
codex_message_processor: CodexMessageProcessor,
config_api: ConfigApi,
initialized: bool,
config_warnings: Vec<ConfigWarningNotification>,
}

impl MessageProcessor {
Expand All @@ -46,6 +49,7 @@ impl MessageProcessor {
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
feedback: CodexFeedback,
config_warnings: Vec<ConfigWarningNotification>,
) -> Self {
let outgoing = Arc::new(outgoing);
let auth_manager = AuthManager::shared(
Expand Down Expand Up @@ -74,6 +78,7 @@ impl MessageProcessor {
codex_message_processor,
config_api,
initialized: false,
config_warnings,
}
}

Expand Down Expand Up @@ -155,6 +160,16 @@ impl MessageProcessor {

self.initialized = true;

if !self.config_warnings.is_empty() {
for notification in self.config_warnings.drain(..) {
self.outgoing
.send_server_notification(ServerNotification::ConfigWarning(
notification,
))
.await;
}
}

return;
}
}
Expand Down
23 changes: 23 additions & 0 deletions codex-rs/app-server/src/outgoing_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ mod tests {
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::LoginChatGptCompleteNotification;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
Expand Down Expand Up @@ -279,4 +280,26 @@ mod tests {
"ensure the notification serializes correctly"
);
}

#[test]
fn verify_config_warning_notification_serialization() {
let notification = ServerNotification::ConfigWarning(ConfigWarningNotification {
summary: "Config error: using defaults".to_string(),
details: Some("error loading config: bad config".to_string()),
});

let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification);
assert_eq!(
json!( {
"method": "configWarning",
"params": {
"summary": "Config error: using defaults",
"details": "error loading config: bad config",
},
}),
serde_json::to_value(jsonrpc_notification)
.expect("ensure the notification serializes correctly"),
"ensure the notification serializes correctly"
);
}
}
2 changes: 1 addition & 1 deletion codex-rs/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pub use sandbox_mode_cli_arg::SandboxModeCliArg;
#[cfg(feature = "cli")]
pub mod format_env_display;

#[cfg(any(feature = "cli", test))]
#[cfg(feature = "cli")]
mod config_override;

#[cfg(feature = "cli")]
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/core/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ impl Codex {

let exec_policy = ExecPolicyManager::load(&config.features, &config.config_layer_stack)
.await
.map_err(|err| CodexErr::Fatal(format!("failed to load execpolicy: {err}")))?;
.map_err(|err| CodexErr::Fatal(format!("failed to load rules: {err}")))?;

let config = Arc::new(config);
if config.features.enabled(Feature::RemoteModels)
Expand Down
22 changes: 22 additions & 0 deletions codex-rs/core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,28 @@ impl Config {
.await
}

/// Load a default configuration when user config files are invalid.
pub fn load_default_with_cli_overrides(
cli_overrides: Vec<(String, TomlValue)>,
) -> std::io::Result<Self> {
let codex_home = find_codex_home()?;
let mut merged = toml::Value::try_from(ConfigToml::default()).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("failed to serialize default config: {e}"),
)
})?;
let cli_layer = crate::config_loader::build_cli_overrides_layer(&cli_overrides);
crate::config_loader::merge_toml_values(&mut merged, &cli_layer);
let config_toml = deserialize_config_toml_with_base(merged, &codex_home)?;
Self::load_config_with_layer_stack(
config_toml,
ConfigOverrides::default(),
codex_home,
ConfigLayerStack::default(),
)
}

/// This is a secondary way of creating [Config], which is appropriate when
/// the harness is meant to be used with a specific configuration that
/// ignores user settings. For example, the `codex exec` subcommand is
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/config_loader/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub use config_requirements::McpServerRequirement;
pub use config_requirements::RequirementSource;
pub use config_requirements::SandboxModeRequirement;
pub use merge::merge_toml_values;
pub(crate) use overrides::build_cli_overrides_layer;
pub use state::ConfigLayerEntry;
pub use state::ConfigLayerStack;
pub use state::ConfigLayerStackOrdering;
Expand Down
4 changes: 2 additions & 2 deletions codex-rs/core/src/config_loader/overrides.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use toml::Value as TomlValue;

pub(super) fn default_empty_table() -> TomlValue {
pub(crate) fn default_empty_table() -> TomlValue {
TomlValue::Table(Default::default())
}

pub(super) fn build_cli_overrides_layer(cli_overrides: &[(String, TomlValue)]) -> TomlValue {
pub(crate) fn build_cli_overrides_layer(cli_overrides: &[(String, TomlValue)]) -> TomlValue {
let mut root = default_empty_table();
for (path, value) in cli_overrides {
apply_toml_override(&mut root, path, value.clone());
Expand Down
44 changes: 30 additions & 14 deletions codex-rs/core/src/exec_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,19 @@ fn is_policy_match(rule_match: &RuleMatch) -> bool {

#[derive(Debug, Error)]
pub enum ExecPolicyError {
#[error("failed to read execpolicy files from {dir}: {source}")]
#[error("failed to read rules files from {dir}: {source}")]
ReadDir {
dir: PathBuf,
source: std::io::Error,
},

#[error("failed to read execpolicy file {path}: {source}")]
#[error("failed to read rules file {path}: {source}")]
ReadFile {
path: PathBuf,
source: std::io::Error,
},

#[error("failed to parse execpolicy file {path}: {source}")]
#[error("failed to parse rules file {path}: {source}")]
ParsePolicy {
path: String,
source: codex_execpolicy::Error,
Expand All @@ -67,19 +67,19 @@ pub enum ExecPolicyError {

#[derive(Debug, Error)]
pub enum ExecPolicyUpdateError {
#[error("failed to update execpolicy file {path}: {source}")]
#[error("failed to update rules file {path}: {source}")]
AppendRule { path: PathBuf, source: AmendError },

#[error("failed to join blocking execpolicy update task: {source}")]
#[error("failed to join blocking rules update task: {source}")]
JoinBlockingTask { source: tokio::task::JoinError },

#[error("failed to update in-memory execpolicy: {source}")]
#[error("failed to update in-memory rules: {source}")]
AddRule {
#[from]
source: ExecPolicyRuleError,
},

#[error("cannot append execpolicy rule because execpolicy feature is disabled")]
#[error("cannot append rule because rules feature is disabled")]
FeatureDisabled,
}

Expand All @@ -98,7 +98,11 @@ impl ExecPolicyManager {
features: &Features,
config_stack: &ConfigLayerStack,
) -> Result<Self, ExecPolicyError> {
let policy = load_exec_policy_for_features(features, config_stack).await?;
let (policy, warning) =
load_exec_policy_for_features_with_warning(features, config_stack).await?;
if let Some(err) = warning.as_ref() {
tracing::warn!("failed to parse rules: {err}");
}
Ok(Self::new(Arc::new(policy)))
}

Expand Down Expand Up @@ -195,14 +199,26 @@ impl Default for ExecPolicyManager {
}
}

async fn load_exec_policy_for_features(
pub async fn check_execpolicy_for_warnings(
features: &Features,
config_stack: &ConfigLayerStack,
) -> Result<Policy, ExecPolicyError> {
) -> Result<Option<ExecPolicyError>, ExecPolicyError> {
let (_, warning) = load_exec_policy_for_features_with_warning(features, config_stack).await?;
Ok(warning)
}

async fn load_exec_policy_for_features_with_warning(
features: &Features,
config_stack: &ConfigLayerStack,
) -> Result<(Policy, Option<ExecPolicyError>), ExecPolicyError> {
if !features.enabled(Feature::ExecPolicy) {
Ok(Policy::empty())
} else {
load_exec_policy(config_stack).await
return Ok((Policy::empty(), None));
}

match load_exec_policy(config_stack).await {
Ok(policy) => Ok((policy, None)),
Err(err @ ExecPolicyError::ParsePolicy { .. }) => Ok((Policy::empty(), Some(err))),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to falling back to a default config, falling back to an empty could be particularly harmful/confusing for a user.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree — that's why it's important to surface the warning to them.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#9187 should help a bit so that at least all ExecPolicyError variants have a PathBuf associated with them.

Maybe we should have a sources: Vec<PathBuf> on ConfigWarningNotification now, even if it's just an empty array, so we can start experimenting with building UI against it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you think of a case where we need multiple paths? Shouldn't we always be able to map an error to a specific file?

Also, do you think we should go so far as to provide a line number and character offset? Or even a character range?

This is a slippery slope which is why I kind of didn't want to go there right now since my main objective is to just keep the VSCE from hanging on startup.

Err(err) => Err(err),
}
}

Expand Down Expand Up @@ -239,7 +255,7 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
}

let policy = parser.build();
tracing::debug!("loaded execpolicy from {} files", policy_paths.len());
tracing::debug!("loaded rules from {} files", policy_paths.len());

Ok(policy)
}
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
pub use command_safety::is_dangerous_command;
pub use command_safety::is_safe_command;
pub use exec_policy::ExecPolicyError;
pub use exec_policy::check_execpolicy_for_warnings;
pub use exec_policy::load_exec_policy;
pub use safety::get_platform_sandbox;
pub use safety::is_windows_elevated_sandbox_enabled;
Expand Down
Loading
Loading