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
+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(())
}