mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user