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
}
+19
View File
@@ -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 {
+159 -1
View File
@@ -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(())
}
+137
View File
@@ -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(())
}