diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 9e791e93e3..5eae6a1d42 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9828,6 +9828,16 @@ "null" ] }, + "allowedLoginMethods": { + "description": "Effective login methods after managed, forced-login, and workspace restrictions. An empty list permits no login method. Older servers may omit this field.", + "items": { + "$ref": "#/definitions/v2/ForcedLoginMethod" + }, + "type": [ + "array", + "null" + ] + }, "allowedPermissionProfiles": { "additionalProperties": { "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 483c1fb9da..a056709984 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5708,6 +5708,16 @@ "null" ] }, + "allowedLoginMethods": { + "description": "Effective login methods after managed, forced-login, and workspace restrictions. An empty list permits no login method. Older servers may omit this field.", + "items": { + "$ref": "#/definitions/ForcedLoginMethod" + }, + "type": [ + "array", + "null" + ] + }, "allowedPermissionProfiles": { "additionalProperties": { "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 206deeae15..e707867d44 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -414,6 +414,16 @@ "null" ] }, + "allowedLoginMethods": { + "description": "Effective login methods after managed, forced-login, and workspace restrictions. An empty list permits no login method. Older servers may omit this field.", + "items": { + "$ref": "#/definitions/ForcedLoginMethod" + }, + "type": [ + "array", + "null" + ] + }, "allowedPermissionProfiles": { "additionalProperties": { "type": "boolean" @@ -762,6 +772,13 @@ }, "type": "object" }, + "ForcedLoginMethod": { + "enum": [ + "chatgpt", + "api" + ], + "type": "string" + }, "InAppBrowserRequirements": { "properties": { "allowExternalBrowserSettingsImport": { diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 8a4f90d7ef..875b499f8d 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 0e73285dac..ecbb0b1669 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 74ad5e74bf..c1fe73f083 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ForcedLoginMethod } from "../ForcedLoginMethod"; import type { PathUri } from "../PathUri"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; @@ -22,4 +23,8 @@ export type ConfigRequirements = {/** modelProvider: string | null, /** * Complete required provider definitions, using config.toml field names. */ -modelProviders: { [key in string]?: JsonValue } | null, cliAuthCredentialsStore: CliAuthCredentialsStoreMode | null, chatgptBaseUrl: string | null, additionalDeveloperInstructions: string | null, allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowBrowserAndComputerUse: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, browserUse: BrowserUseRequirements | null, inAppBrowser: InAppBrowserRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, autoReview: AutoReviewRequirements | null, models: ModelsRequirements | null, sqliteHome: PathUri | null, logDir: PathUri | null, modelCatalogJson: PathUri | null, checkForUpdateOnStartup: boolean | null, allowLoginShell: boolean | null, feedback: FeedbackRequirements | null, windowsSandboxPrivateDesktop: boolean | null}; +modelProviders: { [key in string]?: JsonValue } | null, /** + * Effective login methods after managed, forced-login, and workspace restrictions. + * An empty list permits no login method. Older servers may omit this field. + */ +allowedLoginMethods: Array | null, cliAuthCredentialsStore: CliAuthCredentialsStoreMode | null, chatgptBaseUrl: string | null, additionalDeveloperInstructions: string | null, allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowBrowserAndComputerUse: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, browserUse: BrowserUseRequirements | null, inAppBrowser: InAppBrowserRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, autoReview: AutoReviewRequirements | null, models: ModelsRequirements | null, sqliteHome: PathUri | null, logDir: PathUri | null, modelCatalogJson: PathUri | null, checkForUpdateOnStartup: boolean | null, allowLoginShell: boolean | null, feedback: FeedbackRequirements | null, windowsSandboxPrivateDesktop: boolean | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index a14f65ab6a..160b758317 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -412,6 +412,9 @@ pub struct ConfigRequirements { pub model_provider: Option, /// Complete required provider definitions, using config.toml field names. pub model_providers: Option>, + /// Effective login methods after managed, forced-login, and workspace restrictions. + /// An empty list permits no login method. Older servers may omit this field. + pub allowed_login_methods: Option>, pub cli_auth_credentials_store: Option, pub chatgpt_base_url: Option, pub additional_developer_instructions: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 3ea4483dda..c185747cab 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2120,6 +2120,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() crate::experimental_api::ExperimentalApi::experimental_reason(&ConfigRequirements { model_provider: None, model_providers: None, + allowed_login_methods: None, application: None, cli_auth_credentials_store: None, chatgpt_base_url: None, diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index 6f8016476d..32611a61cb 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -2630,3 +2630,45 @@ exclude = ["AWS_*"] Ok(()) } + +#[tokio::test] +async fn allowed_login_methods_follow_current_forced_workspaces() -> Result<()> { + use codex_protocol::config_types::ForcedLoginMethod; + + let tmp = tempdir()?; + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?; + std::fs::write( + tmp.path().join("requirements.toml"), + "allowed_chatgpt_workspaces = ['managed']", + )?; + 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 config = service.load_latest_config(/*fallback_cwd*/ None).await?; + let auth = codex_login::AuthManager::shared_from_config( + &config, /*enable_codex_api_key_env*/ false, + ) + .await?; + for (workspaces, expected) in [ + ( + Some(vec!["managed".to_string()]), + vec![ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt], + ), + ( + Some(vec!["other".to_string()]), + vec![ForcedLoginMethod::Api], + ), + (Some(Vec::new()), vec![ForcedLoginMethod::Api]), + ( + None, + vec![ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt], + ), + ] { + auth.set_forced_chatgpt_workspace_id(workspaces); + assert_eq!(auth.allowed_login_methods(), expected); + } + Ok(()) +} diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 30b441067d..77730bc619 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -55,6 +55,7 @@ use codex_features::canonical_feature_for_key; use codex_features::feature_for_key; use codex_model_provider::create_model_provider; use codex_plugin::PluginId; +use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::WebSearchMode; use serde_json::json; use std::path::PathBuf; @@ -135,8 +136,11 @@ impl ConfigRequestProcessor { .config_manager .read_requirements() .await - .map_err(map_error)? - .map(map_requirements_toml_to_api); + .map_err(map_error)?; + let requirements = map_requirements_to_api( + requirements, + self.thread_manager.auth_manager().allowed_login_methods(), + ); Ok(ConfigRequirementsReadResponse { requirements }) } @@ -381,13 +385,23 @@ pub(super) async fn reload_user_config( } } -fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { +fn map_requirements_to_api( + requirements: Option, + allowed_login_methods: Vec, +) -> Option { + let requirements = match requirements { + Some(requirements) => requirements, + None if allowed_login_methods == [ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt] => { + return None; + } + None => ConfigRequirementsToml::default(), + }; let windows_sandbox_private_desktop = requirements .windows .as_ref() .and_then(|windows| windows.sandbox_private_desktop); - ConfigRequirements { + Some(ConfigRequirements { model_provider: requirements.model_provider, model_providers: requirements.model_providers.map(|providers| { providers @@ -395,6 +409,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR .map(|(id, provider)| (id, serde_json::json!(provider))) .collect() }), + allowed_login_methods: Some(allowed_login_methods), application: requirements.application.map(|application| { codex_app_server_protocol::ApplicationRequirements { network: application.network.map(|network| { @@ -522,7 +537,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR enabled: feedback.enabled, }), windows_sandbox_private_desktop, - } + }) } fn map_computer_use_requirements_to_api( @@ -828,7 +843,7 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> #[cfg(test)] mod tests { - use super::map_requirements_toml_to_api; + use super::map_requirements_to_api; use codex_app_server_protocol::AllowDenyRequirement; use codex_app_server_protocol::AutoReviewRequirements; use codex_app_server_protocol::BrowserUseAccessApprovalLifetime; @@ -854,15 +869,26 @@ mod tests { use codex_config::NewThreadModelDefaultsToml; use codex_config::WindowsRequirementsToml; use codex_config::types::FeedbackConfigToml; + use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::openai_models::ReasoningEffort; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use std::collections::BTreeMap; + fn map_test_requirements( + requirements: ConfigRequirementsToml, + ) -> codex_app_server_protocol::ConfigRequirements { + map_requirements_to_api( + Some(requirements), + vec![ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt], + ) + .expect("requirements") + } + #[test] fn requirements_api_includes_allow_managed_hooks_only() { - let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + let mapped = map_test_requirements(ConfigRequirementsToml { allow_managed_hooks_only: Some(true), ..ConfigRequirementsToml::default() }); @@ -873,7 +899,7 @@ mod tests { #[test] fn requirements_api_includes_permission_default_and_allowlist() { - let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + let mapped = map_test_requirements(ConfigRequirementsToml { allowed_permission_profiles: Some(BTreeMap::from([ ("managed-build".to_string(), false), ("managed-standard".to_string(), true), @@ -897,7 +923,7 @@ mod tests { #[test] fn requirements_api_includes_allow_appshots() { - let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + let mapped = map_test_requirements(ConfigRequirementsToml { allow_appshots: Some(false), ..ConfigRequirementsToml::default() }); @@ -908,7 +934,7 @@ mod tests { #[test] fn requirements_api_includes_allow_remote_control() { - let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + let mapped = map_test_requirements(ConfigRequirementsToml { allow_remote_control: Some(false), ..ConfigRequirementsToml::default() }); @@ -918,7 +944,7 @@ mod tests { #[test] fn requirements_api_includes_model_auto_review_and_new_thread_defaults() { - let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + let mapped = map_test_requirements(ConfigRequirementsToml { auto_review: Some(AutoReviewRequirementsToml { required_on_models: Some(vec!["gpt-protected".to_string()]), ignore_rules: Some(vec!["gpt-protected".to_string()]), @@ -952,7 +978,7 @@ mod tests { #[test] fn requirements_api_includes_browser_and_computer_use_requirements() { - let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + let mapped = map_test_requirements(ConfigRequirementsToml { allow_browser_and_computer_use: Some(false), browser_use: Some(BrowserUseRequirementsToml { allow_webmcp: Some(true), @@ -1070,7 +1096,7 @@ mod tests { #[test] fn requirements_api_includes_allowed_windows_sandbox_implementations() { - let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + let mapped = map_test_requirements(ConfigRequirementsToml { windows: Some(WindowsRequirementsToml { allowed_sandbox_implementations: Some(vec![ codex_config::types::WindowsSandboxModeToml::Elevated, @@ -1100,7 +1126,7 @@ mod tests { let model_catalog_json = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-models.json")) .expect("managed model catalog path should be absolute"); - let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + let mapped = map_test_requirements(ConfigRequirementsToml { sqlite_home: Some(sqlite_home.clone()), log_dir: Some(log_dir.clone()), model_catalog_json: Some(model_catalog_json.clone()), diff --git a/codex-rs/app-server/tests/suite/strict_config.rs b/codex-rs/app-server/tests/suite/strict_config.rs index 6adee1d6cb..153927ccc6 100644 --- a/codex-rs/app-server/tests/suite/strict_config.rs +++ b/codex-rs/app-server/tests/suite/strict_config.rs @@ -103,12 +103,24 @@ foo = "bar" #[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", + for (requirements, config) in [ + ("allowed_login_methods = []", ""), + ( + "allowed_login_methods = ['chatgpt']\nallowed_chatgpt_workspaces = []", + "", + ), + ( + "allowed_login_methods = ['api']", + "forced_login_method = 'chatgpt'", + ), + ( + "allowed_login_methods = ['chatgpt']\nallowed_chatgpt_workspaces = ['managed']", + "forced_chatgpt_workspace_id = ['other']", + ), ] { let codex_home = TempDir::new()?; std::fs::write(codex_home.path().join("requirements.toml"), requirements)?; + std::fs::write(codex_home.path().join("config.toml"), config)?; let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex-app-server")?) .env("CODEX_HOME", codex_home.path()) diff --git a/codex-rs/app-server/tests/suite/v2/config_requirements_login.rs b/codex-rs/app-server/tests/suite/v2/config_requirements_login.rs new file mode 100644 index 0000000000..829a4ad8d6 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/config_requirements_login.rs @@ -0,0 +1,183 @@ +//! Requirements reads report the authentication policy enforced by the running server. + +use anyhow::Result; +use app_test_support::TestAppServer; +use codex_app_server_protocol::Account; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountResponse; +use codex_app_server_protocol::RequestId; +use codex_protocol::config_types::ForcedLoginMethod; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::time::Duration; +use tempfile::TempDir; +use test_case::test_case; +use tokio::time::timeout; +use wiremock::MockServer; + +const READ_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 60); + +async fn start_server( + config: &str, + requirements: Option<&str>, +) -> Result<(TempDir, TestAppServer)> { + let home = TempDir::new()?; + std::fs::write(home.path().join("config.toml"), config)?; + if let Some(requirements) = requirements { + std::fs::write(home.path().join("requirements.toml"), requirements)?; + } + let server = TestAppServer::builder() + .with_codex_home(home.path()) + .build_initialized_with_timeout(READ_TIMEOUT) + .await?; + Ok((home, server)) +} + +async fn read_requirements(server: &mut TestAppServer) -> Result { + let id = server.send_config_requirements_read_request().await?; + timeout(READ_TIMEOUT, server.read_response(id)).await? +} + +#[test_case(""; "no_requirements")] +#[test_case("forced_chatgpt_workspace_id = []"; "empty_forced_workspaces_are_unrestricted")] +#[test_case("forced_chatgpt_workspace_id = ['managed']"; "forced_workspace_does_not_exclude_api")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_requirements_read_preserves_unrestricted_default(config: &str) -> Result<()> { + let (_home, mut server) = start_server(config, /*requirements*/ None).await?; + assert_eq!( + read_requirements(&mut server).await?, + json!({"requirements": null}) + ); + Ok(()) +} + +#[test_case("allow_remote_control = false", "", &[ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt]; "unrestricted_with_other_requirements")] +#[test_case("allowed_login_methods = ['api']", "", &[ForcedLoginMethod::Api]; "managed_api")] +#[test_case("allowed_login_methods = ['chatgpt']", "", &[ForcedLoginMethod::Chatgpt]; "managed_chatgpt")] +#[test_case("allowed_login_methods = ['chatgpt', 'api', 'api']", "", &[ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt]; "both_normalized")] +#[test_case("", "forced_login_method = 'api'", &[ForcedLoginMethod::Api]; "forced_api_without_requirements")] +#[test_case("", "forced_login_method = 'chatgpt'", &[ForcedLoginMethod::Chatgpt]; "forced_chatgpt_without_requirements")] +#[test_case("allowed_login_methods = ['chatgpt', 'api']", "forced_login_method = 'api'", &[ForcedLoginMethod::Api]; "forced_narrows_managed")] +#[test_case("allowed_chatgpt_workspaces = []", "", &[ForcedLoginMethod::Api]; "empty_managed_workspaces")] +#[test_case("allowed_chatgpt_workspaces = ['managed']", "forced_chatgpt_workspace_id = ['other']", &[ForcedLoginMethod::Api]; "disjoint_workspaces")] +#[test_case("allowed_chatgpt_workspaces = ['managed']", "forced_chatgpt_workspace_id = ['other', 'managed']", &[ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt]; "overlapping_workspaces")] +#[test_case("allowed_login_methods = ['api']\nallowed_chatgpt_workspaces = ['managed']", "forced_chatgpt_workspace_id = ['other']", &[ForcedLoginMethod::Api]; "api_only_ignores_workspace_mismatch")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_requirements_read_exposes_effective_login_methods( + requirements: &str, + config: &str, + expected: &[ForcedLoginMethod], +) -> Result<()> { + let (_home, mut server) = start_server(config, Some(requirements)).await?; + let wire = read_requirements(&mut server).await?; + assert_eq!(wire["requirements"]["allowedLoginMethods"], json!(expected)); + if !expected.contains(&ForcedLoginMethod::Chatgpt) { + let id = server.send_login_account_chatgpt_request().await?; + let error = timeout( + READ_TIMEOUT, + server.read_stream_until_error_message(RequestId::Integer(id)), + ) + .await??; + assert!(error.error.message.contains("disabled"), "{error:?}"); + } + if !expected.contains(&ForcedLoginMethod::Api) { + let id = server.send_login_account_api_key_request("sk-test").await?; + let error = timeout( + READ_TIMEOUT, + server.read_stream_until_error_message(RequestId::Integer(id)), + ) + .await??; + assert!(error.error.message.contains("disabled"), "{error:?}"); + } + Ok(()) +} + +#[test_case(""; "removed")] +#[test_case("allowed_login_methods = ['chatgpt']"; "changed")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_requirements_read_uses_running_auth_policy(refreshed: &str) -> Result<()> { + let (home, mut server) = start_server("", Some("allowed_login_methods = ['api']")).await?; + std::fs::write(home.path().join("requirements.toml"), refreshed)?; + assert_eq!( + read_requirements(&mut server).await?["requirements"]["allowedLoginMethods"], + json!(["api"]) + ); + let id = server.send_login_account_chatgpt_request().await?; + let error = timeout( + READ_TIMEOUT, + server.read_stream_until_error_message(RequestId::Integer(id)), + ) + .await??; + assert!(error.error.message.contains("disabled"), "{error:?}"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_requirements_read_rejects_invalid_login_method() -> Result<()> { + let (home, mut server) = start_server("", /*requirements*/ None).await?; + std::fs::write( + home.path().join("requirements.toml"), + "allowed_login_methods = ['saml']", + )?; + let id = server.send_config_requirements_read_request().await?; + let error = timeout( + READ_TIMEOUT, + server.read_stream_until_error_message(RequestId::Integer(id)), + ) + .await??; + assert!( + error.error.message.contains("allowed_login_methods"), + "{error:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_requirements_read_preserves_api_only_bedrock_without_chatgpt_requests() -> Result<()> +{ + let backend = MockServer::start().await; + let (_home, mut server) = start_server( + &format!( + r#" +forced_login_method = "api" +chatgpt_base_url = "{}/backend-api" +model_provider = "amazon-bedrock" +[model_providers.amazon-bedrock] +base_url = "https://bedrock.example.com/v1" +[model_providers.amazon-bedrock.auth] +command = "print-token" +"#, + backend.uri() + ), + /*requirements*/ None, + ) + .await?; + assert_eq!( + read_requirements(&mut server).await?["requirements"]["allowedLoginMethods"], + json!(["api"]) + ); + let id = server + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let account: GetAccountResponse = timeout(READ_TIMEOUT, server.read_response(id)).await??; + assert_eq!( + account, + GetAccountResponse { + account: Some(Account::AmazonBedrock { + uses_codex_managed_credentials: false + }), + requires_openai_auth: false, + } + ); + assert!( + backend + .received_requests() + .await + .expect("recorded requests") + .is_empty() + ); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 73928ce666..049e561d91 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -21,6 +21,7 @@ mod config_requirements_browser_use; #[path = "config_requirements_exec_tests.rs"] mod config_requirements_exec; mod config_requirements_in_app_browser; +mod config_requirements_login; mod config_rpc; #[cfg(unix)] #[path = "connection_handling_stdio_tests.rs"] diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 581d2faf1e..1453a09da2 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -65,24 +65,30 @@ fn cloud_auth_requirements_do_not_override_local_or_discard_other_policy() { cli_auth_credentials_store = "keyring" chatgpt_base_url = "https://managed.example/backend-api/""#, ); - let cloud = layer( - "req_cloud", - "Cloud policy", + for cloud_auth in [ + r#"allowed_login_methods = ["api", "chatgpt"] +allowed_chatgpt_workspaces = ["other"]"#, r#"allowed_login_methods = ["saml"] allowed_chatgpt_workspaces = "invalid" cli_auth_credentials_store = "invalid" -chatgpt_base_url = false -allow_login_shell = false"#, - ); - assert_eq!( - compose(vec![local, cloud]).expect("cloud auth cannot invalidate enterprise policy"), - Some(expected_requirements( - r#"allowed_login_methods = ["api"] +chatgpt_base_url = false"#, + ] { + let cloud = layer( + "req_cloud", + "Cloud policy", + &format!("{cloud_auth}\nallow_login_shell = false"), + ); + assert_eq!( + compose(vec![local.clone(), cloud]) + .expect("cloud auth cannot invalidate enterprise policy"), + Some(expected_requirements( + r#"allowed_login_methods = ["api"] cli_auth_credentials_store = "keyring" chatgpt_base_url = "https://managed.example/backend-api/" allow_login_shell = false"# - )) - ); + )) + ); + } } #[test] diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index de1f26ca0c..baf243faf6 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -2691,7 +2691,8 @@ impl AuthManager { ) } - fn allowed_login_methods(&self) -> Vec { + /// Returns the login methods permitted by the current effective authentication policy. + pub fn allowed_login_methods(&self) -> Vec { self.managed_auth_policy.allowed_login_methods( self.forced_login_method, self.forced_chatgpt_workspace_id().as_deref(), diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 0d8184b30c..ce48b6338e 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -11280,6 +11280,13 @@ class ConfigRequirements(BaseModel): allowed_approval_policies: Annotated[ list[AskForApproval] | None, Field(alias="allowedApprovalPolicies") ] = None + allowed_login_methods: Annotated[ + list[ForcedLoginMethod] | None, + Field( + alias="allowedLoginMethods", + description="Effective login methods after managed, forced-login, and workspace restrictions. An empty list permits no login method. Older servers may omit this field.", + ), + ] = None allowed_permission_profiles: Annotated[ dict[str, Any] | None, Field(alias="allowedPermissionProfiles") ] = None