Add one-shot app-server startup configuration discovery (#55300)

## What changed

Add `codex app-server --print-startup-config` to validate local and cloud-backed configuration and emit the app-server version and configuration requirements as JSON without initializing environments, thread storage, or plugins. Configuration failures produce structured JSON errors and a nonzero exit status.

Support `--startup-sign-out` to remove account credentials and reload signed-out requirements, including recovery when cloud policy is unavailable. Reject sign-out for host-owned external authentication. Accept `--startup-client-info` to identify configuration and authentication requests.

Reuse configuration loading and account cleanup logic between startup discovery and the running app server.

## Testing

Add CLI integration coverage for configuration validation, project login restrictions, managed cloud requirements and failures, sign-out recovery, external authentication protection, and independence from locked or invalid history databases.

GitOrigin-RevId: 636ccb095b684b6136cf9e408f3f8613cb776e16
This commit is contained in:
acrognale-oai
2026-10-19 15:13:44 +00:00
committed by copyberry
parent 54f99017f2
commit 1277bcfddc
10 changed files with 628 additions and 83 deletions
+26
View File
@@ -66,6 +66,12 @@ pub(crate) struct StartupConfig {
pub(crate) used_defaults: bool,
}
/// Local auth authority and the cloud-backed configuration loaded with it.
pub(crate) struct CloudStartupConfig {
pub(crate) bootstrap: Config,
pub(crate) config: std::io::Result<Config>,
}
/// Configuration and policy must finish loading against the same account and cloud snapshot.
pub(crate) struct ApplicationPolicyLoad {
cloud_config: CloudConfigBundleLoader,
@@ -374,6 +380,26 @@ impl ConfigManager {
Ok(())
}
/// Load local policy before auth, then fetch the account's cloud configuration.
/// Callers choose their error/recovery policy before initializing any runtimes.
pub(crate) async fn load_cloud_startup_config(
&self,
cwd: Option<PathBuf>,
) -> std::io::Result<CloudStartupConfig> {
let bootstrap = self.load_startup_config(cwd.clone()).await?.config;
let auth =
AuthManager::shared_from_config(&bootstrap, /*enable_codex_api_key_env*/ false)
.await
.map_err(std::io::Error::other)?;
self.replace_cloud_config_bundle_loader(
auth,
bootstrap.chatgpt_base_url.clone(),
bootstrap.http_client_factory(),
);
let config = self.load_latest_config(cwd).await;
Ok(CloudStartupConfig { bootstrap, config })
}
/// Installs local policy before cloud access; failed policy loads block traffic while
/// non-strict startup remains available for configuration repair.
pub(crate) async fn load_startup_config(
+8 -14
View File
@@ -104,6 +104,10 @@ mod code_mode_host;
mod codex_home_metrics;
mod command_exec;
mod config_layer;
mod startup_config;
pub use startup_config::StartupConfigArgs;
pub use startup_config::print_startup_config;
mod config_manager;
mod config_manager_service;
mod connection_cleanup;
@@ -546,22 +550,12 @@ pub async fn run_main_with_transport_options(
arg0_paths.clone(),
Arc::new(NoopThreadConfigLoader),
);
let bootstrap_config = config_manager
.load_startup_config(startup_cwd.clone())
.await?
.config;
let bootstrap_auth =
AuthManager::shared_from_config(&bootstrap_config, /*enable_codex_api_key_env*/ false)
.await
.map_err(std::io::Error::other)?;
config_manager.replace_cloud_config_bundle_loader(
bootstrap_auth,
bootstrap_config.chatgpt_base_url.clone(),
bootstrap_config.http_client_factory(),
);
let startup = config_manager
.load_cloud_startup_config(startup_cwd.clone())
.await?;
let mut config_warnings = Vec::new();
let mut plugin_startup_config = PluginStartupConfig::Current;
let config = match config_manager.load_latest_config(startup_cwd.clone()).await {
let config = match startup.config {
Ok(config) => config,
Err(err) if is_unsupported_untrusted_approval_policy_error(&err) => {
return Err(err);
@@ -537,12 +537,12 @@ use uuid::Uuid;
#[cfg(test)]
use codex_app_server_protocol::ServerRequest;
mod account_processor;
pub(crate) mod account_processor;
mod apps_processor;
mod bedrock_auth;
mod catalog_processor;
mod command_exec_processor;
mod config_processor;
pub(crate) mod config_processor;
#[cfg(test)]
#[path = "request_processors/config_reload_tests.rs"]
mod config_reload_tests;
@@ -704,7 +704,7 @@ fn resolve_runtime_workspace_roots(workspace_roots: Vec<AbsolutePathBuf>) -> Vec
resolved_roots
}
mod config_errors;
pub(crate) mod config_errors;
mod request_errors;
mod thread_delete;
mod thread_goal_processor;
@@ -1009,71 +1009,12 @@ impl AccountRequestProcessor {
if self.auth_manager.is_workload_identity_selected() {
return Err(self.configured_auth_owned_by_host_error());
}
// Another process may have changed the persisted workspace. Reload both
// account authority and its policy before selecting a grant to remove.
self.auth_manager.reload().await;
self.config_manager.replace_cloud_config_bundle_loader(
Arc::clone(&self.auth_manager),
self.config.chatgpt_base_url.clone(),
self.config.http_client_factory(),
);
let config = self
.config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await;
let scope = ema_auth_scope(self.auth_manager.auth_cached().as_ref());
let enterprise_policy_failed = scope.is_some() && config.is_err();
// Startup policy may belong to another workspace. Never use that fallback
// to select a credential; a policy failure must not block primary logout.
let enterprise_grant = config.as_ref().ok().and_then(|config| {
scope
.as_ref()
.zip(config.mcp_enterprise_managed_auth.as_ref())
.map(|(scope, profile)| {
(
profile.idp.credential_name(scope),
profile.idp.issuer.clone(),
config.auth_keyring_backend_kind(),
)
})
});
let config = config.unwrap_or_else(|_| self.config.as_ref().clone());
self.cancel_active_login().await;
// Retain the credential lock through primary logout. Otherwise a second
// process can commit after deletion but before the account is removed.
let enterprise_guard =
if let Some((credential_name, issuer, keyring_backend)) = enterprise_grant {
EnterpriseOAuthCredentialGuard::acquire(&credential_name, &issuer, keyring_backend)
.await
.map(Some)
} else {
Ok(None)
};
let cleanup_failed = match &enterprise_guard {
Ok(Some(guard)) => guard.delete_tokens().is_err(),
Ok(None) => false,
Err(_) => true,
};
if enterprise_policy_failed || cleanup_failed {
tracing::warn!("Failed to remove enterprise authorization; continuing account logout");
}
match self.auth_manager.logout_with_revoke().await {
Ok(_) => {}
Err(err) => {
return Err(internal_error(format!("logout failed: {err}")));
}
}
drop(enterprise_guard);
let config =
logout_configured_account(&self.config_manager, &self.auth_manager, &self.config)
.await?;
self.thread_manager.invalidate_mcp_runtimes().await;
self.config_manager.clear_cloud_config_bundle_loader();
if config.model_provider.is_amazon_bedrock() {
clear_user_model_provider_if_bedrock(&self.config_manager, &config).await?;
}
clear_account_configuration(&self.config_manager, &config).await?;
self.model_catalog.invalidate().await;
*self.workspace_routing.lock().await = None;
@@ -1592,6 +1533,82 @@ fn workspace_messages_feature_disabled(err: &BackendRequestError) -> bool {
err.status().is_some_and(|status| status.as_u16() == 404)
}
/// Remove account credentials using the current account's policy, even during startup recovery.
pub(crate) async fn logout_configured_account(
config_manager: &ConfigManager,
auth_manager: &Arc<AuthManager>,
fallback_config: &Config,
) -> Result<Config, JSONRPCErrorError> {
// Another process may have changed the persisted workspace. Reload both
// account authority and its policy before selecting a grant to remove.
auth_manager.reload().await;
config_manager.replace_cloud_config_bundle_loader(
Arc::clone(auth_manager),
fallback_config.chatgpt_base_url.clone(),
fallback_config.http_client_factory(),
);
let config = config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await;
let scope = ema_auth_scope(auth_manager.auth_cached().as_ref());
let enterprise_policy_failed = scope.is_some() && config.is_err();
// Startup policy may belong to another workspace. Never use that fallback
// to select a credential; a policy failure must not block primary logout.
let enterprise_grant = config.as_ref().ok().and_then(|config| {
scope
.as_ref()
.zip(config.mcp_enterprise_managed_auth.as_ref())
.map(|(scope, profile)| {
(
profile.idp.credential_name(scope),
profile.idp.issuer.clone(),
config.auth_keyring_backend_kind(),
)
})
});
let config = config.unwrap_or_else(|_| fallback_config.clone());
// Retain the credential lock through primary logout. Otherwise a second
// process can commit after deletion but before the account is removed.
let enterprise_guard =
if let Some((credential_name, issuer, keyring_backend)) = enterprise_grant {
EnterpriseOAuthCredentialGuard::acquire(&credential_name, &issuer, keyring_backend)
.await
.map(Some)
} else {
Ok(None)
};
let cleanup_failed = match &enterprise_guard {
Ok(Some(guard)) => guard.delete_tokens().is_err(),
Ok(None) => false,
Err(_) => true,
};
if enterprise_policy_failed || cleanup_failed {
tracing::warn!("Failed to remove enterprise authorization; continuing account logout");
}
match auth_manager.logout_with_revoke().await {
Ok(_) => {}
Err(err) => {
return Err(internal_error(format!("logout failed: {err}")));
}
}
drop(enterprise_guard);
Ok(config)
}
/// Discard account-scoped cloud and provider settings after credentials have been removed.
pub(crate) async fn clear_account_configuration(
config_manager: &ConfigManager,
config: &Config,
) -> Result<(), JSONRPCErrorError> {
config_manager.clear_cloud_config_bundle_loader();
if config.model_provider.is_amazon_bedrock() {
clear_user_model_provider_if_bedrock(config_manager, config).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -13,7 +13,7 @@ fn cloud_config_bundle_load_error(err: &std::io::Error) -> Option<&CloudConfigBu
None
}
pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError {
pub(crate) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError {
let data = cloud_config_bundle_load_error(err).map(|cloud_error| {
let mut data = serde_json::json!({
"reason": "cloudConfigBundle",
@@ -439,7 +439,7 @@ async fn reload_user_config_inner(config_manager: &ConfigManager, thread_manager
}
}
fn map_requirements_to_api(
pub(crate) fn map_requirements_to_api(
requirements: Option<ConfigRequirementsToml>,
allowed_login_methods: Vec<ForcedLoginMethod>,
) -> Option<ConfigRequirements> {
+194
View File
@@ -0,0 +1,194 @@
//! One-shot configuration discovery. Never initializes environments, thread storage, or plugins.
use crate::config_manager::ConfigManager;
use crate::request_processors::account_processor::clear_account_configuration;
use crate::request_processors::account_processor::logout_configured_account;
use crate::request_processors::config_errors::config_load_error;
use crate::request_processors::config_processor::map_requirements_to_api;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_arg0::Arg0DispatchPaths;
use codex_config::LoaderOverrides;
use codex_config::NoopThreadConfigLoader;
use codex_core::config::find_codex_home;
use codex_login::AuthManager;
use codex_login::default_client::SetOriginatorError;
use codex_login::default_client::USER_AGENT_SUFFIX;
use codex_login::default_client::set_default_client_residency_requirement;
use codex_login::default_client::set_default_originator;
use codex_login::is_workload_identity_selected;
use codex_login::read_codex_access_token_from_env;
use codex_utils_cli::CliConfigOverrides;
use serde::Serialize;
use std::io;
use std::io::Write;
use std::sync::Arc;
#[derive(Debug, clap::Args)]
pub struct StartupConfigArgs {
/// Sign out using the managed auth path before loading fresh startup requirements.
#[arg(long, requires = "print_startup_config")]
pub startup_sign_out: bool,
/// Validate configuration and print requirements as JSON without starting the app server.
#[arg(long, conflicts_with_all = ["listen", "stdio", "remote_control", "managed_daemon"])]
pub print_startup_config: bool,
/// ClientInfo JSON used for configuration/auth request identity.
#[arg(long, requires = "print_startup_config", value_parser = |value: &str| serde_json::from_str::<ClientInfo>(value))]
pub startup_client_info: Option<ClientInfo>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StartupConfig {
app_server_version: &'static str,
config_requirements: ConfigRequirementsReadResponse,
}
/// Writes exactly one JSON value, including structured errors on failure.
pub async fn print_startup_config(
arg0_paths: Arg0DispatchPaths,
cli_config_overrides: CliConfigOverrides,
loader_overrides: LoaderOverrides,
strict_config: bool,
startup: StartupConfigArgs,
) -> io::Result<()> {
let result = read_startup_config(
arg0_paths,
cli_config_overrides,
loader_overrides,
strict_config,
startup,
)
.await;
let output = match &result {
Ok(config) => serde_json::to_vec(config)?,
Err(error) => serde_json::to_vec(&serde_json::json!({ "error": error }))?,
};
let mut stdout = io::stdout().lock();
stdout.write_all(&output)?;
stdout.write_all(b"\n")?;
stdout.flush()?;
result
.map(|_| ())
.map_err(|_| io::Error::other("startup configuration unavailable"))
}
async fn read_startup_config(
arg0_paths: Arg0DispatchPaths,
cli_config_overrides: CliConfigOverrides,
loader_overrides: LoaderOverrides,
strict_config: bool,
startup: StartupConfigArgs,
) -> Result<StartupConfig, JSONRPCErrorError> {
if startup.startup_sign_out
&& (is_workload_identity_selected() || read_codex_access_token_from_env().is_some())
{
return Err(crate::error_code::invalid_request(
"Configured external authentication is owned by the app-server host and cannot be changed during startup recovery.",
));
}
if let Some(client) = startup.startup_client_info {
match set_default_originator(client.name.clone()) {
Ok(()) | Err(SetOriginatorError::AlreadyInitialized) => {}
Err(SetOriginatorError::InvalidHeaderValue) => {
return Err(crate::error_code::invalid_params("invalid client name"));
}
}
if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() {
*suffix = Some(format!("{}; {}", client.name, client.version));
}
}
let loader_overrides = crate::loader_overrides_with_test_user_config_file(
loader_overrides,
crate::test_user_config_file_from_env(),
)
.map_err(startup_error)?;
let overrides = cli_config_overrides
.parse_overrides()
.map_err(|error| startup_error(io::Error::new(io::ErrorKind::InvalidInput, error)))?;
let manager = ConfigManager::new(
find_codex_home().map_err(startup_error)?.to_path_buf(),
overrides,
loader_overrides,
strict_config,
Default::default(),
arg0_paths,
Arc::new(NoopThreadConfigLoader),
);
let cwd = std::env::current_dir().ok();
let loaded = manager
.load_cloud_startup_config(cwd.clone())
.await
.map_err(startup_error)?;
let config = match loaded.config {
Ok(config) => config,
// Recovery retains local credential settings when cloud policy is unavailable.
Err(_) if startup.startup_sign_out => loaded.bootstrap.clone(),
Err(error) => return Err(startup_error(error)),
};
let config = if startup.startup_sign_out {
// Preserve local credential authority while using the effective HTTP route.
let mut auth_config = loaded.bootstrap.auth_config();
auth_config.auth_route_config = config.auth_route_config();
auth_config.chatgpt_base_url = Some(config.chatgpt_base_url.clone());
let auth = AuthManager::shared_from_auth_config(
auth_config,
/*enable_codex_api_key_env*/ false,
)
.await
.map_err(|error| startup_error(io::Error::other(error)))?;
set_default_client_residency_requirement(config.enforce_residency.value());
let config = logout_configured_account(&manager, &auth, &config).await?;
clear_account_configuration(&manager, &config).await?;
manager.replace_cloud_config_bundle_loader(
Arc::clone(&auth),
config.chatgpt_base_url.clone(),
config.http_client_factory(),
);
manager
.load_latest_config(cwd)
.await
.map_err(startup_error)?
} else {
config
};
// Validate and emit the fetched snapshot, or the fresh signed-out snapshot.
config
.validate_windows_mxc_requirement()
.map_err(startup_error)?;
let auth_config = config.auth_config();
auth_config.validate().map_err(startup_error)?;
let allowed_login_methods = auth_config.managed_auth_policy.allowed_login_methods(
auth_config.forced_login_method,
auth_config.forced_chatgpt_workspace_id.as_deref(),
);
let requirements = config.config_layer_stack.requirements_toml();
Ok(StartupConfig {
app_server_version: env!("CARGO_PKG_VERSION"),
config_requirements: ConfigRequirementsReadResponse {
supports_independent_speed_modes: Some(true),
requirements: map_requirements_to_api(
(!requirements.is_empty()).then(|| requirements.clone()),
allowed_login_methods,
),
},
})
}
fn startup_error(error: io::Error) -> JSONRPCErrorError {
let mut response = config_load_error(&error);
if response.data.is_none() {
let warning = crate::config_warning_from_error("Invalid configuration", &error);
response.data = Some(serde_json::json!({
"reason": "configLoad",
"filePath": warning.path,
"line": warning.range.as_ref().map(|range| range.start.line),
"column": warning.range.as_ref().map(|range| range.start.column),
"detail": error.to_string(),
}));
}
response
}