Enforce managed authentication during bootstrap

This commit is contained in:
Abhinav Vedmala
2026-06-16 07:39:50 +00:00
parent 85a7b9d646
commit 3cd059585e
12 changed files with 431 additions and 49 deletions
+91
View File
@@ -8330,6 +8330,10 @@ 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,
cli_auth_credentials_store: None,
chatgpt_base_url: None,
sqlite_home: None,
log_dir: None,
model_catalog_json: None,
@@ -10943,3 +10947,90 @@ async fn absent_allow_login_shell_does_not_report_an_override() -> std::io::Resu
);
Ok(())
}
#[tokio::test]
async fn conflicting_login_method_requirements_fail_closed() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
"forced_login_method = \"api\"\n",
)?;
let err =
load_with_enterprise_requirement(&codex_home, "[allowed_login_methods]\nchatgpt = true\n")
.await
.expect_err("conflicting login restrictions should fail closed");
assert_eq!(err.kind(), ErrorKind::InvalidInput);
assert!(
err.to_string()
.contains("conflicts with `allowed_login_methods`")
);
Ok(())
}
#[tokio::test]
async fn workspace_requirements_can_disable_all_chatgpt_workspaces() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = load_with_enterprise_requirement(
&codex_home,
r#"
[allowed_chatgpt_workspaces]
"A" = false
"#,
)
.await?;
assert_eq!(config.forced_chatgpt_workspace_id, Some(Vec::new()));
Ok(())
}
#[tokio::test]
async fn bootstrap_config_applies_local_auth_requirements() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let requirements_path = codex_home.path().join("requirements.toml");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
cli_auth_credentials_store = "file"
chatgpt_base_url = "https://user.example/backend-api"
"#,
)?;
std::fs::write(
&requirements_path,
r#"
cli_auth_credentials_store = "keyring"
chatgpt_base_url = "https://managed.example/backend-api"
[features]
secret_auth_storage = true
"#,
)?;
let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests();
loader_overrides.system_requirements_path = Some(requirements_path);
let bootstrap_config = load_bootstrap_config_toml_with_layer_stack(
codex_home.path(),
Some(&codex_home.path().abs()),
Vec::new(),
ConfigLoadOptions {
loader_overrides,
..Default::default()
},
)
.await?;
let auth_keyring_backend_kind = resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?;
assert_eq!(
(
bootstrap_config.config_toml.cli_auth_credentials_store,
bootstrap_config.config_toml.chatgpt_base_url,
auth_keyring_backend_kind,
),
(
Some(AuthCredentialsStoreMode::Keyring),
Some("https://managed.example/backend-api".to_string()),
AuthKeyringBackendKind::Secrets,
)
);
Ok(())
}
+30 -20
View File
@@ -1738,6 +1738,26 @@ pub async fn load_config_toml_with_layer_stack(
})
}
/// Load the bootstrap config together with its layer stack, applying exact
/// auth requirements needed before cloud requirements can be fetched.
pub async fn load_bootstrap_config_toml_with_layer_stack(
codex_home: &Path,
cwd: Option<&AbsolutePathBuf>,
cli_overrides: Vec<(String, TomlValue)>,
options: impl Into<ConfigLoadOptions>,
) -> std::io::Result<ConfigTomlLoadResult> {
let mut result =
load_config_toml_with_layer_stack(codex_home, cwd, cli_overrides, options).await?;
let requirements = result.config_layer_stack.requirements_toml();
if let Some(required) = requirements.cli_auth_credentials_store {
result.config_toml.cli_auth_credentials_store = Some(required);
}
if let Some(required) = requirements.chatgpt_base_url.as_ref() {
result.config_toml.chatgpt_base_url = Some(required.clone());
}
Ok(result)
}
pub fn deserialize_config_toml_with_base(
root_value: TomlValue,
config_base_dir: &Path,
@@ -2663,7 +2683,11 @@ impl Config {
.startup_warnings()
.unwrap_or_default()
.to_vec();
requirements::apply_to_config(
let requirements::AppliedConfigRequirements {
cli_auth_credentials_store_mode,
forced_login_method,
forced_chatgpt_workspace_id,
} = requirements::apply_to_config(
&mut cfg,
config_layer_stack.requirements(),
&mut startup_warnings,
@@ -2672,6 +2696,10 @@ impl Config {
// 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: _,
cli_auth_credentials_store: _,
chatgpt_base_url: _,
sqlite_home: _,
log_dir: _,
model_catalog_json: _,
@@ -3274,21 +3302,6 @@ impl Config {
let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec);
let forced_chatgpt_workspace_id = cfg
.forced_chatgpt_workspace_id
.clone()
.map(codex_config::config_toml::ForcedChatgptWorkspaceIds::into_vec)
.map(|values| {
values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
})
.filter(|values| !values.is_empty());
let forced_login_method = cfg.forced_login_method;
let model = model.or(cfg.model);
let notices = cfg.notice.unwrap_or_default();
let service_tier = match service_tier_override {
@@ -3547,10 +3560,7 @@ impl Config {
include_environment_context,
// The config.toml omits "_mode" because it's a config file. However, "_mode"
// is important in code to differentiate the mode from the store implementation.
cli_auth_credentials_store_mode: resolve_cli_auth_credentials_store_mode(
cfg.cli_auth_credentials_store.unwrap_or_default(),
env!("CARGO_PKG_VERSION"),
),
cli_auth_credentials_store_mode,
mcp_servers,
// The config.toml omits "_mode" because it's a config file. However, "_mode"
// is important in code to differentiate the mode from the store implementation.
+100 -2
View File
@@ -3,6 +3,15 @@ use codex_config::RequirementSource;
use codex_config::Sourced;
use codex_config::config_toml::ConfigToml;
use codex_config::types::FeedbackConfigToml;
use codex_config::types::AuthCredentialsStoreMode;
use codex_protocol::config_types::ForcedLoginMethod;
/// Runtime values that cannot be applied by mutating [`ConfigToml`] alone.
pub(super) struct AppliedConfigRequirements {
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
pub forced_login_method: Option<ForcedLoginMethod>,
pub forced_chatgpt_workspace_id: Option<Vec<String>>,
}
/// Applies managed requirements to regular config before final config construction.
///
@@ -12,7 +21,7 @@ pub(super) fn apply_to_config(
config: &mut ConfigToml,
requirements: &ConfigRequirements,
startup_warnings: &mut Vec<String>,
) -> std::io::Result<()> {
) -> std::io::Result<AppliedConfigRequirements> {
macro_rules! apply_exact {
($field:ident) => {
apply_exact_requirement(
@@ -24,6 +33,8 @@ pub(super) fn apply_to_config(
};
}
apply_exact!(cli_auth_credentials_store);
apply_exact!(chatgpt_base_url);
apply_exact!(sqlite_home);
apply_exact!(log_dir);
apply_exact!(model_catalog_json);
@@ -46,7 +57,23 @@ pub(super) fn apply_to_config(
);
}
Ok(())
let (forced_login_method, forced_chatgpt_workspace_id) =
resolve_auth_restrictions(config, requirements)?;
let configured_auth_credentials_store = config.cli_auth_credentials_store.unwrap_or_default();
let cli_auth_credentials_store_mode = if requirements.cli_auth_credentials_store.is_some() {
configured_auth_credentials_store
} else {
super::resolve_cli_auth_credentials_store_mode(
configured_auth_credentials_store,
env!("CARGO_PKG_VERSION"),
)
};
Ok(AppliedConfigRequirements {
cli_auth_credentials_store_mode,
forced_login_method,
forced_chatgpt_workspace_id,
})
}
fn apply_exact_requirement<T>(
@@ -123,3 +150,74 @@ pub(super) fn push_structured_requirement_override_warning(
"Configured values under `{field_name}` are overridden by requirements from {source}."
));
}
fn resolve_auth_restrictions(
config: &ConfigToml,
requirements: &ConfigRequirements,
) -> std::io::Result<(Option<ForcedLoginMethod>, Option<Vec<String>>)> {
let configured_forced_chatgpt_workspace_id = config
.forced_chatgpt_workspace_id
.clone()
.map(codex_config::config_toml::ForcedChatgptWorkspaceIds::into_vec)
.map(|values| {
values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
})
.filter(|values| !values.is_empty());
let forced_chatgpt_workspace_id = match requirements.allowed_chatgpt_workspaces.as_ref() {
Some(requirement) => Some(match configured_forced_chatgpt_workspace_id {
Some(configured) => configured
.into_iter()
.filter(|workspace| requirement.value.get(workspace).copied().unwrap_or(false))
.collect(),
None => requirement
.value
.iter()
.filter_map(|(workspace, allowed)| allowed.then_some(workspace.clone()))
.collect(),
}),
None => configured_forced_chatgpt_workspace_id,
};
let forced_login_method = match requirements.allowed_login_methods.as_ref() {
Some(requirement) => {
let chatgpt_allowed = requirement.value.get("chatgpt").copied().unwrap_or(false);
let api_allowed = requirement.value.get("api").copied().unwrap_or(false);
match config.forced_login_method {
Some(ForcedLoginMethod::Chatgpt) if chatgpt_allowed => {
Some(ForcedLoginMethod::Chatgpt)
}
Some(ForcedLoginMethod::Api) if api_allowed => Some(ForcedLoginMethod::Api),
Some(configured) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"configured `forced_login_method = \"{configured}\"` conflicts with `allowed_login_methods` from {}",
requirement.source
),
));
}
None => match (chatgpt_allowed, api_allowed) {
(true, true) => None,
(true, false) => Some(ForcedLoginMethod::Chatgpt),
(false, true) => Some(ForcedLoginMethod::Api),
(false, false) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"`allowed_login_methods` from {} does not allow any login method",
requirement.source
),
));
}
},
}
}
None => config.forced_login_method,
};
Ok((forced_login_method, forced_chatgpt_workspace_id))
}