Support Amazon Bedrock login in the app server (#33170)

## What changed

- Handle experimental `account/login/start` requests with `type: "amazonBedrock"`.
- Validate the API key and Mantle region, persist the managed credential, select the `amazon-bedrock` model provider, and emit the standard login and account-update notifications.
- Reload configuration for account and authentication status reads so the newly selected provider is visible immediately, with the startup configuration as a fallback.
- Extend the app-server test client with an Amazon Bedrock login mode that redacts API keys from request logs.

## Testing

- Add app-server coverage for successful login, credential and provider persistence, notifications, refreshed account state, invalid inputs, conflicting configuration, forced ChatGPT login, external authentication, and configuration reload failure.

GitOrigin-RevId: bf3ae98375caee41e78d2cdc677127138184c17a
This commit is contained in:
Celia Chen
2026-07-14 21:28:39 +00:00
committed by copyberry
parent a2fb3f048d
commit 518085387b
11 changed files with 816 additions and 22 deletions
+24
View File
@@ -22,6 +22,30 @@ cargo run -p codex-app-server-test-client -- model-list
When Codex asks a question, choose a numbered option (or `o` for a free-form answer when offered)
and the client will send the response and continue streaming the same turn.
## Testing Codex-managed Amazon Bedrock login
`test-login --amazon-bedrock` initializes the experimental app-server API, sends an
`account/login/start` request with an Amazon Bedrock API key, and waits for the
`account/login/completed` and `account/updated` notifications. Login replaces the current primary
credential and sets `model_provider = "amazon-bedrock"`, so use an isolated `CODEX_HOME` when
testing.
```bash
export CODEX_HOME="$(mktemp -d)"
printf 'cli_auth_credentials_store = "file"\n' > "$CODEX_HOME/config.toml"
cargo build -p codex-cli --bin codex
cargo run -p codex-app-server-test-client -- \
--codex-bin ./target/debug/codex \
test-login \
--amazon-bedrock \
--api-key "<BEDROCK_API_KEY>" \
--region us-west-2
```
The test client redacts `apiKey` from its outbound request log. After login, start a fresh Codex
process with the same `CODEX_HOME` to verify that it uses the persisted managed credential.
## Testing Plugin Analytics
The `plugin-analytics-smoke` command exercises `plugin/installed`, plugin
+77 -13
View File
@@ -230,11 +230,20 @@ enum CliCommand {
#[arg(long)]
abort_on: Option<usize>,
},
/// Trigger the ChatGPT login flow and wait for completion.
/// Trigger a ChatGPT or Amazon Bedrock login flow.
TestLogin {
/// Use the device-code login flow instead of the browser callback flow.
#[arg(long, default_value_t = false)]
#[arg(long, default_value_t = false, conflicts_with = "amazon_bedrock")]
device_code: bool,
/// Use a Codex-managed Amazon Bedrock API key.
#[arg(long, default_value_t = false, conflicts_with = "device_code")]
amazon_bedrock: bool,
/// Amazon Bedrock API key.
#[arg(long, value_name = "API_KEY")]
api_key: Option<String>,
/// AWS Region for the Amazon Bedrock Mantle endpoint.
#[arg(long, value_name = "REGION")]
region: Option<String>,
},
/// Fetch the current account rate limits from the Codex app-server.
GetAccountRateLimits,
@@ -313,6 +322,12 @@ enum CliCommand {
},
}
enum TestLoginMode {
ChatgptBrowser,
ChatgptDeviceCode,
AmazonBedrock { api_key: String, region: String },
}
pub async fn run() -> Result<()> {
let Cli {
codex_bin,
@@ -415,10 +430,24 @@ pub async fn run() -> Result<()> {
)
.await
}
CliCommand::TestLogin { device_code } => {
CliCommand::TestLogin {
device_code,
amazon_bedrock,
api_key,
region,
} => {
ensure_dynamic_tools_unused(&dynamic_tools, "test-login")?;
let endpoint = resolve_endpoint(codex_bin, url)?;
test_login(&endpoint, &config_overrides, device_code).await
let mode = if amazon_bedrock {
let api_key = api_key.context("--api-key is required with --amazon-bedrock")?;
let region = region.context("--region is required with --amazon-bedrock")?;
TestLoginMode::AmazonBedrock { api_key, region }
} else if device_code {
TestLoginMode::ChatgptDeviceCode
} else {
TestLoginMode::ChatgptBrowser
};
test_login(&endpoint, &config_overrides, mode).await
}
CliCommand::GetAccountRateLimits => {
ensure_dynamic_tools_unused(&dynamic_tools, "get-account-rate-limits")?;
@@ -1128,16 +1157,45 @@ async fn send_follow_up_v2(
async fn test_login(
endpoint: &Endpoint,
config_overrides: &[String],
device_code: bool,
mode: TestLoginMode,
) -> Result<()> {
with_client("test-login", endpoint, config_overrides, |client| {
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let login_response = if device_code {
client.login_account_chatgpt_device_code()?
} else {
client.login_account_chatgpt()?
let login_response = match mode {
TestLoginMode::ChatgptBrowser => client.login_account_chatgpt()?,
TestLoginMode::ChatgptDeviceCode => client.login_account_chatgpt_device_code()?,
TestLoginMode::AmazonBedrock { api_key, region } => {
let request_id = client.request_id();
let login_response: LoginAccountResponse = client.send_request(
ClientRequest::LoginAccount {
request_id: request_id.clone(),
params: codex_app_server_protocol::LoginAccountParams::AmazonBedrock {
api_key,
region,
},
},
request_id,
"account/login/start",
)?;
println!("< account/login/start response: {login_response:?}");
let completion =
client.wait_for_account_login_completion(/*expected_login_id*/ None)?;
println!("< account/login/completed notification: {completion:?}");
loop {
let notification = client.next_notification()?;
if let Ok(ServerNotification::AccountUpdated(account_updated)) =
ServerNotification::try_from(notification)
{
println!("< account/updated notification: {account_updated:?}");
break;
}
}
return Ok(());
}
};
println!("< account/login/start response: {login_response:?}");
let login_id = match login_response {
@@ -1158,7 +1216,7 @@ async fn test_login(
_ => bail!("expected chatgpt login response"),
};
let completion = client.wait_for_account_login_completion(&login_id)?;
let completion = client.wait_for_account_login_completion(Some(&login_id))?;
println!("< account/login/completed notification: {completion:?}");
if completion.success {
@@ -1799,7 +1857,7 @@ impl CodexClient {
fn wait_for_account_login_completion(
&mut self,
expected_login_id: &str,
expected_login_id: Option<&str>,
) -> Result<AccountLoginCompletedNotification> {
loop {
let notification = self.next_notification()?;
@@ -1807,7 +1865,7 @@ impl CodexClient {
if let Ok(server_notification) = ServerNotification::try_from(notification) {
match server_notification {
ServerNotification::AccountLoginCompleted(completion) => {
if completion.login_id.as_deref() == Some(expected_login_id) {
if completion.login_id.as_deref() == expected_login_id {
return Ok(completion);
}
@@ -1956,7 +2014,13 @@ impl CodexClient {
.context("client request was not a valid JSON-RPC request")?;
request.trace = current_span_w3c_trace_context();
let request_json = serde_json::to_string(&request)?;
let request_pretty = serde_json::to_string_pretty(&request)?;
let mut request_for_logging = serde_json::to_value(&request)?;
if request.method == "account/login/start"
&& let Some(api_key) = request_for_logging.pointer_mut("/params/apiKey")
{
*api_key = Value::String("<redacted>".to_string());
}
let request_pretty = serde_json::to_string_pretty(&request_for_logging)?;
print_multiline_with_prefix("> ", &request_pretty);
self.write_payload(&request_json)
}
@@ -392,6 +392,7 @@ use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::complete_device_code_login;
use codex_login::login_with_api_key;
use codex_login::login_with_bedrock_api_key;
use codex_login::oauth_client_id;
use codex_login::request_device_code;
use codex_login::run_login_server;
@@ -506,6 +507,7 @@ use codex_app_server_protocol::ServerRequest;
mod account_processor;
mod apps_processor;
mod bedrock_auth;
mod catalog_processor;
mod command_exec_processor;
mod config_processor;
@@ -1,7 +1,9 @@
use super::bedrock_auth::set_user_model_provider_to_bedrock;
use super::*;
use crate::auth_mode::auth_mode_to_api;
use crate::external_auth::ExternalAuthBridge;
use chrono::DateTime;
use codex_model_provider::is_supported_amazon_bedrock_region;
mod rate_limit_resets;
@@ -191,6 +193,20 @@ impl AccountRequestProcessor {
}
}
async fn load_latest_config(&self) -> Config {
match self
.config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
{
Ok(config) => config,
Err(err) => {
tracing::warn!("failed to reload config, using startup config: {err}");
self.config.as_ref().clone()
}
}
}
async fn maybe_refresh_plugin_caches_for_current_config(
config_manager: &ConfigManager,
thread_manager: &Arc<ThreadManager>,
@@ -293,8 +309,9 @@ impl AccountRequestProcessor {
)
.await;
}
LoginAccountParams::AmazonBedrock { .. } => {
return Err(invalid_request("Amazon Bedrock login is not implemented"));
LoginAccountParams::AmazonBedrock { api_key, region } => {
self.login_amazon_bedrock_v2(request_id, api_key, region)
.await;
}
}
Ok(())
@@ -359,6 +376,65 @@ impl AccountRequestProcessor {
}
}
async fn login_amazon_bedrock_v2(
&self,
request_id: ConnectionRequestId,
api_key: String,
region: String,
) {
let result = async {
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)
) {
return Err(invalid_request(
"Amazon Bedrock login is disabled. Use ChatGPT login instead.",
));
}
let api_key = api_key.trim();
if api_key.is_empty() {
return Err(invalid_request("Amazon Bedrock API key must not be empty."));
}
let region = region.trim();
if !is_supported_amazon_bedrock_region(region) {
return Err(invalid_request(format!(
"Amazon Bedrock Mantle does not support region `{region}`"
)));
}
{
let mut guard = self.active_login.lock().await;
if let Some(active) = guard.take() {
drop(active);
}
}
set_user_model_provider_to_bedrock(&self.config_manager).await?;
login_with_bedrock_api_key(
&self.config.codex_home,
api_key,
region,
self.config.cli_auth_credentials_store_mode,
self.config.auth_keyring_backend_kind(),
)
.map_err(|err| internal_error(format!("failed to save Amazon Bedrock auth: {err}")))?;
self.auth_manager.reload().await;
Ok(LoginAccountResponse::AmazonBedrock {})
}
.await;
let logged_in = result.is_ok();
self.outgoing.send_result(request_id, result).await;
if logged_in {
self.send_login_success_notifications(/*login_id*/ None)
.await;
}
}
// Build options for a ChatGPT login attempt; performs validation.
async fn login_chatgpt_common(
&self,
@@ -837,7 +913,8 @@ impl AccountRequestProcessor {
// Determine whether auth is required based on the active model provider.
// If a custom provider is configured with `requires_openai_auth == false`,
// then no auth step is required; otherwise, default to requiring auth.
let requires_openai_auth = self.config.model_provider.requires_openai_auth;
let config = self.load_latest_config().await;
let requires_openai_auth = config.model_provider.requires_openai_auth;
let response = if !requires_openai_auth {
GetAuthStatusResponse {
@@ -905,10 +982,9 @@ impl AccountRequestProcessor {
self.refresh_token_if_requested(do_refresh).await;
let provider = create_model_provider(
self.config.model_provider.clone(),
Some(self.auth_manager.clone()),
);
let config = self.load_latest_config().await;
let provider =
create_model_provider(config.model_provider, Some(self.auth_manager.clone()));
let account_state = match provider.account_state() {
Ok(account_state) => account_state,
Err(err) => return Err(invalid_request(err.to_string())),
@@ -0,0 +1,72 @@
use super::config_processor::map_error as map_config_error;
use crate::config_manager::ConfigManager;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::MergeStrategy;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerSource;
use codex_config::format_config_layer_source;
use codex_model_provider::AMAZON_BEDROCK_PROVIDER_ID;
pub(super) async fn set_user_model_provider_to_bedrock(
config_manager: &ConfigManager,
) -> Result<(), JSONRPCErrorError> {
let layers = config_manager
.load_config_layers(/*cwd*/ None)
.await
.map_err(|err| internal_error(format!("failed to load configuration layers: {err}")))?;
let user_precedence = match layers.get_active_user_layer() {
Some(layer) => layer.name.precedence(),
None => ConfigLayerSource::User {
file: config_manager.user_config_path().map_err(|err| {
internal_error(format!("failed to resolve user config path: {err}"))
})?,
profile: None,
}
.precedence(),
};
if let Some((overriding_layer, effective_provider)) = layers
.layers_high_to_low()
.into_iter()
.filter(|layer| layer.name.precedence() > user_precedence)
.find_map(|layer| {
layer
.config
.get("model_provider")
.map(|value| (layer, value))
})
&& effective_provider.as_str() != Some(AMAZON_BEDROCK_PROVIDER_ID)
{
let source = format_config_layer_source(&overriding_layer.name, CONFIG_TOML_FILE);
return Err(invalid_request(format!(
"Amazon Bedrock login cannot select `{AMAZON_BEDROCK_PROVIDER_ID}` because {source} sets `model_provider` to {effective_provider}"
)));
}
write_user_model_provider(
config_manager,
serde_json::json!(AMAZON_BEDROCK_PROVIDER_ID),
/*expected_version*/ None,
)
.await
}
async fn write_user_model_provider(
config_manager: &ConfigManager,
value: serde_json::Value,
expected_version: Option<String>,
) -> Result<(), JSONRPCErrorError> {
config_manager
.write_value(ConfigValueWriteParams {
key_path: "model_provider".to_string(),
value,
merge_strategy: MergeStrategy::Replace,
file_path: None,
expected_version,
})
.await
.map(|_| ())
.map_err(map_config_error)
}
@@ -555,7 +555,7 @@ fn map_network_unix_socket_permission_to_api(
}
}
fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
pub(super) fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
if let Some(code) = err.write_error_code() {
return config_write_error(code, err.to_string());
}
@@ -1335,6 +1335,20 @@ impl TestAppServer {
self.send_login_account_request(params).await
}
/// Send an `account/login/start` JSON-RPC request for managed Amazon Bedrock login.
pub async fn send_login_account_amazon_bedrock_request(
&mut self,
api_key: &str,
region: &str,
) -> anyhow::Result<i64> {
let params = serde_json::json!({
"type": "amazonBedrock",
"apiKey": api_key,
"region": region,
});
self.send_request("account/login/start", Some(params)).await
}
/// Send an `account/login/start` JSON-RPC request for ChatGPT login.
pub async fn send_login_account_chatgpt_request(&mut self) -> anyhow::Result<i64> {
let params = serde_json::json!({
@@ -5,24 +5,30 @@ use app_test_support::to_response;
use app_test_support::ChatGptAuthFixture;
use app_test_support::ChatGptIdTokenClaims;
use app_test_support::DEFAULT_CLIENT_NAME;
use app_test_support::encode_id_token;
use app_test_support::write_chatgpt_auth;
use app_test_support::write_models_cache;
use chrono::Duration as ChronoDuration;
use chrono::Utc;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::CancelLoginAccountParams;
use codex_app_server_protocol::CancelLoginAccountResponse;
use codex_app_server_protocol::CancelLoginAccountStatus;
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountResponse;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
@@ -33,13 +39,17 @@ use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::AuthDotJson;
use codex_login::AuthKeyringBackendKind;
use codex_login::CLIENT_ID_OVERRIDE_ENV_VAR;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_login::auth::BedrockApiKeyAuth;
use codex_login::load_auth_dot_json;
use codex_login::login_with_api_key;
use codex_login::login_with_bedrock_api_key;
use codex_protocol::account::AmazonBedrockCredentialSource;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::auth::AuthMode as DomainAuthMode;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -142,6 +152,56 @@ shell_snapshot = false
std::fs::write(config_toml, contents)
}
fn read_config_toml(codex_home: &Path) -> Result<toml::Value> {
Ok(toml::from_str(&std::fs::read_to_string(
codex_home.join("config.toml"),
)?)?)
}
fn load_file_auth(codex_home: &Path) -> Result<Option<AuthDotJson>> {
Ok(load_auth_dot_json(
codex_home,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)?)
}
async fn read_account(mcp: &mut TestAppServer) -> Result<GetAccountResponse> {
let request_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}
async fn assert_account_updated(
mcp: &mut TestAppServer,
auth_mode: Option<AuthMode>,
) -> Result<()> {
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("account/updated"),
)
.await??;
let ServerNotification::AccountUpdated(payload) = notification.try_into()? else {
bail!("unexpected notification")
};
assert_eq!(
payload,
AccountUpdatedNotification {
auth_mode,
plan_type: None,
}
);
Ok(())
}
async fn mock_device_code_usercode(server: &MockServer, interval_seconds: u64) {
Mock::given(method("POST"))
.and(path("/api/accounts/deviceauth/usercode"))
@@ -1011,6 +1071,420 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn login_amazon_bedrock_replaces_primary_auth_and_persists_provider() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
login_with_api_key(
codex_home.path(),
"sk-test-key",
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let mut expected_config = read_config_toml(codex_home.path())?;
expected_config
.as_table_mut()
.expect("config should be a table")
.insert(
"model_provider".to_string(),
toml::Value::String("amazon-bedrock".to_string()),
);
let request_id = mcp
.send_login_account_amazon_bedrock_request(" managed-bedrock-api-key ", " us-west-2 ")
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
to_response::<LoginAccountResponse>(response)?,
LoginAccountResponse::AmazonBedrock {}
);
assert_eq!(
load_file_auth(codex_home.path())?,
Some(AuthDotJson {
auth_mode: Some(DomainAuthMode::BedrockApiKey),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: Some(BedrockApiKeyAuth {
api_key: "managed-bedrock-api-key".to_string(),
region: "us-west-2".to_string(),
}),
})
);
assert_eq!(read_config_toml(codex_home.path())?, expected_config);
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("account/login/completed"),
)
.await??;
let ServerNotification::AccountLoginCompleted(payload) = notification.try_into()? else {
bail!("unexpected notification")
};
assert_eq!(
payload,
AccountLoginCompletedNotification {
login_id: None,
success: true,
error: None,
}
);
assert_account_updated(&mut mcp, Some(AuthMode::BedrockApiKey)).await?;
Ok(())
}
#[tokio::test]
async fn login_amazon_bedrock_rejects_non_bedrock_provider_override_without_changes() -> Result<()>
{
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
login_with_api_key(
codex_home.path(),
"sk-test-key",
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)?;
let expected_auth = load_file_auth(codex_home.path())?;
let expected_config = read_config_toml(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.with_args(&["-c", "model_provider=\"mock_provider\""])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2")
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
error.error.message,
"Amazon Bedrock login cannot select `amazon-bedrock` because session-flags sets `model_provider` to \"mock_provider\""
);
assert_eq!(load_file_auth(codex_home.path())?, expected_auth);
assert_eq!(read_config_toml(codex_home.path())?, expected_config);
let maybe_completed = timeout(
Duration::from_millis(500),
mcp.read_stream_until_notification_message("account/login/completed"),
)
.await;
assert!(
maybe_completed.is_err(),
"account/login/completed should not be emitted when the provider is overridden"
);
let maybe_updated = timeout(
Duration::from_millis(500),
mcp.read_stream_until_notification_message("account/updated"),
)
.await;
assert!(
maybe_updated.is_err(),
"account/updated should not be emitted when the provider is overridden"
);
Ok(())
}
#[tokio::test]
async fn login_amazon_bedrock_allows_bedrock_provider_override() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mut expected_config = read_config_toml(codex_home.path())?;
expected_config
.as_table_mut()
.expect("config should be a table")
.insert(
"model_provider".to_string(),
toml::Value::String("amazon-bedrock".to_string()),
);
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.with_args(&["-c", "model_provider=\"amazon-bedrock\""])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2")
.await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
to_response::<LoginAccountResponse>(response)?,
LoginAccountResponse::AmazonBedrock {}
);
assert_eq!(
load_file_auth(codex_home.path())?,
Some(AuthDotJson {
auth_mode: Some(DomainAuthMode::BedrockApiKey),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: Some(BedrockApiKeyAuth {
api_key: "managed-bedrock-api-key".to_string(),
region: "us-west-2".to_string(),
}),
})
);
assert_eq!(read_config_toml(codex_home.path())?, expected_config);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("account/login/completed"),
)
.await??;
assert_account_updated(&mut mcp, Some(AuthMode::BedrockApiKey)).await?;
Ok(())
}
#[tokio::test]
async fn managed_bedrock_login_requires_experimental_api() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
let initialized = mcp
.initialize_with_capabilities(
ClientInfo {
name: DEFAULT_CLIENT_NAME.to_string(),
title: None,
version: "0.1.0".to_string(),
},
Some(InitializeCapabilities {
experimental_api: false,
..Default::default()
}),
)
.await?;
assert!(matches!(initialized, JSONRPCMessage::Response(_)));
let request_id = mcp
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2")
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
error.error.message,
"account/login/start.amazonBedrock requires experimentalApi capability"
);
assert_eq!(load_file_auth(codex_home.path())?, None);
Ok(())
}
#[tokio::test]
async fn login_managed_bedrock_updates_active_bedrock_account() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2")
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
to_response::<LoginAccountResponse>(response)?,
LoginAccountResponse::AmazonBedrock {}
);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("account/login/completed"),
)
.await??;
assert_account_updated(&mut mcp, Some(AuthMode::BedrockApiKey)).await?;
assert_eq!(
read_account(&mut mcp).await?,
GetAccountResponse {
account: Some(Account::AmazonBedrock {
credential_source: AmazonBedrockCredentialSource::CodexManaged,
}),
requires_openai_auth: false,
}
);
assert!(codex_home.path().join("auth.json").exists());
Ok(())
}
#[tokio::test]
async fn login_account_amazon_bedrock_rejects_invalid_credentials_without_changes() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let expected_config = read_config_toml(codex_home.path())?;
let request_id = mcp
.send_login_account_amazon_bedrock_request(" ", "us-west-2")
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
error.error.message,
"Amazon Bedrock API key must not be empty."
);
let request_id = mcp
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-1")
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
error.error.message,
"Amazon Bedrock Mantle does not support region `us-west-1`"
);
assert_eq!(load_file_auth(codex_home.path())?, None);
assert_eq!(read_config_toml(codex_home.path())?, expected_config);
Ok(())
}
#[tokio::test]
async fn login_account_amazon_bedrock_rejected_when_forced_chatgpt() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
forced_method: Some("chatgpt".to_string()),
..Default::default()
},
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2")
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
error.error.message,
"Amazon Bedrock login is disabled. Use ChatGPT login instead."
);
assert_eq!(load_file_auth(codex_home.path())?, None);
Ok(())
}
#[tokio::test]
async fn login_account_amazon_bedrock_rejected_with_external_chatgpt_auth() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let access_token = encode_id_token(
&ChatGptIdTokenClaims::new()
.email("embedded@example.com")
.plan_type("pro")
.chatgpt_account_id(WORKSPACE_ID_EMBEDDED),
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let set_id = mcp
.send_chatgpt_auth_tokens_login_request(
access_token,
WORKSPACE_ID_EMBEDDED.to_string(),
Some("pro".to_string()),
)
.await?;
let set_response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(set_id)),
)
.await??;
assert_eq!(
to_response::<LoginAccountResponse>(set_response)?,
LoginAccountResponse::ChatgptAuthTokens {}
);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("account/updated"),
)
.await??;
let request_id = mcp
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2")
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
error.error.message,
"External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it."
);
assert_eq!(load_file_auth(codex_home.path())?, None);
Ok(())
}
#[tokio::test]
async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1903,6 +2377,66 @@ region = "us-west-2"
Ok(())
}
#[tokio::test]
async fn account_reads_use_startup_config_when_config_reload_fails() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
model_provider_id: Some("amazon-bedrock".to_string()),
extra_provider_config: Some(
r#"[model_providers.amazon-bedrock.aws]
profile = "codex-bedrock"
region = "us-west-2"
"#
.to_string(),
),
..Default::default()
},
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
std::fs::write(codex_home.path().join("config.toml"), "invalid = [")?;
assert_eq!(
read_account(&mut mcp).await?,
GetAccountResponse {
account: Some(Account::AmazonBedrock {
credential_source: AmazonBedrockCredentialSource::AwsManaged,
}),
requires_openai_auth: false,
}
);
let request_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(false),
refresh_token: Some(false),
})
.await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
to_response::<GetAuthStatusResponse>(response)?,
GetAuthStatusResponse {
auth_method: None,
auth_token: None,
requires_openai_auth: Some(false),
}
);
Ok(())
}
#[tokio::test]
async fn get_account_with_managed_bedrock_provider() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -39,8 +39,13 @@ pub(super) fn region_from_config(aws: &ModelProviderAwsAuthInfo) -> Option<Strin
.map(str::to_string)
}
/// Returns whether Amazon Bedrock Mantle is available in `region`.
pub fn is_supported_amazon_bedrock_region(region: &str) -> bool {
BEDROCK_MANTLE_SUPPORTED_REGIONS.contains(&region)
}
pub(super) fn base_url(region: &str) -> Result<String> {
if BEDROCK_MANTLE_SUPPORTED_REGIONS.contains(&region) {
if is_supported_amazon_bedrock_region(region) {
Ok(format!("https://bedrock-mantle.{region}.api.aws/openai/v1"))
} else {
Err(CodexErr::Fatal(format!(
@@ -31,6 +31,7 @@ use crate::provider::ProviderCapabilities;
use auth::resolve_provider_auth;
pub(crate) use catalog::static_model_catalog;
use catalog::with_default_only_service_tier;
pub use mantle::is_supported_amazon_bedrock_region;
use mantle::runtime_base_url;
/// Runtime provider for Amazon Bedrock's OpenAI-compatible Mantle endpoint.
+2
View File
@@ -4,6 +4,7 @@ mod bearer_auth_provider;
mod models_endpoint;
mod provider;
pub use amazon_bedrock::is_supported_amazon_bedrock_region;
pub use auth::AgentIdentitySessionFallback;
pub use auth::ProviderAuthScope;
pub use auth::ResolvedProviderAuth;
@@ -12,6 +13,7 @@ pub use auth::auth_provider_from_auth_manager;
pub use auth::unauthenticated_auth_provider;
pub use bearer_auth_provider::BearerAuthProvider;
pub use bearer_auth_provider::BearerAuthProvider as CoreAuthProvider;
pub use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID;
pub use codex_model_provider_info::CHATGPT_CODEX_BASE_URL;
pub use codex_protocol::account::ProviderAccount;
pub use provider::ModelProvider;