mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +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:
@@ -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