Enforce managed authentication requirements locally (#37132)

## Why

Authentication restrictions must apply before stored or environment-provided credentials can be used, including during bootstrap before cloud requirements are fetched.

## What changed

- Add local `requirements.toml` allowlists for login methods and ChatGPT workspaces. Ignore these fields in cloud-provided requirements.
- Combine managed workspace allowlists with existing workspace restrictions by intersection, and fail closed when the resulting policy permits no usable login method.
- Centralize policy checks in the authentication manager so CLI, TUI, app-server, external-auth, and credential-loading paths consistently reject disallowed authentication before token hydration or network requests.

## Testing

- Cover policy composition, workspace intersection, invalid stored and external credentials, bootstrap enforcement, and login endpoint restrictions.

GitOrigin-RevId: efef22b248f3c3333e9aa55423e539efa2d2dd48
This commit is contained in:
Abhinav
2026-08-05 18:09:26 +00:00
committed by copyberry
parent 4cb8676d3a
commit 2994f545a7
36 changed files with 1121 additions and 328 deletions
+11 -15
View File
@@ -6,6 +6,7 @@ use codex_config::LoaderOverrides;
use codex_config::ThreadConfigLoader;
use codex_config::loader::load_config_layers_state;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_exec_server::LOCAL_FS;
use codex_features::feature_for_key;
@@ -171,21 +172,16 @@ impl ConfigManager {
}
pub(crate) async fn load_default_config(&self) -> std::io::Result<Config> {
let mut config = Config::load_default_with_cli_overrides_for_codex_home(
self.codex_home.clone(),
self.current_cli_overrides(),
)
.await?;
if self.loader_overrides.user_config_path.is_some()
|| self.loader_overrides.user_config_profile.is_some()
{
let user_config_path = self.loader_overrides.user_config_path(self.codex_home())?;
config.config_layer_stack = config.config_layer_stack.with_user_config_profile(
&user_config_path,
self.loader_overrides.user_config_profile.as_ref(),
TomlValue::Table(toml::map::Map::new()),
)?;
}
let mut loader_overrides = self.loader_overrides.clone();
loader_overrides.ignore_user_config = true;
let mut config = ConfigBuilder::default()
.codex_home(self.codex_home.clone())
.cli_overrides(self.current_cli_overrides())
.loader_overrides(loader_overrides)
.fallback_cwd(Some(self.codex_home.clone()))
.cloud_config_bundle(CloudConfigBundleLoader::default())
.build()
.await?;
config.psp = self.psp;
self.apply_runtime_feature_enablement(&mut config);
self.apply_arg0_paths(&mut config);
@@ -671,9 +671,14 @@ async fn write_value_defaults_to_selected_user_config_path() {
}
#[tokio::test]
async fn load_default_config_preserves_selected_user_config_path_after_load_error() {
async fn load_default_config_preserves_managed_requirements_and_selected_user_config_path() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"gpt-main\"").unwrap();
std::fs::write(
tmp.path().join("requirements.toml"),
"allowed_login_methods = [\"api\"]\nallowed_chatgpt_workspaces = [\"managed-workspace\"]\n",
)
.unwrap();
let selected_path = tmp.path().join("work.config.toml");
std::fs::write(&selected_path, "not valid toml").unwrap();
let selected_file =
@@ -703,6 +708,61 @@ async fn load_default_config_preserves_selected_user_config_path_after_load_erro
config.config_layer_stack.get_user_config_file(),
Some(&selected_file)
);
assert_eq!(
config
.config_layer_stack
.requirements()
.managed_auth_policy(),
codex_config::ManagedAuthPolicy {
allowed_login_methods: Some(vec![codex_protocol::config_types::ForcedLoginMethod::Api]),
allowed_chatgpt_workspaces: Some(vec!["managed-workspace".to_string()]),
}
);
}
#[tokio::test]
async fn managed_auth_policy_survives_unusable_requirements_file_changes() -> Result<()> {
let tmp = tempdir()?;
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?;
let requirements_path = tmp.path().join("requirements.toml");
std::fs::write(
&requirements_path,
"allowed_login_methods = [\"api\"]\nallowed_chatgpt_workspaces = [\"startup\"]\n",
)?;
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
Vec::new(),
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml")),
CloudConfigBundleLoader::default(),
);
let startup = service.load_latest_config(/*fallback_cwd*/ None).await?;
let auth_manager = codex_login::AuthManager::shared_from_config(
&startup, /*enable_codex_api_key_env*/ false,
)
.await;
std::fs::write(
&requirements_path,
"allowed_login_methods = [\"chatgpt\"]\nallowed_chatgpt_workspaces = []\n",
)?;
for refreshed in [
service.load_latest_config(/*fallback_cwd*/ None).await?,
service.load_latest_config_for_thread(&startup).await?,
] {
assert_eq!(refreshed.forced_login_method, None);
assert_eq!(refreshed.forced_chatgpt_workspace_id, None);
}
assert!(
auth_manager.is_login_method_allowed(codex_protocol::config_types::ForcedLoginMethod::Api)
);
assert!(
!auth_manager
.is_login_method_allowed(codex_protocol::config_types::ForcedLoginMethod::Chatgpt)
);
assert_eq!(
auth_manager.effective_chatgpt_workspaces(),
Some(vec!["startup".to_string()])
);
Ok(())
}
#[tokio::test]
+1
View File
@@ -403,6 +403,7 @@ async fn run_outbound_router(
}
async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
args.config.auth_config().validate()?;
let channel_capacity = args.channel_capacity.max(1);
let installation_id = resolve_installation_id(&args.config.codex_home).await?;
let (client_tx, mut client_rx) = mpsc::channel::<InProcessClientMessage>(channel_capacity);
+1
View File
@@ -542,6 +542,7 @@ pub async fn run_main_with_transport_options(
})?
}
};
config.auth_config().validate()?;
let code_mode_session_provider: Option<Arc<dyn CodeModeSessionProvider>> =
match &runtime_options.code_mode_host_transport {
CodeModeHostTransport::Local => None,
@@ -347,10 +347,10 @@ impl AccountRequestProcessor {
return Err(self.external_auth_active_error());
}
if matches!(
self.config.forced_login_method,
Some(ForcedLoginMethod::Chatgpt)
) {
if !self
.auth_manager
.is_login_method_allowed(ForcedLoginMethod::Api)
{
return Err(invalid_request(
"API key login is disabled. Use ChatGPT login instead.",
));
@@ -402,10 +402,10 @@ impl AccountRequestProcessor {
if self.auth_manager.is_external_chatgpt_auth_active() {
return Err(self.external_auth_active_error());
}
if matches!(
self.config.forced_login_method,
Some(ForcedLoginMethod::Chatgpt)
) {
if !self
.auth_manager
.is_login_method_allowed(ForcedLoginMethod::Api)
{
return Err(invalid_request(
"Amazon Bedrock login is disabled. Use ChatGPT login instead.",
));
@@ -463,7 +463,10 @@ impl AccountRequestProcessor {
return Err(self.external_auth_active_error());
}
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
if !self
.auth_manager
.is_login_method_allowed(ForcedLoginMethod::Chatgpt)
{
return Err(invalid_request(
"ChatGPT login is disabled. Use API key login instead.",
));
@@ -476,7 +479,7 @@ impl AccountRequestProcessor {
..LoginServerOptions::new(
config.codex_home.to_path_buf(),
oauth_client_id(),
config.forced_chatgpt_workspace_id.clone(),
self.auth_manager.effective_chatgpt_workspaces(),
config.cli_auth_credentials_store_mode,
config.auth_keyring_backend_kind(),
config.auth_route_config(),
@@ -739,10 +742,10 @@ impl AccountRequestProcessor {
chatgpt_account_id: String,
chatgpt_plan_type: Option<String>,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
if matches!(
self.config.forced_login_method,
Some(ForcedLoginMethod::Api)
) {
if !self
.auth_manager
.is_login_method_allowed(ForcedLoginMethod::Chatgpt)
{
return Err(invalid_request(
"External ChatGPT auth is disabled. Use API key login instead.",
));
@@ -756,7 +759,7 @@ impl AccountRequestProcessor {
}
}
if let Some(expected_workspaces) = self.config.forced_chatgpt_workspace_id.as_deref()
if let Some(expected_workspaces) = self.auth_manager.effective_chatgpt_workspaces()
&& !expected_workspaces.contains(&chatgpt_account_id)
{
return Err(invalid_request(format!(
@@ -31,3 +31,36 @@ foo = "bar"
Ok(())
}
#[test]
fn managed_auth_requirements_fail_closed_for_standalone_app_server() -> Result<()> {
for requirements in [
"allowed_login_methods = []\n",
"allowed_login_methods = [\"chatgpt\"]\nallowed_chatgpt_workspaces = []\n",
] {
let codex_home = TempDir::new()?;
std::fs::write(codex_home.path().join("requirements.toml"), requirements)?;
let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex-app-server")?)
.env("CODEX_HOME", codex_home.path())
.env(
"CODEX_APP_SERVER_MANAGED_CONFIG_PATH",
codex_home.path().join("managed_config.toml"),
)
.args(["--listen", "off"])
.output()?;
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr)?;
assert!(
stderr.contains("authentication requirements do not permit any usable login method"),
"expected managed authentication error in stderr, got: {stderr}"
);
assert!(
!stderr.contains("using defaults"),
"managed authentication requirements must not fall back to defaults"
);
}
Ok(())
}
@@ -356,6 +356,51 @@ async fn logout_account_succeeds_when_config_reload_fails() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn startup_enforces_local_auth_requirements_before_cloud_fetch() -> Result<()> {
let codex_home = TempDir::new()?;
let mock_server = MockServer::start().await;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())),
..Default::default()
},
)?;
std::fs::write(
codex_home.path().join("requirements.toml"),
"allowed_login_methods = [\"api\"]\n",
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.plan_type("enterprise")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123")
.account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
assert!(
mock_server
.received_requests()
.await
.expect("recorded requests")
.is_empty(),
"disallowed ChatGPT auth must not fetch cloud requirements"
);
assert_eq!(read_account(&mut mcp).await?.account, None);
Ok(())
}
#[tokio::test]
async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
let codex_home = TempDir::new()?;
+2 -22
View File
@@ -1,9 +1,8 @@
use codex_cloud_config::cloud_config_bundle_loader_for_storage;
use codex_config::CloudConfigBundleLoader;
use codex_config::ConfigLoadOptions;
use codex_core::config::bootstrap_auth_config;
use codex_core::config::load_config_toml_with_layer_stack;
use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind;
use codex_core::config::resolve_bootstrap_auth_route_config;
use codex_utils_absolute_path::AbsolutePathBuf;
use toml::Value as TomlValue;
@@ -41,28 +40,9 @@ pub(super) async fn bootstrap_cloud_config_bundle(
},
)
.await?;
let bootstrap_config_toml = &bootstrap_config.config_toml;
let auth_route_config = resolve_bootstrap_auth_route_config(
bootstrap_config_toml,
bootstrap_config
.config_layer_stack
.requirements()
.feature_requirements
.as_ref(),
)?;
Ok(cloud_config_bundle_loader_for_storage(
codex_home.to_path_buf(),
bootstrap_auth_config(codex_home.as_path(), &bootstrap_config)?,
/*enable_codex_api_key_env*/ false,
bootstrap_config_toml
.cli_auth_credentials_store
.unwrap_or_default(),
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
bootstrap_config_toml
.chatgpt_base_url
.clone()
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()),
auth_route_config,
)
.await)
}
+41 -25
View File
@@ -12,7 +12,6 @@ use codex_core::config::Config;
use codex_login::AuthKeyringBackendKind;
use codex_login::AuthRouteConfig;
use codex_login::CLIENT_ID;
use codex_login::CodexAuth;
use codex_login::ServerOptions;
use codex_login::login_with_access_token;
use codex_login::login_with_api_key;
@@ -169,15 +168,18 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) ->
let _login_log_guard = init_login_file_logging(&config);
tracing::info!("starting browser login flow");
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
if !config
.auth_config()
.is_login_method_allowed(ForcedLoginMethod::Chatgpt)
{
eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}");
std::process::exit(1);
}
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
let effective_chatgpt_workspaces = config.auth_config().effective_chatgpt_workspaces();
match login_with_chatgpt(
config.codex_home.to_path_buf(),
forced_chatgpt_workspace_id,
effective_chatgpt_workspaces,
config.cli_auth_credentials_store_mode,
config.auth_keyring_backend_kind(),
config.auth_route_config(),
@@ -203,7 +205,10 @@ pub async fn run_login_with_api_key(
let _login_log_guard = init_login_file_logging(&config);
tracing::info!("starting api key login flow");
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) {
if !config
.auth_config()
.is_login_method_allowed(ForcedLoginMethod::Api)
{
eprintln!("{API_KEY_LOGIN_DISABLED_MESSAGE}");
std::process::exit(1);
}
@@ -233,17 +238,21 @@ pub async fn run_login_with_access_token(
let _login_log_guard = init_login_file_logging(&config);
tracing::info!("starting access token login flow");
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
if !config
.auth_config()
.is_login_method_allowed(ForcedLoginMethod::Chatgpt)
{
eprintln!("{ACCESS_TOKEN_LOGIN_DISABLED_MESSAGE}");
std::process::exit(1);
}
let auth_route_config = config.auth_route_config();
let effective_chatgpt_workspaces = config.auth_config().effective_chatgpt_workspaces();
match login_with_access_token(
&config.codex_home,
&access_token,
config.cli_auth_credentials_store_mode,
config.forced_chatgpt_workspace_id.as_deref(),
effective_chatgpt_workspaces.as_deref(),
Some(&config.chatgpt_base_url),
config.auth_keyring_backend_kind(),
&auth_route_config,
@@ -311,7 +320,10 @@ pub async fn run_login_with_device_code(
let config = load_config_or_exit(cli_config_overrides).await;
let _login_log_guard = init_login_file_logging(&config);
tracing::info!("starting device code login flow");
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
if !config
.auth_config()
.is_login_method_allowed(ForcedLoginMethod::Chatgpt)
{
eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}");
std::process::exit(1);
}
@@ -323,11 +335,11 @@ pub async fn run_login_with_device_code(
&auth_route_config,
)
.await;
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
let effective_chatgpt_workspaces = config.auth_config().effective_chatgpt_workspaces();
let mut opts = ServerOptions::new(
config.codex_home.to_path_buf(),
client_id.unwrap_or(CLIENT_ID.to_string()),
forced_chatgpt_workspace_id,
effective_chatgpt_workspaces,
config.cli_auth_credentials_store_mode,
config.auth_keyring_backend_kind(),
auth_route_config,
@@ -359,7 +371,10 @@ pub async fn run_login_with_device_code_fallback_to_browser(
let config = load_config_or_exit(cli_config_overrides).await;
let _login_log_guard = init_login_file_logging(&config);
tracing::info!("starting login flow with device code fallback");
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
if !config
.auth_config()
.is_login_method_allowed(ForcedLoginMethod::Chatgpt)
{
eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}");
std::process::exit(1);
}
@@ -372,11 +387,11 @@ pub async fn run_login_with_device_code_fallback_to_browser(
)
.await;
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
let effective_chatgpt_workspaces = config.auth_config().effective_chatgpt_workspaces();
let mut opts = ServerOptions::new(
config.codex_home.to_path_buf(),
client_id.unwrap_or(CLIENT_ID.to_string()),
forced_chatgpt_workspace_id,
effective_chatgpt_workspaces,
config.cli_auth_credentials_store_mode,
config.auth_keyring_backend_kind(),
auth_route_config,
@@ -423,16 +438,11 @@ pub async fn run_login_with_device_code_fallback_to_browser(
pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides).await;
let auth_route_config = config.auth_route_config();
match CodexAuth::from_auth_storage(
&config.codex_home,
config.cli_auth_credentials_store_mode,
Some(&config.chatgpt_base_url),
config.auth_keyring_backend_kind(),
&auth_route_config,
)
.await
match config
.auth_config()
.load_auth(/*enable_codex_api_key_env*/ false)
.await
{
Ok(Some(auth)) => match auth.auth_mode() {
AuthMode::ApiKey => match auth.get_token() {
@@ -469,8 +479,8 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
eprintln!("Not logged in");
std::process::exit(1);
}
Err(e) => {
eprintln!("Error checking login status: {e}");
Err(err) => {
eprintln!("Error checking login status: {err}");
std::process::exit(1);
}
}
@@ -513,7 +523,13 @@ async fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config
};
match Config::load_with_cli_overrides(cli_overrides).await {
Ok(config) => config,
Ok(config) => match config.auth_config().validate() {
Ok(()) => config,
Err(e) => {
eprintln!("Error loading configuration: {e}");
std::process::exit(1);
}
},
Err(e) => {
eprintln!("Error loading configuration: {e}");
std::process::exit(1);
+11 -8
View File
@@ -1821,16 +1821,19 @@ async fn load_exec_server_remote_auth_provider(
use_agent_identity_auth: bool,
) -> anyhow::Result<codex_api::SharedAuthProvider> {
if use_agent_identity_auth {
let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| {
read_codex_access_token_from_env().ok_or_else(|| {
anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set")
})?;
let auth_route_config = config.auth_route_config();
let auth = CodexAuth::from_agent_identity_jwt(
&agent_identity_jwt,
Some(&config.chatgpt_base_url),
&auth_route_config,
)
.await?;
let auth = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false)
.await
.auth()
.await
.ok_or_else(|| anyhow::anyhow!("Agent Identity authentication is unavailable"))?;
if !matches!(auth, CodexAuth::AgentIdentity(_)) {
anyhow::bail!(
"CODEX_ACCESS_TOKEN did not provide permitted Agent Identity authentication"
);
}
return Ok(codex_model_provider::auth_provider_from_auth(&auth));
}
+3 -23
View File
@@ -6,10 +6,9 @@ use codex_config::ConfigLoadOptions;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::LoaderOverrides;
use codex_core::config::bootstrap_auth_config;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_toml_with_layer_stack;
use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind;
use codex_core::config::resolve_bootstrap_auth_route_config;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
@@ -34,29 +33,10 @@ pub(super) async fn load_mcp_config(
)
.await
.context("failed to load bootstrap configuration")?;
let bootstrap_config_toml = &bootstrap_config.config_toml;
let auth_route_config = resolve_bootstrap_auth_route_config(
bootstrap_config_toml,
bootstrap_config
.config_layer_stack
.requirements()
.feature_requirements
.as_ref(),
)
.context("failed to resolve cloud configuration authentication")?;
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
codex_home.to_path_buf(),
bootstrap_auth_config(codex_home.as_path(), &bootstrap_config)
.context("failed to resolve cloud configuration authentication")?,
/*enable_codex_api_key_env*/ false,
bootstrap_config_toml
.cli_auth_credentials_store
.unwrap_or_default(),
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)
.context("failed to resolve cloud configuration credential storage")?,
bootstrap_config_toml
.chatgpt_base_url
.clone()
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()),
auth_route_config,
)
.await;
+6 -18
View File
@@ -18,8 +18,7 @@ use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_core_plugins::marketplace::MarketplacePluginSource;
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
use codex_login::CodexAuth;
use codex_login::auth::read_codex_api_key_from_env;
use codex_login::AuthManager;
use codex_plugin::PluginId;
use codex_plugin::validate_plugin_segment;
use codex_protocol::auth::AuthMode;
@@ -600,22 +599,11 @@ async fn load_plugin_command_context(
}
pub(crate) async fn load_cli_auth_mode(config: &Config) -> Option<AuthMode> {
if let Some(api_key) = read_codex_api_key_from_env() {
return Some(CodexAuth::from_api_key(&api_key).api_auth_mode());
}
let auth_route_config = config.auth_route_config();
CodexAuth::from_auth_storage(
&config.codex_home,
config.cli_auth_credentials_store_mode,
Some(&config.chatgpt_base_url),
config.auth_keyring_backend_kind(),
&auth_route_config,
)
.await
.ok()
.flatten()
.map(|auth| auth.api_auth_mode())
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true)
.await
.auth()
.await
.map(|auth| auth.api_auth_mode())
}
struct PluginSelection {
+15
View File
@@ -66,6 +66,21 @@ fn login_with_api_key_reads_stdin_and_writes_auth_json() -> Result<()> {
Ok(())
}
#[test]
fn login_status_reports_auth_storage_errors() -> Result<()> {
let codex_home = TempDir::new()?;
write_file_auth_config(codex_home.path())?;
std::fs::write(codex_home.path().join("auth.json"), "{invalid json")?;
codex_command(codex_home.path())?
.args(["login", "status"])
.assert()
.failure()
.stderr(contains("Error checking login status:"));
Ok(())
}
#[test]
fn login_with_access_token_rejects_invalid_jwt() -> Result<()> {
let codex_home = TempDir::new()?;
+10 -19
View File
@@ -4,11 +4,9 @@ use crate::service::CloudConfigBundleService;
use codex_config::CloudConfigBundleLoadError;
use codex_config::CloudConfigBundleLoadErrorCode;
use codex_config::CloudConfigBundleLoader;
use codex_config::types::AuthCredentialsStoreMode;
use codex_http_client::HttpClientFactory;
use codex_login::AuthKeyringBackendKind;
use codex_login::AuthConfig;
use codex_login::AuthManager;
use codex_login::AuthRouteConfig;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
@@ -59,24 +57,17 @@ pub fn cloud_config_bundle_loader(
}
pub async fn cloud_config_bundle_loader_for_storage(
codex_home: PathBuf,
auth_config: AuthConfig,
enable_codex_api_key_env: bool,
credentials_store_mode: AuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
chatgpt_base_url: String,
auth_route_config: AuthRouteConfig,
) -> CloudConfigBundleLoader {
let http_client_factory = auth_route_config.http_client_factory().clone();
let auth_manager = AuthManager::shared(
codex_home.clone(),
enable_codex_api_key_env,
credentials_store_mode,
/*forced_chatgpt_workspace_id*/ None,
Some(chatgpt_base_url.clone()),
keyring_backend_kind,
auth_route_config,
)
.await;
let codex_home = auth_config.codex_home.clone();
let chatgpt_base_url = auth_config
.chatgpt_base_url
.clone()
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string());
let http_client_factory = auth_config.auth_route_config.http_client_factory().clone();
let auth_manager =
AuthManager::shared_from_auth_config(auth_config, enable_codex_api_key_env).await;
cloud_config_bundle_loader(
auth_manager,
chatgpt_base_url,
+6 -11
View File
@@ -7,6 +7,7 @@ use codex_core::config::Config;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_login::AuthManager;
use std::sync::Arc;
pub fn set_user_agent_suffix(suffix: &str) {
if let Ok(mut guard) = codex_login::default_client::USER_AGENT_SUFFIX.lock() {
@@ -45,7 +46,7 @@ pub fn normalize_base_url(input: &str) -> String {
pub async fn load_auth_manager(
chatgpt_base_url: Option<String>,
) -> (Option<AuthManager>, HttpClientFactory) {
) -> (Option<Arc<AuthManager>>, HttpClientFactory) {
// TODO: pass in cli overrides once cloud tasks properly support them.
let config = match Config::load_with_cli_overrides(Vec::new()).await {
Ok(config) => config,
@@ -58,16 +59,10 @@ pub async fn load_auth_manager(
}
};
let http_client_factory = config.http_client_factory();
let auth_manager = AuthManager::new(
config.codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
config.cli_auth_credentials_store_mode,
config.forced_chatgpt_workspace_id.clone(),
chatgpt_base_url.or(Some(config.chatgpt_base_url.clone())),
config.auth_keyring_backend_kind(),
config.auth_route_config(),
)
.await;
let mut auth_config = config.auth_config();
auth_config.chatgpt_base_url = chatgpt_base_url.or(Some(config.chatgpt_base_url.clone()));
let auth_manager =
AuthManager::shared_from_auth_config(auth_config, /*enable_codex_api_key_env*/ false).await;
(Some(auth_manager), http_client_factory)
}
+61
View File
@@ -0,0 +1,61 @@
use codex_protocol::config_types::ForcedLoginMethod;
/// Authentication restrictions supplied by locally managed requirements.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ManagedAuthPolicy {
pub allowed_login_methods: Option<Vec<ForcedLoginMethod>>,
pub allowed_chatgpt_workspaces: Option<Vec<String>>,
}
impl ManagedAuthPolicy {
pub fn allows_login_method(
&self,
method: ForcedLoginMethod,
forced_login_method: Option<ForcedLoginMethod>,
forced_workspaces: Option<&[String]>,
) -> bool {
forced_login_method.is_none_or(|forced| forced == method)
&& self
.allowed_login_methods
.as_ref()
.is_none_or(|allowed| allowed.contains(&method))
&& (method != ForcedLoginMethod::Chatgpt
|| self
.effective_chatgpt_workspaces(forced_workspaces)
.is_none_or(|workspaces| !workspaces.is_empty()))
}
pub fn allowed_login_methods(
&self,
forced_login_method: Option<ForcedLoginMethod>,
forced_workspaces: Option<&[String]>,
) -> Vec<ForcedLoginMethod> {
[ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt]
.into_iter()
.filter(|method| {
self.allows_login_method(*method, forced_login_method, forced_workspaces)
})
.collect()
}
pub fn effective_chatgpt_workspaces(
&self,
forced_workspaces: Option<&[String]>,
) -> Option<Vec<String>> {
match (
forced_workspaces,
self.allowed_chatgpt_workspaces.as_deref(),
) {
(Some(forced), Some(allowed)) => Some(
forced
.iter()
.filter(|workspace| allowed.contains(workspace))
.cloned()
.collect(),
),
(Some(forced), None) => Some(forced.to_vec()),
(None, Some(allowed)) => Some(allowed.to_vec()),
(None, None) => None,
}
}
}
+59 -1
View File
@@ -1,4 +1,5 @@
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::models::PermissionProfile;
@@ -19,6 +20,7 @@ use super::requirements_exec_policy::RequirementsExecPolicy;
use super::requirements_exec_policy::RequirementsExecPolicyToml;
use crate::Constrained;
use crate::ConstraintError;
use crate::ManagedAuthPolicy;
use crate::ManagedHooksRequirementsToml;
use crate::config_toml::ConfigToml;
use crate::mcp_requirements::McpServerRequirement;
@@ -147,6 +149,8 @@ impl<T> std::ops::DerefMut for ConstrainedWithSource<T> {
/// normalization.
#[derive(Debug, Clone, PartialEq)]
pub struct ConfigRequirements {
pub allowed_login_methods: Option<Sourced<Vec<ForcedLoginMethod>>>,
pub allowed_chatgpt_workspaces: Option<Sourced<Vec<String>>>,
pub sqlite_home: Option<Sourced<AbsolutePathBuf>>,
pub log_dir: Option<Sourced<AbsolutePathBuf>>,
pub model_catalog_json: Option<Sourced<AbsolutePathBuf>>,
@@ -181,6 +185,8 @@ pub struct ConfigRequirements {
impl Default for ConfigRequirements {
fn default() -> Self {
Self {
allowed_login_methods: None,
allowed_chatgpt_workspaces: None,
sqlite_home: None,
log_dir: None,
model_catalog_json: None,
@@ -230,6 +236,24 @@ impl Default for ConfigRequirements {
}
impl ConfigRequirements {
pub fn managed_auth_policy(&self) -> ManagedAuthPolicy {
ManagedAuthPolicy {
allowed_login_methods: self
.allowed_login_methods
.as_ref()
.map(|allowed| allowed.value.clone()),
allowed_chatgpt_workspaces: self.allowed_chatgpt_workspaces.as_ref().map(|allowed| {
allowed
.value
.iter()
.map(|workspace| workspace.trim())
.filter(|workspace| !workspace.is_empty())
.map(str::to_string)
.collect()
}),
}
}
pub fn exec_policy_source(&self) -> Option<&RequirementSource> {
self.exec_policy.as_ref().map(|policy| &policy.source)
}
@@ -873,6 +897,8 @@ pub(crate) fn merge_app_requirements_descending(
/// Base config deserialized from system `requirements.toml` or MDM.
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
pub struct ConfigRequirementsToml {
pub allowed_login_methods: Option<Vec<ForcedLoginMethod>>,
pub allowed_chatgpt_workspaces: Option<Vec<String>>,
pub sqlite_home: Option<AbsolutePathBuf>,
pub log_dir: Option<AbsolutePathBuf>,
pub model_catalog_json: Option<AbsolutePathBuf>,
@@ -964,6 +990,8 @@ impl<T> std::ops::Deref for Sourced<T> {
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ConfigRequirementsWithSources {
pub allowed_login_methods: Option<Sourced<Vec<ForcedLoginMethod>>>,
pub allowed_chatgpt_workspaces: Option<Sourced<Vec<String>>>,
pub sqlite_home: Option<Sourced<AbsolutePathBuf>>,
pub log_dir: Option<Sourced<AbsolutePathBuf>>,
pub model_catalog_json: Option<Sourced<AbsolutePathBuf>>,
@@ -1015,6 +1043,8 @@ impl ConfigRequirementsWithSources {
// Destructure without `..` so adding fields to `ConfigRequirementsToml`
// forces this merge logic to be updated.
let ConfigRequirementsToml {
allowed_login_methods: _,
allowed_chatgpt_workspaces: _,
sqlite_home: _,
log_dir: _,
model_catalog_json: _,
@@ -1061,6 +1091,8 @@ impl ConfigRequirementsWithSources {
other,
source,
{
allowed_login_methods,
allowed_chatgpt_workspaces,
sqlite_home,
log_dir,
model_catalog_json,
@@ -1104,6 +1136,8 @@ impl ConfigRequirementsWithSources {
pub fn into_toml(self) -> ConfigRequirementsToml {
let ConfigRequirementsWithSources {
allowed_login_methods,
allowed_chatgpt_workspaces,
sqlite_home,
log_dir,
model_catalog_json,
@@ -1136,6 +1170,8 @@ impl ConfigRequirementsWithSources {
guardian_policy_config,
} = self;
ConfigRequirementsToml {
allowed_login_methods: allowed_login_methods.map(|sourced| sourced.value),
allowed_chatgpt_workspaces: allowed_chatgpt_workspaces.map(|sourced| sourced.value),
sqlite_home: sqlite_home.map(|sourced| sourced.value),
log_dir: log_dir.map(|sourced| sourced.value),
model_catalog_json: model_catalog_json.map(|sourced| sourced.value),
@@ -1235,7 +1271,9 @@ impl ConfigRequirementsToml {
}
pub fn is_empty(&self) -> bool {
self.sqlite_home.is_none()
self.allowed_login_methods.is_none()
&& self.allowed_chatgpt_workspaces.is_none()
&& self.sqlite_home.is_none()
&& self.log_dir.is_none()
&& self.model_catalog_json.is_none()
&& self.check_for_update_on_startup.is_none()
@@ -1416,6 +1454,8 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
// defaults also remain there because they are initialization values,
// not runtime constraints.
let ConfigRequirementsWithSources {
allowed_login_methods,
allowed_chatgpt_workspaces,
sqlite_home,
log_dir,
model_catalog_json,
@@ -1744,6 +1784,8 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
});
let guardian_policy_config_source = guardian_policy_config.map(|sourced| sourced.source);
Ok(ConfigRequirements {
allowed_login_methods,
allowed_chatgpt_workspaces,
sqlite_home,
log_dir,
model_catalog_json,
@@ -1898,6 +1940,8 @@ mod tests {
fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources {
let ConfigRequirementsToml {
allowed_login_methods,
allowed_chatgpt_workspaces,
sqlite_home,
log_dir,
model_catalog_json,
@@ -1931,6 +1975,10 @@ mod tests {
guardian_policy_config,
} = toml;
ConfigRequirementsWithSources {
allowed_login_methods: allowed_login_methods
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_chatgpt_workspaces: allowed_chatgpt_workspaces
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
sqlite_home: sqlite_home.map(|value| Sourced::new(value, RequirementSource::Unknown)),
log_dir: log_dir.map(|value| Sourced::new(value, RequirementSource::Unknown)),
model_catalog_json: model_catalog_json
@@ -2201,6 +2249,8 @@ mod tests {
// Intentionally constructed without `..Default::default()` so adding a new field to
// `ConfigRequirementsToml` forces this test to be updated.
let other = ConfigRequirementsToml {
allowed_login_methods: Some(vec![ForcedLoginMethod::Chatgpt]),
allowed_chatgpt_workspaces: Some(vec!["managed-workspace".to_string()]),
sqlite_home: Some(sqlite_home.clone()),
log_dir: Some(log_dir.clone()),
model_catalog_json: Some(model_catalog_json.clone()),
@@ -2239,6 +2289,14 @@ mod tests {
assert_eq!(
target,
ConfigRequirementsWithSources {
allowed_login_methods: Some(Sourced::new(
vec![ForcedLoginMethod::Chatgpt],
source.clone(),
)),
allowed_chatgpt_workspaces: Some(Sourced::new(
vec!["managed-workspace".to_string()],
source.clone(),
)),
sqlite_home: Some(Sourced::new(sqlite_home, source.clone())),
log_dir: Some(Sourced::new(log_dir, source.clone())),
model_catalog_json: Some(Sourced::new(model_catalog_json, source.clone())),
+2
View File
@@ -1,3 +1,4 @@
mod auth_policy;
mod cloud_config_bundle;
mod cloud_config_layers;
mod config_layer_source;
@@ -34,6 +35,7 @@ pub mod types;
pub const CONFIG_TOML_FILE: &str = "config.toml";
pub use auth_policy::ManagedAuthPolicy;
pub use cloud_config_bundle::CloudConfigBundle;
pub use cloud_config_bundle::CloudConfigBundleLayers;
pub use cloud_config_bundle::CloudConfigBundleLoadError;
+8 -1
View File
@@ -192,7 +192,14 @@ pub async fn load_config_layers_state(
requirements_layers.extend(managed_preferences_requirements_layer);
}
let config_requirements_toml = compose_requirements(requirements_layers)?.unwrap_or_default();
let mut config_requirements_toml =
compose_requirements(requirements_layers)?.unwrap_or_default();
// Remote app servers enforce auth policy for their workspaces; do not let local
// requirements reintroduce authentication restrictions for those workspaces.
if overrides.ignore_login_requirements {
config_requirements_toml.allowed_login_methods = None;
config_requirements_toml.allowed_chatgpt_workspaces = None;
}
let thread_config_context = ThreadConfigContext {
thread_id: None,
@@ -65,8 +65,18 @@ impl ComposableRequirementsLayer {
let _guard = base_dir
.as_ref()
.map(|base_dir| AbsolutePathBufGuard::new(base_dir.as_path()));
let regular_toml = parse_layer_toml(&toml, &source)?;
let requirements = parse_layer_requirements(&toml, &source)?;
let mut regular_toml = parse_layer_toml(&toml, &source)?;
// These fields can only be set locally; ignore them before validating cloud policy.
if matches!(source, RequirementSource::EnterpriseManaged { .. }) {
remove_top_level_field(&mut regular_toml, "allowed_login_methods");
remove_top_level_field(&mut regular_toml, "allowed_chatgpt_workspaces");
}
let requirements = parse_layer_requirements(
&RequirementsLayerToml::Value(regular_toml.clone()),
&source,
)?;
(regular_toml, requirements)
};
@@ -206,6 +206,8 @@ fn populate_merged_regular_fields_with_sources(
// Destructure without `..` so every new requirements field must choose
// whether it belongs in the regular TOML merge path or in a special merger.
let ConfigRequirementsToml {
allowed_login_methods,
allowed_chatgpt_workspaces,
sqlite_home,
log_dir,
model_catalog_json,
@@ -239,6 +241,8 @@ fn populate_merged_regular_fields_with_sources(
guardian_policy_config,
} = requirements;
set_sourced!(allowed_login_methods, &["allowed_login_methods"]);
set_sourced!(allowed_chatgpt_workspaces, &["allowed_chatgpt_workspaces"]);
set_sourced!(sqlite_home, &["sqlite_home"]);
set_sourced!(log_dir, &["log_dir"]);
set_sourced!(model_catalog_json, &["model_catalog_json"]);
@@ -57,6 +57,25 @@ fn empty_layers_compose_to_none() {
assert_eq!(composed, None);
}
#[test]
fn cloud_auth_requirements_do_not_override_local_or_discard_other_policy() {
let local = RequirementsLayerEntry::from_toml(
RequirementSource::Unknown,
"allowed_login_methods = [\"api\"]",
);
let cloud = layer(
"req_cloud",
"Cloud policy",
"allowed_login_methods = [\"saml\"]\nallowed_chatgpt_workspaces = \"invalid\"\nallow_login_shell = false",
);
assert_eq!(
compose(vec![local, cloud]).expect("cloud auth cannot invalidate enterprise policy"),
Some(expected_requirements(
"allowed_login_methods = [\"api\"]\nallow_login_shell = false"
))
);
}
#[test]
fn top_level_values_use_toml_priority() {
let composed = compose(vec![
+3
View File
@@ -47,6 +47,8 @@ pub struct LoaderOverrides {
pub system_config_path: Option<PathBuf>,
pub system_requirements_path: Option<PathBuf>,
pub ignore_managed_requirements: bool,
/// Remote app servers own their authentication policy independently.
pub ignore_login_requirements: bool,
pub ignore_user_config: bool,
pub ignore_user_and_project_exec_policy_rules: bool,
//TODO(gt): Add a macos_ prefix to this field and remove the target_os check.
@@ -68,6 +70,7 @@ impl LoaderOverrides {
system_config_path: Some(base.join("config.toml")),
system_requirements_path: Some(base.join("requirements.toml")),
ignore_managed_requirements: false,
ignore_login_requirements: false,
ignore_user_config: false,
ignore_user_and_project_exec_policy_rules: false,
#[cfg(target_os = "macos")]
+63
View File
@@ -1,11 +1,14 @@
use super::Config;
use super::ConfigTomlLoadResult;
use super::ManagedFeatures;
use super::resolve_bootstrap_auth_route_config;
use codex_config::types::AuthKeyringBackendKind;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
use codex_features::Features;
use codex_login::AuthConfig;
use std::path::Path;
impl Config {
pub fn auth_keyring_backend_kind(&self) -> AuthKeyringBackendKind {
@@ -13,6 +16,66 @@ impl Config {
self.features.enabled(Feature::SecretAuthStorage),
)
}
pub fn auth_config(&self) -> AuthConfig {
AuthConfig {
codex_home: self.codex_home.to_path_buf(),
auth_credentials_store_mode: self.cli_auth_credentials_store_mode,
keyring_backend_kind: self.auth_keyring_backend_kind(),
forced_login_method: self.forced_login_method,
chatgpt_base_url: Some(self.chatgpt_base_url.clone()),
forced_chatgpt_workspace_id: self.forced_chatgpt_workspace_id.clone(),
managed_auth_policy: self.config_layer_stack.requirements().managed_auth_policy(),
auth_route_config: self.auth_route_config(),
}
}
}
/// Builds authentication settings from the locally resolved bootstrap config.
///
/// Use this before fetching cloud requirements, when a full [`Config`] is not
/// yet available. Preserves the configured credential store, keyring backend,
/// ChatGPT base URL, auth routing, and managed login/workspace restrictions.
pub fn bootstrap_auth_config(
codex_home: &Path,
bootstrap_config: &ConfigTomlLoadResult,
) -> std::io::Result<AuthConfig> {
let config = &bootstrap_config.config_toml;
// Empty legacy workspace settings mean unrestricted, not an empty allowlist.
let forced_chatgpt_workspace_id = config
.forced_chatgpt_workspace_id
.clone()
.map(|workspaces| {
workspaces
.into_vec()
.into_iter()
.map(|workspace| workspace.trim().to_string())
.filter(|workspace| !workspace.is_empty())
.collect::<Vec<_>>()
})
.filter(|workspaces| !workspaces.is_empty());
let auth_config = AuthConfig {
codex_home: codex_home.to_path_buf(),
auth_credentials_store_mode: config.cli_auth_credentials_store.unwrap_or_default(),
keyring_backend_kind: resolve_bootstrap_auth_keyring_backend_kind(bootstrap_config)?,
forced_login_method: config.forced_login_method,
chatgpt_base_url: config.chatgpt_base_url.clone(),
forced_chatgpt_workspace_id,
managed_auth_policy: bootstrap_config
.config_layer_stack
.requirements()
.managed_auth_policy(),
auth_route_config: resolve_bootstrap_auth_route_config(
config,
bootstrap_config
.config_layer_stack
.requirements()
.feature_requirements
.as_ref(),
)?,
};
auth_config.validate()?;
Ok(auth_config)
}
/// Resolve the auth keyring backend from a partially loaded bootstrap config.
@@ -6,7 +6,9 @@ use codex_config::FeatureRequirementsToml;
use codex_config::RequirementSource;
use codex_config::Sourced;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ForcedChatgptWorkspaceIds;
use codex_features::FeaturesToml;
use codex_protocol::config_types::ForcedLoginMethod;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -60,6 +62,70 @@ fn resolve_bootstrap_auth_keyring_backend_kind_uses_secret_auth_storage_feature(
Ok(())
}
#[test]
fn managed_auth_restrictions_intersect_workspaces_and_fail_closed() {
let config = ConfigToml {
forced_login_method: None,
forced_chatgpt_workspace_id: Some(ForcedChatgptWorkspaceIds::Multiple(vec![
" denied ".to_string(),
" allowed ".to_string(),
])),
..Default::default()
};
let mut requirements = ConfigRequirements {
allowed_login_methods: Some(Sourced::new(
vec![ForcedLoginMethod::Chatgpt],
RequirementSource::Unknown,
)),
allowed_chatgpt_workspaces: Some(Sourced::new(
vec!["allowed".to_string()],
RequirementSource::Unknown,
)),
..Default::default()
};
let bootstrap_config = ConfigTomlLoadResult {
config_toml: config.clone(),
config_layer_stack: ConfigLayerStack::new(
Vec::new(),
requirements.clone(),
ConfigRequirementsToml::default(),
)
.expect("requirements should stack"),
};
let auth_config = bootstrap_auth_config(Path::new("codex-home"), &bootstrap_config)
.expect("policy should resolve");
assert_eq!(auth_config.forced_login_method, None);
assert!(auth_config.is_login_method_allowed(ForcedLoginMethod::Chatgpt));
assert!(!auth_config.is_login_method_allowed(ForcedLoginMethod::Api));
assert_eq!(
auth_config.forced_chatgpt_workspace_id,
Some(vec!["denied".to_string(), "allowed".to_string()])
);
assert_eq!(
auth_config.effective_chatgpt_workspaces(),
Some(vec!["allowed".to_string()])
);
requirements.allowed_chatgpt_workspaces =
Some(Sourced::new(Vec::new(), RequirementSource::Unknown));
let bootstrap_config = ConfigTomlLoadResult {
config_toml: config,
config_layer_stack: ConfigLayerStack::new(
Vec::new(),
requirements,
ConfigRequirementsToml::default(),
)
.expect("requirements should stack"),
};
assert_eq!(
bootstrap_auth_config(Path::new("codex-home"), &bootstrap_config)
.expect_err("ChatGPT-only policy without an allowed workspace must fail")
.kind(),
std::io::ErrorKind::PermissionDenied
);
}
fn config_toml_load_result(
config_toml: ConfigToml,
feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
+2
View File
@@ -9446,6 +9446,8 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
let fixture = create_test_fixture()?;
let requirements_toml = codex_config::ConfigRequirementsToml {
allowed_login_methods: None,
allowed_chatgpt_workspaces: None,
sqlite_home: None,
log_dir: None,
model_catalog_json: None,
+12 -1
View File
@@ -13,6 +13,7 @@ use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::ConstrainedWithSource;
use codex_config::FeatureRequirementsToml;
use codex_config::ManagedAuthPolicy;
use codex_config::McpServerRequirement;
use codex_config::PluginRequirementsToml;
use codex_config::ProfileV2Name;
@@ -160,6 +161,7 @@ mod requirements;
mod resolved_permission_profile;
#[cfg(test)]
mod schema;
pub use auth_keyring::bootstrap_auth_config;
pub use auth_keyring::resolve_bootstrap_auth_keyring_backend_kind;
pub use codex_config::ConfigLoadOptions;
pub use codex_config::Constrained;
@@ -1337,10 +1339,18 @@ impl AuthManagerConfig for Config {
Config::auth_keyring_backend_kind(self)
}
fn forced_login_method(&self) -> Option<ForcedLoginMethod> {
self.forced_login_method
}
fn forced_chatgpt_workspace_id(&self) -> Option<Vec<String>> {
self.forced_chatgpt_workspace_id.clone()
}
fn managed_auth_policy(&self) -> ManagedAuthPolicy {
self.config_layer_stack.requirements().managed_auth_policy()
}
fn chatgpt_base_url(&self) -> String {
self.chatgpt_base_url.clone()
}
@@ -3198,10 +3208,11 @@ impl Config {
config_layer_stack.requirements(),
&mut startup_warnings,
);
// Destructure every field to ensure ConfigRequirements additions are
// either applied above or handled while constructing the final Config.
let ConfigRequirements {
allowed_login_methods: _,
allowed_chatgpt_workspaces: _,
sqlite_home: _,
log_dir: _,
model_catalog_json: _,
+3 -35
View File
@@ -63,10 +63,9 @@ use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigTomlLoadResult;
use codex_core::config::bootstrap_auth_config;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_toml_with_layer_stack;
use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind;
use codex_core::config::resolve_bootstrap_auth_route_config;
use codex_core::config::resolve_oss_provider;
use codex_core::config::resolve_profile_v2_config_path;
use codex_core::find_thread_meta_by_name_str;
@@ -75,7 +74,6 @@ use codex_core::path_utils;
use codex_core::read_session_meta_line;
use codex_feedback::CodexFeedback;
use codex_git_utils::get_git_repo_root;
use codex_login::AuthConfig;
use codex_login::default_client::set_default_client_residency_requirement;
use codex_login::default_client::set_default_originator;
use codex_login::enforce_login_restrictions;
@@ -342,28 +340,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
)
.await;
let bootstrap_config_toml = &bootstrap_config.config_toml;
let chatgpt_base_url = bootstrap_config_toml
.chatgpt_base_url
.clone()
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string());
let auth_route_config = resolve_bootstrap_auth_route_config(
bootstrap_config_toml,
bootstrap_config
.config_layer_stack
.requirements()
.feature_requirements
.as_ref(),
)?;
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
codex_home.to_path_buf(),
bootstrap_auth_config(&codex_home, &bootstrap_config)?,
/*enable_codex_api_key_env*/ false,
bootstrap_config_toml
.cli_auth_credentials_store
.unwrap_or_default(),
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
chatgpt_base_url,
auth_route_config,
)
.await;
let run_cli_overrides = cli_kv_overrides.clone();
@@ -480,18 +459,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
set_default_client_residency_requirement(config.enforce_residency.value());
let auth_route_config = config.auth_route_config();
if let Err(err) = enforce_login_restrictions(&AuthConfig {
codex_home: config.codex_home.to_path_buf(),
auth_credentials_store_mode: config.cli_auth_credentials_store_mode,
keyring_backend_kind: config.auth_keyring_backend_kind(),
forced_login_method: config.forced_login_method,
forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(),
chatgpt_base_url: Some(config.chatgpt_base_url.clone()),
auth_route_config,
})
.await
{
if let Err(err) = enforce_login_restrictions(&config.auth_config()).await {
eprintln!("{err}");
std::process::exit(1);
}
+204
View File
@@ -141,6 +141,33 @@ async fn login_with_access_token_writes_agent_identity_jwt() {
server.verify().await;
}
#[tokio::test]
async fn login_with_access_token_rejects_agent_identity_workspace_mismatch() {
let dir = tempdir().unwrap();
let record = agent_identity_record(WORKSPACE_ID_DISALLOWED);
let agent_identity =
signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity");
let server = MockServer::start().await;
let chatgpt_base_url = format!("{}/backend-api", server.uri());
let allowed_workspaces = [WORKSPACE_ID_ALLOWED.to_string()];
let err = super::login_with_access_token(
dir.path(),
&agent_identity,
AuthCredentialsStoreMode::File,
Some(&allowed_workspaces),
Some(&chatgpt_base_url),
AuthKeyringBackendKind::default(),
&crate::test_support::transport_default_auth_route_config(),
)
.await
.expect_err("agent identity workspace mismatch should fail");
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
assert!(!get_auth_file(dir.path()).exists());
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result<()> {
@@ -178,6 +205,7 @@ async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
Some(&chatgpt_base_url),
AuthKeyringBackendKind::Direct,
@@ -366,6 +394,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -448,6 +477,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result<
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -490,6 +520,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow:
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -556,6 +587,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -617,6 +649,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible()
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -673,6 +706,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -816,6 +850,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -878,6 +913,7 @@ async fn loads_api_key_from_auth_json() {
dir.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -974,6 +1010,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() {
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -1343,6 +1380,7 @@ async fn build_config(
keyring_backend_kind: AuthKeyringBackendKind::Direct,
forced_login_method,
forced_chatgpt_workspace_id,
managed_auth_policy: ManagedAuthPolicy::default(),
chatgpt_base_url: None,
auth_route_config: crate::test_support::transport_default_auth_route_config(),
}
@@ -1423,6 +1461,7 @@ async fn load_auth_reads_access_token_from_env() {
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
Some(&chatgpt_base_url),
AuthKeyringBackendKind::Direct,
@@ -1471,6 +1510,7 @@ async fn load_auth_reads_personal_access_token_from_env() {
codex_home.path(),
/*enable_codex_api_key_env*/ false,
auth_credentials_store_mode,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
@@ -1644,6 +1684,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() {
codex_home.path(),
/*enable_codex_api_key_env*/ true,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -1687,6 +1728,162 @@ async fn enforce_login_restrictions_logs_out_for_method_mismatch() {
);
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn auth_manager_rejects_disallowed_stored_and_external_auth() {
let codex_home = tempdir().unwrap();
let _access_token_guard = remove_access_token_env_var();
login_with_api_key(
codex_home.path(),
"sk-test",
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)
.expect("seed api key");
let mut config = build_config(
codex_home.path(),
/*forced_login_method*/ None,
/*forced_chatgpt_workspace_id*/ None,
)
.await;
config.managed_auth_policy.allowed_login_methods = Some(vec![ForcedLoginMethod::Chatgpt]);
let manager =
AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await;
assert_eq!(manager.auth().await, None);
assert!(
manager
.set_external_auth(Arc::new(StaticExternalAuth(CodexAuth::from_api_key(
"sk-external",
))))
.await
.is_err(),
"external auth cannot bypass managed login policy"
);
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn api_only_policy_rejects_access_tokens_before_hydration() {
let codex_home = tempdir().unwrap();
let server = MockServer::start().await;
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-rejected");
let mut config = build_config(
codex_home.path(),
/*forced_login_method*/ None,
/*forced_chatgpt_workspace_id*/ None,
)
.await;
config.managed_auth_policy.allowed_login_methods = Some(vec![ForcedLoginMethod::Api]);
let manager =
AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await;
assert_eq!(manager.auth().await, None);
assert!(
server
.received_requests()
.await
.expect("inspect auth requests")
.is_empty(),
"rejected access tokens must not call whoami or register Agent Identity"
);
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn workspace_policy_rejects_agent_identity_before_hydration() {
let codex_home = tempdir().unwrap();
let server = MockServer::start().await;
let record = agent_identity_record(WORKSPACE_ID_DISALLOWED);
let agent_identity =
signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity");
let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri());
let _access_token_reset = remove_access_token_env_var();
let access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity);
let mut config = build_config(
codex_home.path(),
/*forced_login_method*/ None,
/*forced_chatgpt_workspace_id*/ None,
)
.await;
config.managed_auth_policy.allowed_chatgpt_workspaces =
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]);
let manager =
AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await;
assert_eq!(manager.auth().await, None);
drop(access_token_guard);
for stored_agent_identity in [
AgentIdentityStorage::Jwt(agent_identity),
AgentIdentityStorage::Record(record),
] {
save_auth(
codex_home.path(),
&AuthDotJson {
auth_mode: Some(AuthMode::AgentIdentity),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: Some(stored_agent_identity),
personal_access_token: None,
bedrock_api_key: None,
},
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::Direct,
)
.expect("store agent identity");
let mut config = build_config(
codex_home.path(),
/*forced_login_method*/ None,
/*forced_chatgpt_workspace_id*/ None,
)
.await;
config.managed_auth_policy.allowed_chatgpt_workspaces =
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]);
let manager =
AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await;
assert_eq!(manager.auth().await, None);
}
assert!(
server.received_requests().await.unwrap().is_empty(),
"rejected agent identities must not fetch JWKS or register"
);
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn workspace_policy_checks_the_selected_request_account() {
let codex_home = tempdir().unwrap();
let _access_token_guard = remove_access_token_env_var();
write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: Some("pro".to_string()),
chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()),
},
codex_home.path(),
)
.expect("seed ChatGPT credentials");
let auth_path = codex_home.path().join("auth.json");
let mut stored: serde_json::Value =
serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap();
stored["tokens"]["account_id"] = json!(WORKSPACE_ID_DISALLOWED);
std::fs::write(&auth_path, serde_json::to_vec(&stored).unwrap()).unwrap();
let config = build_config(
codex_home.path(),
/*forced_login_method*/ None,
Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
)
.await;
let manager =
AuthManager::shared_from_auth_config(config, /*enable_codex_api_key_env*/ false).await;
assert_eq!(manager.auth().await, None);
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() {
@@ -1756,6 +1953,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace
keyring_backend_kind: AuthKeyringBackendKind::default(),
forced_login_method: None,
forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
managed_auth_policy: ManagedAuthPolicy::default(),
chatgpt_base_url: None,
auth_route_config: crate::test_support::transport_default_auth_route_config(),
};
@@ -1880,6 +2078,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat
keyring_backend_kind: AuthKeyringBackendKind::Direct,
forced_login_method: None,
forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
managed_auth_policy: ManagedAuthPolicy::default(),
chatgpt_base_url: Some(chatgpt_base_url),
auth_route_config: crate::test_support::transport_default_auth_route_config(),
};
@@ -2162,6 +2361,7 @@ async fn plan_type_maps_known_plan() {
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -2194,6 +2394,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() {
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -2229,6 +2430,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() {
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -2264,6 +2466,7 @@ async fn plan_type_maps_unknown_to_unknown() {
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
@@ -2296,6 +2499,7 @@ async fn missing_plan_type_maps_to_unknown() {
codex_home.path(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::Direct,
+293 -45
View File
@@ -56,6 +56,7 @@ use crate::outbound_proxy::AuthRouteConfig;
use crate::token_data::TokenData;
use crate::token_data::parse_chatgpt_jwt_claims;
use crate::token_data::parse_jwt_expiration;
use codex_config::ManagedAuthPolicy;
use codex_config::types::AuthCredentialsStoreMode;
use codex_http_client::HttpClient;
use codex_http_client::HttpClientFactory;
@@ -361,6 +362,7 @@ impl CodexAuth {
codex_home,
/*enable_codex_api_key_env*/ false,
auth_credentials_store_mode,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
chatgpt_base_url,
keyring_backend_kind,
@@ -940,7 +942,7 @@ pub async fn login_with_access_token(
let auth_dot_json = match classify_codex_access_token(access_token) {
CodexAccessToken::PersonalAccessToken(access_token) => {
let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?;
ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?;
ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, auth.account_id())?;
AuthDotJson {
// Infer PAT auth from the credential field so older Codex builds can still
// deserialize auth.json after a rollback.
@@ -954,6 +956,8 @@ pub async fn login_with_access_token(
}
}
CodexAccessToken::AgentIdentityJwt(jwt) => {
let record = AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?;
ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, &record.account_id)?;
let base_url = chatgpt_base_url
.unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
.trim_end_matches('/')
@@ -978,14 +982,33 @@ pub async fn login_with_access_token(
)
}
fn ensure_personal_access_token_workspace_allowed(
fn ensure_auth_workspace_allowed(
expected_workspace_ids: Option<&[String]>,
auth: &PersonalAccessTokenAuth,
account_id: &str,
) -> std::io::Result<()> {
crate::server::ensure_workspace_account_allowed(expected_workspace_ids, auth.account_id())
crate::server::ensure_workspace_account_allowed(expected_workspace_ids, account_id)
.map_err(|message| std::io::Error::new(std::io::ErrorKind::PermissionDenied, message))
}
fn ensure_agent_identity_workspace_allowed(
expected_workspace_ids: Option<&[String]>,
agent_identity: &AgentIdentityStorage,
) -> std::io::Result<()> {
let Some(expected_workspace_ids) = expected_workspace_ids else {
return Ok(());
};
match agent_identity {
AgentIdentityStorage::Jwt(jwt) => {
let record = AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?;
ensure_auth_workspace_allowed(Some(expected_workspace_ids), &record.account_id)
}
AgentIdentityStorage::Record(record) => {
ensure_auth_workspace_allowed(Some(expected_workspace_ids), &record.account_id)
}
}
}
/// Writes an in-memory auth payload for externally managed ChatGPT tokens.
pub fn login_with_chatgpt_auth_tokens(
codex_home: &Path,
@@ -1047,9 +1070,131 @@ pub struct AuthConfig {
pub forced_login_method: Option<ForcedLoginMethod>,
pub chatgpt_base_url: Option<String>,
pub forced_chatgpt_workspace_id: Option<Vec<String>>,
pub managed_auth_policy: ManagedAuthPolicy,
pub auth_route_config: AuthRouteConfig,
}
impl AuthConfig {
pub fn is_login_method_allowed(&self, method: ForcedLoginMethod) -> bool {
self.managed_auth_policy.allows_login_method(
method,
self.forced_login_method,
self.forced_chatgpt_workspace_id.as_deref(),
)
}
pub fn effective_chatgpt_workspaces(&self) -> Option<Vec<String>> {
self.managed_auth_policy
.effective_chatgpt_workspaces(self.forced_chatgpt_workspace_id.as_deref())
}
pub fn validate(&self) -> std::io::Result<()> {
if self.is_login_method_allowed(ForcedLoginMethod::Api)
|| self.is_login_method_allowed(ForcedLoginMethod::Chatgpt)
{
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"authentication requirements do not permit any usable login method",
))
}
}
pub fn allows_auth(&self, auth: &CodexAuth) -> bool {
let allowed_login_methods = self.allowed_login_methods();
let workspaces = self.effective_chatgpt_workspaces();
validate_auth_restrictions(Some(&allowed_login_methods), workspaces.as_deref(), auth)
.is_ok()
}
pub async fn load_auth(
&self,
enable_codex_api_key_env: bool,
) -> std::io::Result<Option<CodexAuth>> {
let allowed_login_methods = self.allowed_login_methods();
let workspaces = self.effective_chatgpt_workspaces();
let agent_identity_authapi_base_url =
agent_identity_authapi_base_url(self.chatgpt_base_url.as_deref()).ok();
let auth = load_auth(
&self.codex_home,
enable_codex_api_key_env,
self.auth_credentials_store_mode,
Some(&allowed_login_methods),
workspaces.as_deref(),
self.chatgpt_base_url.as_deref(),
self.keyring_backend_kind,
agent_identity_authapi_base_url.as_deref(),
&self.auth_route_config,
)
.await?;
Ok(auth.filter(|auth| self.allows_auth(auth)))
}
fn allowed_login_methods(&self) -> Vec<ForcedLoginMethod> {
self.managed_auth_policy.allowed_login_methods(
self.forced_login_method,
self.forced_chatgpt_workspace_id.as_deref(),
)
}
}
fn auth_mode_is_allowed(
allowed_login_methods: Option<&[ForcedLoginMethod]>,
mode: AuthMode,
) -> bool {
let method = if mode.uses_codex_backend() {
ForcedLoginMethod::Chatgpt
} else {
ForcedLoginMethod::Api
};
allowed_login_methods.is_none_or(|allowed| allowed.contains(&method))
}
fn validate_auth_restrictions(
allowed_login_methods: Option<&[ForcedLoginMethod]>,
expected_workspaces: Option<&[String]>,
auth: &CodexAuth,
) -> Result<(), String> {
if !auth_mode_is_allowed(allowed_login_methods, auth.auth_mode()) {
return Err(match allowed_login_methods {
Some(methods) if methods.contains(&ForcedLoginMethod::Api) => {
"API key login is required".to_string()
}
Some(_) => "ChatGPT login is required".to_string(),
None => unreachable!("unrestricted login methods accept every auth mode"),
});
}
let Some(expected_workspaces) = expected_workspaces else {
return Ok(());
};
if matches!(
auth,
CodexAuth::ApiKey(_) | CodexAuth::Headers(_) | CodexAuth::BedrockApiKey(_)
) {
return Ok(());
}
let actual_workspace = auth.get_account_id().or_else(|| {
auth.get_token_data()
.ok()
.and_then(|tokens| tokens.id_token.chatgpt_account_id)
});
if actual_workspace
.as_ref()
.is_some_and(|workspace| expected_workspaces.contains(workspace))
{
Ok(())
} else {
let actual = actual_workspace.unwrap_or_else(|| "unknown".to_string());
Err(format!(
"Login is restricted to workspace(s) {}, but current credentials belong to {actual}",
expected_workspaces.join(", ")
))
}
}
/// Enforces configured login restrictions using auth-owned HTTP settings.
pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> {
let agent_identity_authapi_base_url =
@@ -1065,10 +1210,16 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url(
config: &AuthConfig,
agent_identity_authapi_base_url: Option<&str>,
) -> std::io::Result<()> {
// Managed-only restrictions are enforced by AuthManager.
if config.forced_login_method.is_none() && config.forced_chatgpt_workspace_id.is_none() {
return Ok(());
}
let Some(auth) = load_auth(
&config.codex_home,
/*enable_codex_api_key_env*/ true,
config.auth_credentials_store_mode,
/*allowed_login_methods*/ None,
/*forced_chatgpt_workspace_id*/ None,
config.chatgpt_base_url.as_deref(),
config.keyring_backend_kind,
@@ -1218,6 +1369,7 @@ async fn load_auth(
codex_home: &Path,
enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
allowed_login_methods: Option<&[ForcedLoginMethod]>,
forced_chatgpt_workspace_id: Option<&[String]>,
chatgpt_base_url: Option<&str>,
keyring_backend_kind: AuthKeyringBackendKind,
@@ -1225,7 +1377,10 @@ async fn load_auth(
auth_route_config: &AuthRouteConfig,
) -> std::io::Result<Option<CodexAuth>> {
// API key via env var takes precedence over any other auth method.
if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() {
if enable_codex_api_key_env
&& auth_mode_is_allowed(allowed_login_methods, AuthMode::ApiKey)
&& let Some(api_key) = read_codex_api_key_from_env()
{
return Ok(Some(CodexAuth::from_api_key(api_key.as_str())));
}
@@ -1236,7 +1391,12 @@ async fn load_auth(
AuthCredentialsStoreMode::Ephemeral,
AuthKeyringBackendKind::default(),
);
if let Some(auth_dot_json) = ephemeral_storage.load()? {
if let Some(auth_dot_json) = ephemeral_storage.load()?
&& auth_mode_is_allowed(allowed_login_methods, auth_dot_json.resolved_mode())
{
if let Some(agent_identity) = auth_dot_json.agent_identity.as_ref() {
ensure_agent_identity_workspace_allowed(forced_chatgpt_workspace_id, agent_identity)?;
}
let auth = CodexAuth::from_auth_dot_json(
codex_home,
auth_dot_json,
@@ -1248,19 +1408,23 @@ async fn load_auth(
)
.await?;
if let CodexAuth::PersonalAccessToken(auth) = &auth {
ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, auth)?;
ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, auth.account_id())?;
}
return Ok(Some(auth));
}
if let Some(access_token) = read_codex_access_token_from_env() {
if auth_mode_is_allowed(allowed_login_methods, AuthMode::AgentIdentity)
&& let Some(access_token) = read_codex_access_token_from_env()
{
return match classify_codex_access_token(&access_token) {
CodexAccessToken::PersonalAccessToken(access_token) => {
let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?;
ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?;
ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, auth.account_id())?;
Ok(Some(CodexAuth::PersonalAccessToken(auth)))
}
CodexAccessToken::AgentIdentityJwt(jwt) => {
let record = AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?;
ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, &record.account_id)?;
CodexAuth::from_agent_identity_jwt_with_authapi_base_url(
jwt,
chatgpt_base_url,
@@ -1288,6 +1452,12 @@ async fn load_auth(
Some(auth) => auth,
None => return Ok(None),
};
if !auth_mode_is_allowed(allowed_login_methods, auth_dot_json.resolved_mode()) {
return Ok(None);
}
if let Some(agent_identity) = auth_dot_json.agent_identity.as_ref() {
ensure_agent_identity_workspace_allowed(forced_chatgpt_workspace_id, agent_identity)?;
}
let auth = CodexAuth::from_auth_dot_json(
codex_home,
@@ -1300,7 +1470,7 @@ async fn load_auth(
)
.await?;
if let CodexAuth::PersonalAccessToken(auth) = &auth {
ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, auth)?;
ensure_auth_workspace_allowed(forced_chatgpt_workspace_id, auth.account_id())?;
}
Ok(Some(auth))
}
@@ -1773,7 +1943,9 @@ pub struct AuthManager {
enable_codex_api_key_env: bool,
auth_credentials_store_mode: AuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
forced_login_method: Option<ForcedLoginMethod>,
forced_chatgpt_workspace_id: RwLock<Option<Vec<String>>>,
managed_auth_policy: ManagedAuthPolicy,
chatgpt_base_url: Option<String>,
agent_identity_authapi_base_url: Option<String>,
refresh_lock: Semaphore,
@@ -1799,9 +1971,15 @@ pub trait AuthManagerConfig {
/// Returns the backend to use when CLI auth keyring storage is selected.
fn auth_keyring_backend_kind(&self) -> AuthKeyringBackendKind;
/// Returns the resolved login-method restriction, if any.
fn forced_login_method(&self) -> Option<ForcedLoginMethod>;
/// Returns the workspace IDs that ChatGPT auth should be restricted to, if any.
fn forced_chatgpt_workspace_id(&self) -> Option<Vec<String>>;
/// Returns administrator-managed authentication restrictions.
fn managed_auth_policy(&self) -> ManagedAuthPolicy;
/// Returns the ChatGPT backend base URL used for first-party backend authorization.
fn chatgpt_base_url(&self) -> String;
@@ -1820,10 +1998,12 @@ impl Debug for AuthManager {
&self.auth_credentials_store_mode,
)
.field("keyring_backend_kind", &self.keyring_backend_kind)
.field("forced_login_method", &self.forced_login_method)
.field(
"forced_chatgpt_workspace_id",
&self.forced_chatgpt_workspace_id,
)
.field("managed_auth_policy", &self.managed_auth_policy)
.field("chatgpt_base_url", &self.chatgpt_base_url)
.field("auth_route_config", &self.auth_route_config)
.field("has_external_auth", &self.has_external_auth())
@@ -1849,21 +2029,40 @@ impl AuthManager {
keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: AuthRouteConfig,
) -> Self {
let agent_identity_authapi_base_url =
agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok();
let managed_auth = load_auth(
&codex_home,
Self::new_from_auth_config(
AuthConfig {
codex_home,
auth_credentials_store_mode,
keyring_backend_kind,
forced_login_method: None,
chatgpt_base_url,
forced_chatgpt_workspace_id,
managed_auth_policy: ManagedAuthPolicy::default(),
auth_route_config,
},
enable_codex_api_key_env,
auth_credentials_store_mode,
forced_chatgpt_workspace_id.as_deref(),
chatgpt_base_url.as_deref(),
keyring_backend_kind,
agent_identity_authapi_base_url.as_deref(),
&auth_route_config,
)
.await
.ok()
.flatten();
}
async fn new_from_auth_config(auth_config: AuthConfig, enable_codex_api_key_env: bool) -> Self {
let managed_auth = auth_config
.load_auth(enable_codex_api_key_env)
.await
.ok()
.flatten();
let AuthConfig {
codex_home,
auth_credentials_store_mode,
keyring_backend_kind,
forced_login_method,
chatgpt_base_url,
forced_chatgpt_workspace_id,
managed_auth_policy,
auth_route_config,
} = auth_config;
let agent_identity_authapi_base_url =
agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok();
let (auth_change_tx, _auth_change_rx) = watch::channel(0);
Self {
codex_home,
@@ -1875,7 +2074,9 @@ impl AuthManager {
enable_codex_api_key_env,
auth_credentials_store_mode,
keyring_backend_kind,
forced_login_method,
forced_chatgpt_workspace_id: RwLock::new(forced_chatgpt_workspace_id),
managed_auth_policy,
chatgpt_base_url,
agent_identity_authapi_base_url,
refresh_lock: Semaphore::new(/*permits*/ 1),
@@ -1901,7 +2102,9 @@ impl AuthManager {
enable_codex_api_key_env: false,
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
keyring_backend_kind: AuthKeyringBackendKind::default(),
forced_login_method: None,
forced_chatgpt_workspace_id: RwLock::new(None),
managed_auth_policy: ManagedAuthPolicy::default(),
chatgpt_base_url: None,
agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
refresh_lock: Semaphore::new(/*permits*/ 1),
@@ -1926,7 +2129,9 @@ impl AuthManager {
enable_codex_api_key_env: false,
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
keyring_backend_kind: AuthKeyringBackendKind::default(),
forced_login_method: None,
forced_chatgpt_workspace_id: RwLock::new(None),
managed_auth_policy: ManagedAuthPolicy::default(),
chatgpt_base_url: None,
agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
refresh_lock: Semaphore::new(/*permits*/ 1),
@@ -1955,7 +2160,9 @@ impl AuthManager {
enable_codex_api_key_env: false,
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
keyring_backend_kind: AuthKeyringBackendKind::default(),
forced_login_method: None,
forced_chatgpt_workspace_id: RwLock::new(None),
managed_auth_policy: ManagedAuthPolicy::default(),
chatgpt_base_url: None,
agent_identity_authapi_base_url: Some(
agent_identity_authapi_base_url
@@ -1982,7 +2189,9 @@ impl AuthManager {
enable_codex_api_key_env: false,
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
keyring_backend_kind: AuthKeyringBackendKind::default(),
forced_login_method: None,
forced_chatgpt_workspace_id: RwLock::new(None),
managed_auth_policy: ManagedAuthPolicy::default(),
chatgpt_base_url: None,
agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
refresh_lock: Semaphore::new(/*permits*/ 1),
@@ -2056,10 +2265,10 @@ impl AuthManager {
.acquire()
.await
.map_err(std::io::Error::other)?;
let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id();
let effective_chatgpt_workspaces = self.effective_chatgpt_workspaces();
let cooldown_key = ManagedChatGptAgentIdentityBinding::from_auth(
&auth,
forced_chatgpt_workspace_id.clone(),
effective_chatgpt_workspaces.clone(),
)
.and_then(|binding| {
self.agent_identity_authapi_base_url
@@ -2079,7 +2288,7 @@ impl AuthManager {
.agent_identity_auth(
policy,
self.agent_identity_authapi_base_url.as_deref(),
forced_chatgpt_workspace_id,
effective_chatgpt_workspaces,
&self.auth_route_config,
session_source,
)
@@ -2098,7 +2307,7 @@ impl AuthManager {
auth.agent_identity_auth(
policy,
self.agent_identity_authapi_base_url.as_deref(),
self.forced_chatgpt_workspace_id(),
self.effective_chatgpt_workspaces(),
&self.auth_route_config,
session_source,
)
@@ -2209,12 +2418,14 @@ impl AuthManager {
};
}
let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id();
let allowed_login_methods = self.allowed_login_methods();
let effective_chatgpt_workspaces = self.effective_chatgpt_workspaces();
load_auth(
&self.codex_home,
self.enable_codex_api_key_env,
self.auth_credentials_store_mode,
forced_chatgpt_workspace_id.as_deref(),
Some(&allowed_login_methods),
effective_chatgpt_workspaces.as_deref(),
self.chatgpt_base_url.as_deref(),
self.keyring_backend_kind,
self.agent_identity_authapi_base_url.as_deref(),
@@ -2223,6 +2434,14 @@ impl AuthManager {
.await
.ok()
.flatten()
.filter(|auth| {
validate_auth_restrictions(
Some(&allowed_login_methods),
effective_chatgpt_workspaces.as_deref(),
auth,
)
.is_ok()
})
}
fn set_cached_auth(&self, new_auth: Option<CodexAuth>) -> bool {
@@ -2279,6 +2498,26 @@ impl AuthManager {
.and_then(|guard| guard.clone())
}
pub fn effective_chatgpt_workspaces(&self) -> Option<Vec<String>> {
self.managed_auth_policy
.effective_chatgpt_workspaces(self.forced_chatgpt_workspace_id().as_deref())
}
pub fn is_login_method_allowed(&self, method: ForcedLoginMethod) -> bool {
self.managed_auth_policy.allows_login_method(
method,
self.forced_login_method,
self.forced_chatgpt_workspace_id().as_deref(),
)
}
fn allowed_login_methods(&self) -> Vec<ForcedLoginMethod> {
self.managed_auth_policy.allowed_login_methods(
self.forced_login_method,
self.forced_chatgpt_workspace_id().as_deref(),
)
}
pub fn has_external_auth(&self) -> bool {
self.external_auth().is_some()
}
@@ -2322,18 +2561,30 @@ impl AuthManager {
config: &impl AuthManagerConfig,
enable_codex_api_key_env: bool,
) -> Arc<Self> {
Self::shared(
config.codex_home(),
Self::shared_from_auth_config(
AuthConfig {
codex_home: config.codex_home(),
auth_credentials_store_mode: config.cli_auth_credentials_store_mode(),
keyring_backend_kind: config.auth_keyring_backend_kind(),
forced_login_method: config.forced_login_method(),
chatgpt_base_url: Some(config.chatgpt_base_url()),
forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id(),
managed_auth_policy: config.managed_auth_policy(),
auth_route_config: config.auth_route_config(),
},
enable_codex_api_key_env,
config.cli_auth_credentials_store_mode(),
config.forced_chatgpt_workspace_id(),
Some(config.chatgpt_base_url()),
config.auth_keyring_backend_kind(),
config.auth_route_config(),
)
.await
}
/// Builds a shared manager using restrictions resolved before authentication.
pub async fn shared_from_auth_config(
auth_config: AuthConfig,
enable_codex_api_key_env: bool,
) -> Arc<Self> {
Arc::new(Self::new_from_auth_config(auth_config, enable_codex_api_key_env).await)
}
pub fn unauthorized_recovery(self: &Arc<Self>) -> UnauthorizedRecovery {
UnauthorizedRecovery::new(Arc::clone(self))
}
@@ -2582,16 +2833,13 @@ impl AuthManager {
}
fn validate_external_auth(&self, auth: &CodexAuth) -> Result<(), RefreshTokenError> {
if let Some(account_id) = auth.get_account_id()
&& let Some(expected_workspace_ids) = self.forced_chatgpt_workspace_id()
&& !expected_workspace_ids.contains(&account_id)
{
return Err(RefreshTokenError::Transient(std::io::Error::other(
format!(
"external auth returned workspace {account_id:?}, expected one of {expected_workspace_ids:?}"
),
)));
}
let allowed_login_methods = self.allowed_login_methods();
validate_auth_restrictions(
Some(&allowed_login_methods),
self.effective_chatgpt_workspaces().as_deref(),
auth,
)
.map_err(std::io::Error::other)?;
Ok(())
}
+1
View File
@@ -80,6 +80,7 @@ pub async fn run_main(
.map_err(|e| {
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
})?;
config.auth_config().validate()?;
set_default_client_residency_requirement(config.enforce_residency.value());
let otel = codex_core::otel_init::build_provider(
&config,
+2
View File
@@ -929,6 +929,8 @@ interrupt_message = false
};
let requirements_toml = ConfigRequirementsToml {
allowed_login_methods: None,
allowed_chatgpt_workspaces: None,
sqlite_home: Some(sqlite_home),
log_dir: Some(log_dir),
model_catalog_json: Some(model_catalog_json),
+19 -46
View File
@@ -7,8 +7,9 @@ use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::ConfigTomlLoadResult;
use crate::legacy_core::config::bootstrap_auth_config;
use crate::legacy_core::config::load_config_toml_with_layer_stack;
use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind;
#[cfg(test)]
use crate::legacy_core::config::resolve_bootstrap_http_client_factory;
use crate::legacy_core::config::resolve_oss_provider;
use crate::legacy_core::config::resolve_profile_v2_config_path;
@@ -47,7 +48,6 @@ use codex_config::types::ResumeCwdMode;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_login::AuthConfig;
use codex_login::AuthRouteConfig;
use codex_login::default_client::originator;
use codex_login::default_client::set_default_client_residency_requirement;
use codex_login::enforce_login_restrictions;
@@ -274,6 +274,17 @@ impl AppServerTarget {
matches!(self, Self::Remote { .. })
}
fn auth_config_for_cloud_loader(&self, mut auth_config: AuthConfig) -> AuthConfig {
if self.uses_remote_workspace() {
// Remove local auth restrictions before loading credentials for a remote
// workspace; the remote app server enforces its own authentication policy.
auth_config.forced_login_method = None;
auth_config.forced_chatgpt_workspace_id = None;
auth_config.managed_auth_policy = Default::default();
}
auth_config
}
fn thread_params_mode(&self) -> ThreadParamsMode {
if self.uses_remote_workspace() {
ThreadParamsMode::Remote
@@ -1007,6 +1018,7 @@ pub async fn run_main(
loader_overrides.user_config_path = Some(user_config_path);
loader_overrides.user_config_profile = Some(profile_v2.clone());
}
loader_overrides.ignore_login_requirements = app_server_target.uses_remote_workspace();
let bootstrap_config = load_bootstrap_config_or_exit(
&codex_home,
@@ -1018,30 +1030,10 @@ pub async fn run_main(
)
.await;
let bootstrap_config_toml = &bootstrap_config.config_toml;
let chatgpt_base_url = bootstrap_config_toml
.chatgpt_base_url
.clone()
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string());
let bootstrap_http_client_factory = resolve_bootstrap_http_client_factory(
bootstrap_config_toml,
bootstrap_config
.config_layer_stack
.requirements()
.feature_requirements
.as_ref(),
)?;
let auth_route_config =
AuthRouteConfig::from_http_client_factory(bootstrap_http_client_factory);
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
codex_home.to_path_buf(),
app_server_target
.auth_config_for_cloud_loader(bootstrap_auth_config(&codex_home, &bootstrap_config)?),
/*enable_codex_api_key_env*/ false,
bootstrap_config_toml
.cli_auth_credentials_store
.unwrap_or_default(),
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
chatgpt_base_url,
auth_route_config,
)
.await;
@@ -1135,12 +1127,8 @@ pub async fn run_main(
.await;
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
config.codex_home.to_path_buf(),
app_server_target.auth_config_for_cloud_loader(config.auth_config()),
/*enable_codex_api_key_env*/ false,
config.cli_auth_credentials_store_mode,
config.auth_keyring_backend_kind(),
config.chatgpt_base_url.clone(),
config.auth_route_config(),
)
.await;
let environment_manager = Arc::new(
@@ -1209,19 +1197,8 @@ pub async fn run_main(
}
if !app_server_target.uses_remote_workspace() {
let auth_route_config = config.auth_route_config();
#[allow(clippy::print_stderr)]
if let Err(err) = enforce_login_restrictions(&AuthConfig {
codex_home: config.codex_home.to_path_buf(),
auth_credentials_store_mode: config.cli_auth_credentials_store_mode,
keyring_backend_kind: config.auth_keyring_backend_kind(),
forced_login_method: config.forced_login_method,
forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(),
chatgpt_base_url: Some(config.chatgpt_base_url.clone()),
auth_route_config,
})
.await
{
if let Err(err) = enforce_login_restrictions(&config.auth_config()).await {
eprintln!("{err}");
std::process::exit(1);
}
@@ -1491,12 +1468,8 @@ async fn run_ratatui_app(
// status detection edge cases.
if show_login_screen && !uses_remote_workspace {
cloud_config_bundle = cloud_config_bundle_loader_for_storage(
initial_config.codex_home.to_path_buf(),
initial_config.auth_config(),
/*enable_codex_api_key_env*/ false,
initial_config.cli_auth_credentials_store_mode,
initial_config.auth_keyring_backend_kind(),
initial_config.chatgpt_base_url.clone(),
initial_config.auth_route_config(),
)
.await;
}
+10 -13
View File
@@ -15,6 +15,7 @@ use codex_app_server_protocol::CancelLoginAccountParams;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_login::AuthConfig;
use codex_login::read_openai_api_key_from_env;
use codex_protocol::auth::AuthMode;
use crossterm::event::KeyCode;
@@ -234,7 +235,7 @@ pub(crate) struct AuthModeWidget {
pub sign_in_state: Arc<RwLock<SignInState>>,
pub login_status: LoginStatus,
pub app_server_request_handle: AppServerRequestHandle,
pub forced_login_method: Option<ForcedLoginMethod>,
pub auth_config: AuthConfig,
pub animations_enabled: bool,
pub animations_suppressed: Cell<bool>,
}
@@ -308,11 +309,13 @@ impl AuthModeWidget {
}
fn is_api_login_allowed(&self) -> bool {
!matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt))
self.auth_config
.is_login_method_allowed(ForcedLoginMethod::Api)
}
fn is_chatgpt_login_allowed(&self) -> bool {
!matches!(self.forced_login_method, Some(ForcedLoginMethod::Api))
self.auth_config
.is_login_method_allowed(ForcedLoginMethod::Chatgpt)
}
fn displayed_sign_in_options(&self) -> Vec<SignInOption> {
@@ -1029,9 +1032,6 @@ mod tests {
use codex_app_server_client::InProcessClientStartArgs;
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_config::cloud_config_bundle_loader_for_storage;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use tempfile::TempDir;
@@ -1059,7 +1059,7 @@ mod tests {
.build()
.await
.unwrap();
let auth_route_config = config.auth_route_config();
let mut auth_config = config.auth_config();
let client = InProcessAppServerClient::start(InProcessClientStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(config),
@@ -1067,12 +1067,8 @@ mod tests {
loader_overrides: Default::default(),
strict_config: false,
cloud_config_bundle: cloud_config_bundle_loader_for_storage(
codex_home_path.clone(),
auth_config.clone(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
"https://chatgpt.com/backend-api/".to_string(),
auth_route_config,
)
.await,
feedback: codex_feedback::CodexFeedback::new(),
@@ -1094,6 +1090,7 @@ mod tests {
})
.await
.unwrap();
auth_config.forced_login_method = Some(ForcedLoginMethod::Chatgpt);
let widget = AuthModeWidget {
request_frame: FrameRequester::test_dummy(),
highlighted_mode: SignInOption::ChatGpt,
@@ -1101,7 +1098,7 @@ mod tests {
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
login_status: LoginStatus::NotAuthenticated,
app_server_request_handle: AppServerRequestHandle::InProcess(client.request_handle()),
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
auth_config,
animations_enabled: true,
animations_suppressed: std::cell::Cell::new(false),
};
@@ -111,7 +111,7 @@ impl OnboardingScreen {
config,
} = args;
let cwd = config.cwd.to_path_buf();
let forced_login_method = config.forced_login_method;
let auth_config = config.auth_config();
let mut steps: Vec<Step> = Vec::new();
steps.push(Step::Welcome(WelcomeWidget::new(
!matches!(login_status, LoginStatus::NotAuthenticated),
@@ -119,10 +119,12 @@ impl OnboardingScreen {
config.animations,
)));
if show_login_screen {
let highlighted_mode = match forced_login_method {
Some(ForcedLoginMethod::Api) => SignInOption::ApiKey,
_ => SignInOption::ChatGpt,
};
let highlighted_mode =
if auth_config.is_login_method_allowed(ForcedLoginMethod::Chatgpt) {
SignInOption::ChatGpt
} else {
SignInOption::ApiKey
};
if let Some(app_server_request_handle) = app_server_request_handle {
steps.push(Step::Auth(AuthModeWidget {
request_frame: tui.frame_requester(),
@@ -131,7 +133,7 @@ impl OnboardingScreen {
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
login_status,
app_server_request_handle,
forced_login_method,
auth_config,
animations_enabled: config.animations,
animations_suppressed: std::cell::Cell::new(false),
}));
+6 -21
View File
@@ -13,9 +13,8 @@ use crate::Cli;
use crate::app_server_session::AppServerSession;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::bootstrap_auth_config;
use crate::legacy_core::config::load_config_toml_with_layer_stack;
use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind;
use crate::legacy_core::config::resolve_bootstrap_http_client_factory;
use crate::legacy_core::config::resolve_oss_provider;
use crate::legacy_core::config::resolve_profile_v2_config_path;
use codex_app_server_protocol::Thread as AppServerThread;
@@ -28,7 +27,6 @@ use codex_config::ConfigLoadOptions;
use codex_config::LoaderOverrides;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_login::AuthRouteConfig;
use codex_protocol::ThreadId;
use codex_utils_cli::CliConfigOverrides;
use codex_utils_home_dir::find_codex_home;
@@ -380,6 +378,7 @@ async fn start_app_server_for_archive_command(
));
loader_overrides.user_config_profile = Some(profile_v2.clone());
}
loader_overrides.ignore_login_requirements = app_server_target.uses_remote_workspace();
let bootstrap_config = load_config_toml_with_layer_stack(
codex_home.as_path(),
@@ -394,26 +393,12 @@ async fn start_app_server_for_archive_command(
.await
.wrap_err("failed to load config.toml")?;
let config_toml = &bootstrap_config.config_toml;
let chatgpt_base_url = config_toml
.chatgpt_base_url
.clone()
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string());
let http_client_factory = resolve_bootstrap_http_client_factory(
config_toml,
bootstrap_config
.config_layer_stack
.requirements()
.feature_requirements
.as_ref(),
)?;
let auth_route_config = AuthRouteConfig::from_http_client_factory(http_client_factory);
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
codex_home.to_path_buf(),
app_server_target.auth_config_for_cloud_loader(bootstrap_auth_config(
codex_home.as_path(),
&bootstrap_config,
)?),
/*enable_codex_api_key_env*/ false,
config_toml.cli_auth_credentials_store.unwrap_or_default(),
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
chatgpt_base_url,
auth_route_config,
)
.await;