mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17: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
|
||||
}
|
||||
@@ -570,6 +570,9 @@ struct LogoutCommand {
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct AppServerCommand {
|
||||
#[command(flatten)]
|
||||
startup_config: codex_app_server::StartupConfigArgs,
|
||||
|
||||
/// Omit to run the app server; specify a subcommand for tooling.
|
||||
#[command(subcommand)]
|
||||
subcommand: Option<AppServerSubcommand>,
|
||||
@@ -1237,6 +1240,7 @@ async fn cli_main(
|
||||
}
|
||||
Some(Subcommand::AppServer(app_server_cli)) => {
|
||||
let AppServerCommand {
|
||||
startup_config,
|
||||
subcommand,
|
||||
code_mode_host,
|
||||
strict_config: app_server_strict_config,
|
||||
@@ -1254,6 +1258,21 @@ async fn cli_main(
|
||||
root_remote_auth_token_env.as_deref(),
|
||||
subcommand.as_ref(),
|
||||
)?;
|
||||
if startup_config.print_startup_config {
|
||||
anyhow::ensure!(
|
||||
subcommand.is_none(),
|
||||
"--print-startup-config cannot run a subcommand"
|
||||
);
|
||||
return codex_app_server::print_startup_config(
|
||||
arg0_paths,
|
||||
root_config_overrides,
|
||||
LoaderOverrides::default(),
|
||||
strict_config,
|
||||
startup_config,
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into);
|
||||
}
|
||||
match subcommand {
|
||||
None => {
|
||||
let transport = if stdio {
|
||||
|
||||
@@ -9,7 +9,13 @@ use tempfile::TempDir;
|
||||
|
||||
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
|
||||
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
||||
cmd.env("CODEX_HOME", codex_home);
|
||||
cmd.env("CODEX_HOME", codex_home)
|
||||
.env_remove("CODEX_ACCESS_TOKEN")
|
||||
.env_remove("CODEX_API_KEY")
|
||||
.env_remove("OPENAI_API_KEY")
|
||||
.env_remove("OPENAI_FEDERATION_RULE_ID")
|
||||
.env_remove("OPENAI_IDENTITY_TOKEN_FILE")
|
||||
.env_remove("OPENAI_WORKLOAD_IDENTITY_CONTEXT");
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
@@ -125,3 +131,155 @@ fn app_server_emits_json_info_events() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_startup_config_preserves_project_login_restrictions() -> Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let project = TempDir::new()?;
|
||||
let project_key =
|
||||
serde_json::to_string(&codex_config::loader::project_trust_key(project.path()))?;
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!("[projects.{project_key}]\ntrust_level = \"trusted\"\n"),
|
||||
)?;
|
||||
std::fs::create_dir(project.path().join(".git"))?;
|
||||
std::fs::create_dir(project.path().join(".codex"))?;
|
||||
for method in ["chatgpt", "api"] {
|
||||
std::fs::write(
|
||||
project.path().join(".codex/config.toml"),
|
||||
format!("forced_login_method = '{method}'\n"),
|
||||
)?;
|
||||
for sign_out in [None, Some("--startup-sign-out")] {
|
||||
let mut command = codex_command(home.path())?;
|
||||
command
|
||||
.current_dir(project.path())
|
||||
.args(["app-server", "--print-startup-config"])
|
||||
.args(sign_out);
|
||||
let output = command.assert().success();
|
||||
let value: serde_json::Value = serde_json::from_slice(&output.get_output().stdout)?;
|
||||
assert_eq!(
|
||||
value["configRequirements"]["requirements"]["allowedLoginMethods"],
|
||||
json!([method])
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_startup_config_rejects_sign_out_with_environment_access_token() -> Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let auth_path = home.path().join("auth.json");
|
||||
let saved_auth = br#"{"OPENAI_API_KEY":"saved-test-key"}"#;
|
||||
std::fs::write(&auth_path, saved_auth)?;
|
||||
for token in ["at-test-token", "header.payload.signature"] {
|
||||
let output = codex_command(home.path())?
|
||||
.env("CODEX_ACCESS_TOKEN", token)
|
||||
.args(["app-server", "--print-startup-config", "--startup-sign-out"])
|
||||
.assert()
|
||||
.failure();
|
||||
let value: serde_json::Value = serde_json::from_slice(&output.get_output().stdout)?;
|
||||
assert_eq!(
|
||||
value["error"]["message"],
|
||||
"Configured external authentication is owned by the app-server host and cannot be changed during startup recovery."
|
||||
);
|
||||
assert_eq!(std::fs::read(&auth_path)?, saved_auth);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_startup_config_validates_configuration() -> Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let config_path = home.path().join("config.toml");
|
||||
for (config, strict_arg, error) in [
|
||||
("[broken\n", None, Some("failed to load configuration")),
|
||||
(
|
||||
"unknown_future_setting = true\n",
|
||||
Some("--strict-config"),
|
||||
Some("unknown configuration field"),
|
||||
),
|
||||
("unknown_future_setting = true\n", None, None),
|
||||
] {
|
||||
std::fs::write(&config_path, config)?;
|
||||
let output = codex_command(home.path())?
|
||||
.args(["app-server", "--print-startup-config"])
|
||||
.args(strict_arg)
|
||||
.assert();
|
||||
let value: serde_json::Value = serde_json::from_slice(&output.get_output().stdout)?;
|
||||
if let Some(error) = error {
|
||||
output.failure().stdout(contains(error));
|
||||
assert_eq!(value["error"]["data"]["reason"], "configLoad");
|
||||
assert_eq!(
|
||||
Path::new(value["error"]["data"]["filePath"].as_str().unwrap()).canonicalize()?,
|
||||
config_path.canonicalize()?
|
||||
);
|
||||
assert!(value["error"]["data"]["line"].as_u64().is_some());
|
||||
assert!(value.get("configRequirements").is_none());
|
||||
} else {
|
||||
output.success();
|
||||
assert_eq!(value["appServerVersion"], env!("CARGO_PKG_VERSION"));
|
||||
assert!(value["configRequirements"].get("requirements").is_some());
|
||||
assert!(value.get("config").is_none());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn print_startup_config_is_independent_of_a_locked_history_database() -> Result<()> {
|
||||
use sqlx::Executor;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
|
||||
let home = TempDir::new()?;
|
||||
let sqlite = TempDir::new()?;
|
||||
let binary = codex_utils_cargo_bin::cargo_bin("codex")?;
|
||||
let mut command = Command::new(&binary);
|
||||
command
|
||||
.env("CODEX_HOME", home.path())
|
||||
.env("CODEX_SQLITE_HOME", sqlite.path())
|
||||
.env_remove("CODEX_ACCESS_TOKEN")
|
||||
.env_remove("CODEX_API_KEY")
|
||||
.env_remove("OPENAI_API_KEY")
|
||||
.env_remove("OPENAI_FEDERATION_RULE_ID")
|
||||
.env_remove("OPENAI_IDENTITY_TOKEN_FILE")
|
||||
.env_remove("OPENAI_WORKLOAD_IDENTITY_CONTEXT")
|
||||
.args(["app-server", "--print-startup-config"])
|
||||
.kill_on_drop(true);
|
||||
let baseline = timeout(Duration::from_secs(10), command.output()).await??;
|
||||
assert!(baseline.status.success());
|
||||
let path = sqlite.path().join("state_5.sqlite");
|
||||
let sqlite_config = codex_state::SqliteConfig::new_for_testing(
|
||||
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(sqlite.path())?,
|
||||
);
|
||||
let pool = sqlite_config.open_read_write_pool(&path).await?;
|
||||
let mut database = pool.acquire().await?;
|
||||
database
|
||||
.execute("CREATE TABLE synthetic_history (id INTEGER)")
|
||||
.await?;
|
||||
database.execute("PRAGMA journal_mode=DELETE").await?;
|
||||
let readers = sqlite_config
|
||||
.open_read_only_pool(&path, Some(Duration::ZERO))
|
||||
.await?;
|
||||
let mut reader = readers.acquire().await?;
|
||||
database.execute("BEGIN EXCLUSIVE").await?;
|
||||
assert!(
|
||||
reader
|
||||
.execute("SELECT * FROM synthetic_history")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let before = std::fs::read(&path)?;
|
||||
let output = timeout(Duration::from_secs(10), command.output()).await??;
|
||||
assert!(output.status.success());
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&output.stdout)?,
|
||||
serde_json::from_slice::<serde_json::Value>(&baseline.stdout)?
|
||||
);
|
||||
assert_eq!(std::fs::read(&path)?, before);
|
||||
|
||||
database.execute("ROLLBACK").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ impl CloudManagedConfigFixture {
|
||||
.env_remove("CODEX_ACCESS_TOKEN")
|
||||
.env_remove("CODEX_API_KEY")
|
||||
.env_remove("OPENAI_API_KEY")
|
||||
.env_remove("OPENAI_FEDERATION_RULE_ID")
|
||||
.env_remove("OPENAI_IDENTITY_TOKEN_FILE")
|
||||
.env_remove("OPENAI_WORKLOAD_IDENTITY_CONTEXT")
|
||||
.args(args);
|
||||
Ok(command)
|
||||
}
|
||||
@@ -442,3 +445,137 @@ async fn add_and_remove_preserve_cloud_managed_resources() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mock_startup_bundle(server: &MockServer, status: u16, config: &str, requirements: &str) {
|
||||
server.reset().await;
|
||||
let layer = |contents: &str| {
|
||||
json!({
|
||||
"enterprise_managed": [{"id": "startup", "name": "Startup policy", "contents": contents}]
|
||||
})
|
||||
};
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/wham/config/bundle"))
|
||||
.respond_with(ResponseTemplate::new(status).set_body_json(json!({
|
||||
"config_toml": layer(config), "requirements_toml": layer(requirements),
|
||||
})))
|
||||
.expect(1..)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn successful_json(command: &mut Command) -> Result<Value> {
|
||||
let output = command.output().await?;
|
||||
ensure!(output.status.success(), "{output:?}");
|
||||
Ok(serde_json::from_slice(&output.stdout)?)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_config_loads_managed_policy_or_reports_cloud_failure() -> Result<()> {
|
||||
for status in [200, 401, 503] {
|
||||
let Some(fixture) = CloudManagedConfigFixture::new().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
mock_startup_bundle(
|
||||
&fixture.server,
|
||||
status,
|
||||
"forced_login_method = 'api'\n",
|
||||
"[application.network.domains]\n'127.0.0.1' = 'allow'\n'example.com' = 'allow'\n",
|
||||
)
|
||||
.await;
|
||||
let sqlite_home = TempDir::new()?;
|
||||
let history_path = sqlite_home.path().join("state_5.sqlite");
|
||||
std::fs::write(&history_path, b"history must not be repaired")?;
|
||||
let output = fixture
|
||||
.command(&["app-server", "--print-startup-config"])?
|
||||
.env("CODEX_SQLITE_HOME", sqlite_home.path())
|
||||
.env(
|
||||
"CODEX_REFRESH_TOKEN_URL_OVERRIDE",
|
||||
format!("{}/refresh", fixture.server.uri()),
|
||||
)
|
||||
.output()
|
||||
.await?;
|
||||
let value: Value = serde_json::from_slice(&output.stdout)?;
|
||||
assert_eq!(output.status.success(), status == 200, "{value}");
|
||||
if status == 200 {
|
||||
let requirements = &value["configRequirements"]["requirements"];
|
||||
assert_eq!(requirements["allowedLoginMethods"], json!(["api"]));
|
||||
assert_eq!(
|
||||
requirements["application"]["network"]["domains"]["example.com"],
|
||||
"allow"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(value["error"]["data"]["reason"], "cloudConfigBundle");
|
||||
assert!(value.get("configRequirements").is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(&history_path)?,
|
||||
b"history must not be repaired"
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(sqlite_home.path())?.count(), 1);
|
||||
fixture.server.verify().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_config_sign_out_reloads_policy_without_starting_runtime() -> Result<()> {
|
||||
for (status, provider) in [(200, None), (503, None), (503, Some("amazon-bedrock"))] {
|
||||
let Some(mut fixture) = CloudManagedConfigFixture::new().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(provider) = provider {
|
||||
fixture
|
||||
.user_config
|
||||
.push_str(&format!("model_provider = \"{provider}\"\n"));
|
||||
}
|
||||
std::fs::write(
|
||||
fixture.codex_home.path().join("config.toml"),
|
||||
&fixture.user_config,
|
||||
)?;
|
||||
mock_startup_bundle(
|
||||
&fixture.server, status, "forced_login_method = 'api'\n",
|
||||
"cli_auth_credentials_store = 'ephemeral'\nenforce_residency = 'us'\n[application.network.domains]\n'127.0.0.1' = 'allow'\n[windows]\nrequire_mxc = true\n",
|
||||
).await;
|
||||
let mut revoke = Mock::given(method("POST")).and(path("/revoke"));
|
||||
if status == 200 {
|
||||
revoke = revoke.and(header("x-openai-internal-codex-residency", "us"));
|
||||
}
|
||||
revoke
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&fixture.server)
|
||||
.await;
|
||||
let signed_out_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
signed_out_home.path().join("config.toml"),
|
||||
&fixture.user_config,
|
||||
)?;
|
||||
let baseline = successful_json(
|
||||
fixture
|
||||
.command(&["app-server", "--print-startup-config"])?
|
||||
.env("CODEX_HOME", signed_out_home.path()),
|
||||
)
|
||||
.await?;
|
||||
let sqlite_home = TempDir::new()?;
|
||||
let value = successful_json(
|
||||
fixture
|
||||
.command(&["app-server", "--print-startup-config", "--startup-sign-out"])?
|
||||
.env("CODEX_SQLITE_HOME", sqlite_home.path())
|
||||
.env(
|
||||
"CODEX_REVOKE_TOKEN_URL_OVERRIDE",
|
||||
format!("{}/revoke", fixture.server.uri()),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(value["configRequirements"], baseline["configRequirements"]);
|
||||
assert_eq!(std::fs::read_dir(sqlite_home.path())?.count(), 0);
|
||||
let user_config: toml::Value = toml::from_str(&std::fs::read_to_string(
|
||||
fixture.codex_home.path().join("config.toml"),
|
||||
)?)?;
|
||||
assert!(user_config.get("model_provider").is_none());
|
||||
let status = fixture.command(&["login", "status"])?.output().await?;
|
||||
assert!(!status.status.success());
|
||||
fixture.server.verify().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user