mirror of
https://github.com/openai/codex.git
synced 2026-11-09 08:40:33 +00:00
Add AWS credential export commands for Amazon Bedrock (#44028)
## What changed Support `model_providers.amazon-bedrock.aws.credential_export` with `command`, `args`, and `timeout_ms` to supply SigV4 signing credentials from a command's JSON output. Accept both flat credential-process output and nested STS `Credentials` objects. Cache credentials in memory, refresh before expiration, and share exports across sessions with matching AWS configuration. On recoverable authentication failures, run the optional `aws.auth_refresh` command before exporting fresh credentials, coalescing concurrent recovery attempts. Bound command execution time and output size, and keep credential values out of errors. Reject combining `aws.credential_export` with `aws.profile`. Bedrock setup and login reject changes while an exporter is configured, preserving configuration and saved credentials. ## Testing Add coverage for output formats, expiration, credential precedence, caching, concurrent recovery, command failures, and secret redaction. Integration tests verify request signing with rotated credentials, bounded recovery attempts, and setup/login rejection across configuration layers. GitOrigin-RevId: 4ef799f88ab5e3b2578f8f41a5e56e4f30e2cd81
This commit is contained in:
1 parent
2ce38ae6d8
commit
fe52d795c9
26 files changed
+1790
-89
No files matched your search
Generated
+3
@@ -3973,6 +3973,7 @@ name = "codex-model-provider"
|
|||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"chrono",
|
||||||
"codex-agent-identity",
|
"codex-agent-identity",
|
||||||
"codex-api",
|
"codex-api",
|
||||||
"codex-aws-auth",
|
"codex-aws-auth",
|
||||||
@@ -3987,8 +3988,10 @@ dependencies = [
|
|||||||
"codex-utils-redacted-string",
|
"codex-utils-redacted-string",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"wiremock",
|
"wiremock",
|
||||||
|
|||||||
@@ -31,3 +31,10 @@ after a client tries to archive or delete it.
|
|||||||
|
|
||||||
After the owner releases the worker, its saved conversation can be archived or
|
After the owner releases the worker, its saved conversation can be archived or
|
||||||
deleted normally. Ordinary client-controlled threads keep their existing behavior.
|
deleted normally. Ordinary client-controlled threads keep their existing behavior.
|
||||||
|
|
||||||
|
# Amazon Bedrock authentication
|
||||||
|
|
||||||
|
If `model_providers.amazon-bedrock.aws.credential_export` is configured, Bedrock setup and
|
||||||
|
Bedrock login return an error without changing configuration or saved credentials. Remove the
|
||||||
|
exporter configuration before selecting another credential source. `aws.credential_export` and
|
||||||
|
`aws.profile` cannot be configured together.
|
||||||
@@ -99,6 +99,19 @@ pub(super) async fn ensure_user_model_provider_can_be_bedrock(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if layers
|
||||||
|
.effective_config()
|
||||||
|
.get("model_providers")
|
||||||
|
.and_then(|providers| providers.get(AMAZON_BEDROCK_PROVIDER_ID))
|
||||||
|
.and_then(|provider| provider.get("aws"))
|
||||||
|
.and_then(|aws| aws.get("credential_export"))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(invalid_request(
|
||||||
|
"Amazon Bedrock is configured to use `aws.credential_export`. Please clear this setting to use another sign-in method.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use codex_app_server_protocol::RequestId;
|
|||||||
use codex_config::types::AuthCredentialsStoreMode;
|
use codex_config::types::AuthCredentialsStoreMode;
|
||||||
use codex_login::AuthKeyringBackendKind;
|
use codex_login::AuthKeyringBackendKind;
|
||||||
use codex_login::login_with_bedrock_api_key;
|
use codex_login::login_with_bedrock_api_key;
|
||||||
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -122,11 +123,10 @@ async fn setup_bedrock_profile_and_environment() -> Result<()> {
|
|||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
let config_path = codex_home.path().join("config.toml");
|
let config_path = codex_home.path().join("config.toml");
|
||||||
let dotenv_path = codex_home.path().join(".env");
|
let dotenv_path = codex_home.path().join(".env");
|
||||||
std::fs::write(
|
let existing_config = "[model_providers.amazon-bedrock]\nhttp_headers = { X-Existing = \"preserved\" }\n\
|
||||||
&config_path,
|
[model_providers.amazon-bedrock.aws]\nprofile = \"old\"\nregion = \"us-east-1\"\n\
|
||||||
"[model_providers.amazon-bedrock]\nhttp_headers = { X-Existing = \"preserved\" }\n\
|
auth_refresh = { command = \"aws\", args = [\"login\"] }\n";
|
||||||
[model_providers.amazon-bedrock.aws]\nprofile = \"old\"\nregion = \"us-east-1\"\n",
|
std::fs::write(&config_path, existing_config)?;
|
||||||
)?;
|
|
||||||
let existing_dotenv = "# existing configuration\nUNRELATED=value\nAWS_ACCESS_KEY_ID=old-id\n\
|
let existing_dotenv = "# existing configuration\nUNRELATED=value\nAWS_ACCESS_KEY_ID=old-id\n\
|
||||||
export AWS_SECRET_ACCESS_KEY=old-secret\nAWS_SESSION_TOKEN=stale-token\n";
|
export AWS_SECRET_ACCESS_KEY=old-secret\nAWS_SESSION_TOKEN=stale-token\n";
|
||||||
std::fs::write(&dotenv_path, existing_dotenv)?;
|
std::fs::write(&dotenv_path, existing_dotenv)?;
|
||||||
@@ -153,10 +153,15 @@ async fn setup_bedrock_profile_and_environment() -> Result<()> {
|
|||||||
let config: toml::Value = toml::from_str(&std::fs::read_to_string(&config_path)?)?;
|
let config: toml::Value = toml::from_str(&std::fs::read_to_string(&config_path)?)?;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_value(&config["model_providers"]["amazon-bedrock"]["aws"])?,
|
serde_json::to_value(&config["model_providers"]["amazon-bedrock"]["aws"])?,
|
||||||
json!({"profile": "engineering", "region": "us-west-2"})
|
json!({
|
||||||
|
"profile": "engineering",
|
||||||
|
"region": "us-west-2",
|
||||||
|
"auth_refresh": {"command": "aws", "args": ["login"]}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(std::fs::read_to_string(&dotenv_path)?, existing_dotenv);
|
assert_eq!(std::fs::read_to_string(&dotenv_path)?, existing_dotenv);
|
||||||
|
|
||||||
|
std::fs::write(&config_path, existing_config)?;
|
||||||
let response: BedrockSetupResponse = app_server
|
let response: BedrockSetupResponse = app_server
|
||||||
.request(|request_id| ClientRequest::BedrockSetup {
|
.request(|request_id| ClientRequest::BedrockSetup {
|
||||||
request_id,
|
request_id,
|
||||||
@@ -170,7 +175,10 @@ async fn setup_bedrock_profile_and_environment() -> Result<()> {
|
|||||||
let config: toml::Value = toml::from_str(&std::fs::read_to_string(&config_path)?)?;
|
let config: toml::Value = toml::from_str(&std::fs::read_to_string(&config_path)?)?;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_value(&config["model_providers"]["amazon-bedrock"]["aws"])?,
|
serde_json::to_value(&config["model_providers"]["amazon-bedrock"]["aws"])?,
|
||||||
json!({"region": "us-east-1"})
|
json!({
|
||||||
|
"region": "us-east-1",
|
||||||
|
"auth_refresh": {"command": "aws", "args": ["login"]}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
let config: toml::Value = toml::from_str(&std::fs::read_to_string(&config_path)?)?;
|
let config: toml::Value = toml::from_str(&std::fs::read_to_string(&config_path)?)?;
|
||||||
@@ -184,6 +192,7 @@ async fn setup_bedrock_profile_and_environment() -> Result<()> {
|
|||||||
|
|
||||||
[model_providers.amazon-bedrock.aws]
|
[model_providers.amazon-bedrock.aws]
|
||||||
region = "us-east-1"
|
region = "us-east-1"
|
||||||
|
auth_refresh = { command = "aws", args = ["login"] }
|
||||||
}
|
}
|
||||||
.into()
|
.into()
|
||||||
);
|
);
|
||||||
@@ -319,5 +328,64 @@ async fn setup_bedrock_rejects_invalid_or_conflicting_credentials() -> Result<()
|
|||||||
);
|
);
|
||||||
assert!(!home.path().join(".env").exists());
|
assert!(!home.path().join(".env").exists());
|
||||||
|
|
||||||
|
for source_file in ["selected.config.toml", "config.toml", "managed_config.toml"] {
|
||||||
|
let layered_home = TempDir::new()?;
|
||||||
|
let layered_home_path =
|
||||||
|
AbsolutePathBuf::from_absolute_path(layered_home.path().canonicalize()?)?
|
||||||
|
.into_path_buf();
|
||||||
|
let config_path = layered_home_path.join("config.toml");
|
||||||
|
let source_path = layered_home_path.join(source_file);
|
||||||
|
let active_path = layered_home_path.join("selected.config.toml");
|
||||||
|
let exporter_config = "[model_providers.amazon-bedrock.aws.credential_export]\n\
|
||||||
|
command = \"secret-command\"\nargs = [\"secret-argument\"]\n";
|
||||||
|
std::fs::write(&config_path, "")?;
|
||||||
|
std::fs::write(&active_path, "")?;
|
||||||
|
std::fs::write(&source_path, exporter_config)?;
|
||||||
|
let active_config_path = active_path.to_string_lossy();
|
||||||
|
let mut app_server = bedrock_app_server(
|
||||||
|
&layered_home_path,
|
||||||
|
&[(
|
||||||
|
"CODEX_APP_SERVER_TEST_USER_CONFIG_FILE",
|
||||||
|
Some(active_config_path.as_ref()),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let expected_config = std::fs::read_to_string(&config_path)?;
|
||||||
|
let expected_active = std::fs::read_to_string(&active_path)?;
|
||||||
|
let expected_error = "Amazon Bedrock is configured to use `aws.credential_export`. Please clear this setting to use another sign-in method.";
|
||||||
|
|
||||||
|
for (method, params) in [
|
||||||
|
(
|
||||||
|
"account/bedrock/setup",
|
||||||
|
json!({"type": "profile", "profile": "engineering", "region": "us-west-2"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"account/bedrock/setup",
|
||||||
|
json!({"type": "environment", "region": "us-west-2"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"account/login/start",
|
||||||
|
json!({"type": "amazonBedrock", "apiKey": "new-key", "region": "us-west-2"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"account/login/start",
|
||||||
|
json!({"type": "amazonBedrockAccessKeys", "accessKeyId": "new-id", "secretAccessKey": "new-secret", "region": "us-west-2"}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let request_id = app_server.send_raw_request(method, Some(params)).await?;
|
||||||
|
let error = timeout(
|
||||||
|
READ_TIMEOUT,
|
||||||
|
app_server.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
assert_eq!(error.error.message, expected_error);
|
||||||
|
}
|
||||||
|
assert_eq!(std::fs::read_to_string(&config_path)?, expected_config);
|
||||||
|
assert_eq!(std::fs::read_to_string(&source_path)?, exporter_config);
|
||||||
|
assert_eq!(std::fs::read_to_string(&active_path)?, expected_active);
|
||||||
|
assert!(!layered_home.path().join("auth.json").exists());
|
||||||
|
assert!(!layered_home.path().join(".env").exists());
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ mod config;
|
|||||||
mod discovery;
|
mod discovery;
|
||||||
mod signing;
|
mod signing;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use aws_credential_types::provider::ProvideCredentials;
|
use aws_credential_types::provider::ProvideCredentials;
|
||||||
@@ -44,6 +45,55 @@ impl std::fmt::Debug for AwsAccessKeys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Supplies AWS access keys on demand without exposing AWS SDK credential types to callers.
|
||||||
|
///
|
||||||
|
/// Implementations should return current credentials for every call so request signing can
|
||||||
|
/// observe credential refreshes. Errors must not contain credentials or command output.
|
||||||
|
pub trait AwsCredentialsProvider: std::fmt::Debug + Send + Sync {
|
||||||
|
fn credentials(
|
||||||
|
&self,
|
||||||
|
) -> impl std::future::Future<Output = std::io::Result<AwsAccessKeys>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct AwsCredentialsProviderAdapter<P>(Arc<P>);
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[error("{0}")]
|
||||||
|
struct ProvidedCredentialsError(std::io::Error);
|
||||||
|
|
||||||
|
impl<P: AwsCredentialsProvider> ProvideCredentials for AwsCredentialsProviderAdapter<P> {
|
||||||
|
fn provide_credentials<'a>(
|
||||||
|
&'a self,
|
||||||
|
) -> aws_credential_types::provider::future::ProvideCredentials<'a>
|
||||||
|
where
|
||||||
|
Self: 'a,
|
||||||
|
{
|
||||||
|
aws_credential_types::provider::future::ProvideCredentials::new(async move {
|
||||||
|
let access_keys = self.0.credentials().await.map_err(|error| {
|
||||||
|
let error = ProvidedCredentialsError(error);
|
||||||
|
match error.0.kind() {
|
||||||
|
std::io::ErrorKind::InvalidData
|
||||||
|
| std::io::ErrorKind::InvalidInput
|
||||||
|
| std::io::ErrorKind::NotFound
|
||||||
|
| std::io::ErrorKind::PermissionDenied => {
|
||||||
|
aws_credential_types::provider::error::CredentialsError::invalid_configuration(error)
|
||||||
|
}
|
||||||
|
_ => aws_credential_types::provider::error::CredentialsError::provider_error(error),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(aws_credential_types::Credentials::new(
|
||||||
|
access_keys.access_key_id,
|
||||||
|
access_keys.secret_access_key,
|
||||||
|
access_keys.session_token,
|
||||||
|
/*expires_after*/ None,
|
||||||
|
"codex-bedrock-credential-export",
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Generic HTTP request shape consumed by SigV4 signing.
|
/// Generic HTTP request shape consumed by SigV4 signing.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct AwsRequestToSign {
|
pub struct AwsRequestToSign {
|
||||||
@@ -135,6 +185,16 @@ impl AwsAuthContext {
|
|||||||
Ok(context)
|
Ok(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn load_with_credentials_provider(
|
||||||
|
config: AwsAuthConfig,
|
||||||
|
provider: Arc<impl AwsCredentialsProvider + 'static>,
|
||||||
|
) -> Result<Self, AwsAuthError> {
|
||||||
|
let mut context = Self::load(config).await?;
|
||||||
|
context.credentials_provider =
|
||||||
|
SharedCredentialsProvider::new(AwsCredentialsProviderAdapter(provider));
|
||||||
|
Ok(context)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_profile(config: AwsAuthConfig) -> Result<Self, AwsAuthError> {
|
pub async fn load_profile(config: AwsAuthConfig) -> Result<Self, AwsAuthError> {
|
||||||
let profile = config
|
let profile = config
|
||||||
.profile
|
.profile
|
||||||
@@ -171,6 +231,16 @@ impl AwsAuthContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AwsAuthError {
|
impl AwsAuthError {
|
||||||
|
/// Returns the caller-supplied credential error without exposing SDK error sources.
|
||||||
|
pub fn credentials_provider_error(&self) -> Option<&std::io::Error> {
|
||||||
|
let Self::Credentials(error) = self else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
std::error::Error::source(error)?
|
||||||
|
.downcast_ref::<ProvidedCredentialsError>()
|
||||||
|
.map(|error| &error.0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns whether retrying the outbound request can reasonably recover from this auth error.
|
/// Returns whether retrying the outbound request can reasonably recover from this auth error.
|
||||||
pub fn is_retryable(&self) -> bool {
|
pub fn is_retryable(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
@@ -263,6 +333,68 @@ mod tests {
|
|||||||
assert!(signing::header_value(&signed.headers, "x-amz-date").is_some());
|
assert!(signing::header_value(&signed.headers, "x-amz-date").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn credentials_provider_adapter_converts_keys_and_provider_failures() {
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TestCredentialsProvider(Result<AwsAccessKeys, std::io::ErrorKind>);
|
||||||
|
|
||||||
|
impl AwsCredentialsProvider for TestCredentialsProvider {
|
||||||
|
async fn credentials(&self) -> std::io::Result<AwsAccessKeys> {
|
||||||
|
self.0
|
||||||
|
.clone()
|
||||||
|
.map_err(|kind| std::io::Error::new(kind, "credential export failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let credentials =
|
||||||
|
AwsCredentialsProviderAdapter(Arc::new(TestCredentialsProvider(Ok(AwsAccessKeys {
|
||||||
|
access_key_id: "access-key-id".to_string(),
|
||||||
|
secret_access_key: "secret-access-key".to_string(),
|
||||||
|
session_token: Some("session-token".to_string()),
|
||||||
|
}))))
|
||||||
|
.provide_credentials()
|
||||||
|
.await
|
||||||
|
.expect("exported credentials should be available");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
credentials,
|
||||||
|
Credentials::new(
|
||||||
|
"access-key-id",
|
||||||
|
"secret-access-key",
|
||||||
|
Some("session-token".to_string()),
|
||||||
|
/*expires_after*/ None,
|
||||||
|
"codex-bedrock-credential-export",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (kind, retryable) in [
|
||||||
|
(std::io::ErrorKind::Other, true),
|
||||||
|
(std::io::ErrorKind::TimedOut, true),
|
||||||
|
(std::io::ErrorKind::InvalidData, false),
|
||||||
|
(std::io::ErrorKind::InvalidInput, false),
|
||||||
|
(std::io::ErrorKind::NotFound, false),
|
||||||
|
(std::io::ErrorKind::PermissionDenied, false),
|
||||||
|
] {
|
||||||
|
let error = AwsCredentialsProviderAdapter(Arc::new(TestCredentialsProvider(Err(kind))))
|
||||||
|
.provide_credentials()
|
||||||
|
.await
|
||||||
|
.expect_err("credential export failure should be propagated");
|
||||||
|
let error = AwsAuthError::Credentials(error);
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
error.is_retryable(),
|
||||||
|
error
|
||||||
|
.credentials_provider_error()
|
||||||
|
.map(|error| (error.kind(), error.to_string())),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
retryable,
|
||||||
|
Some((kind, "credential export failed".to_string()))
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn credentials_provider_failures_are_retryable() {
|
fn credentials_provider_failures_are_retryable() {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ region = "us-west-2"
|
|||||||
Some(ModelProviderAwsAuthInfo {
|
Some(ModelProviderAwsAuthInfo {
|
||||||
profile: Some("runtime-profile".to_string()),
|
profile: Some("runtime-profile".to_string()),
|
||||||
region: Some("us-west-2".to_string()),
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -419,6 +419,35 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"AwsCredentialExportConfig": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Command used to export AWS signing credentials for a model provider.",
|
||||||
|
"properties": {
|
||||||
|
"args": {
|
||||||
|
"default": [],
|
||||||
|
"description": "Arguments passed to the credential export command.",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"description": "Executable to invoke directly, without a shell.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"timeout_ms": {
|
||||||
|
"default": 30000,
|
||||||
|
"description": "Maximum time to wait for the credential export command to complete.",
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 1.0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"command"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"BrowserUseConfigToml": {
|
"BrowserUseConfigToml": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2215,6 +2244,14 @@
|
|||||||
],
|
],
|
||||||
"description": "Optional command used to reauthenticate after a refreshable AWS auth failure."
|
"description": "Optional command used to reauthenticate after a refreshable AWS auth failure."
|
||||||
},
|
},
|
||||||
|
"credential_export": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/AwsCredentialExportConfig"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Optional command whose exported credentials replace the AWS SDK credential chain."
|
||||||
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"description": "AWS profile name to use. When unset, the AWS SDK default chain decides.",
|
"description": "AWS profile name to use. When unset, the AWS SDK default chain decides.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
@@ -1241,7 +1241,7 @@ supports_websockets = true
|
|||||||
|
|
||||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||||
assert!(err.to_string().contains(
|
assert!(err.to_string().contains(
|
||||||
"model_providers.amazon-bedrock only supports changing `base_url`, `auth`, `http_headers`, `aws.profile`, `aws.region`, and `aws.auth_refresh`; other non-default provider fields are not supported"
|
"model_providers.amazon-bedrock only supports changing `base_url`, `auth`, `http_headers`, `aws.profile`, `aws.region`, `aws.credential_export`, and `aws.auth_refresh`; other non-default provider fields are not supported"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use codex_login::ExternalAuthFuture;
|
|||||||
use codex_login::ExternalAuthRefreshContext;
|
use codex_login::ExternalAuthRefreshContext;
|
||||||
use codex_login::auth::BedrockAccessKeysAuth;
|
use codex_login::auth::BedrockAccessKeysAuth;
|
||||||
use codex_model_provider_info::AwsAuthRefreshConfig;
|
use codex_model_provider_info::AwsAuthRefreshConfig;
|
||||||
|
use codex_model_provider_info::AwsCredentialExportConfig;
|
||||||
use codex_model_provider_info::ModelProviderAwsAuthInfo;
|
use codex_model_provider_info::ModelProviderAwsAuthInfo;
|
||||||
use codex_model_provider_info::ModelProviderInfo;
|
use codex_model_provider_info::ModelProviderInfo;
|
||||||
use codex_model_provider_info::WireApi;
|
use codex_model_provider_info::WireApi;
|
||||||
@@ -16,16 +17,21 @@ use codex_protocol::user_input::UserInput;
|
|||||||
use codex_utils_redacted_string::RedactedString;
|
use codex_utils_redacted_string::RedactedString;
|
||||||
use core_test_support::responses::ev_completed;
|
use core_test_support::responses::ev_completed;
|
||||||
use core_test_support::responses::ev_response_created;
|
use core_test_support::responses::ev_response_created;
|
||||||
|
use core_test_support::responses::mount_response_sequence;
|
||||||
use core_test_support::responses::mount_sse_once;
|
use core_test_support::responses::mount_sse_once;
|
||||||
|
use core_test_support::responses::mount_sse_sequence;
|
||||||
use core_test_support::responses::sse;
|
use core_test_support::responses::sse;
|
||||||
use core_test_support::responses::start_mock_server;
|
use core_test_support::responses::start_mock_server;
|
||||||
use core_test_support::skip_if_no_network;
|
use core_test_support::skip_if_no_network;
|
||||||
use core_test_support::test_codex::test_codex;
|
use core_test_support::test_codex::test_codex;
|
||||||
|
use core_test_support::wait_for_event;
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
use http::HeaderValue;
|
use http::HeaderValue;
|
||||||
use http::header::AUTHORIZATION;
|
use http::header::AUTHORIZATION;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use std::num::NonZeroU64;
|
use std::num::NonZeroU64;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
@@ -200,6 +206,7 @@ async fn amazon_bedrock_managed_access_keys_sign_requests() -> anyhow::Result<()
|
|||||||
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: Some("us-east-1".to_string()),
|
region: Some("us-east-1".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}));
|
}));
|
||||||
provider.base_url = Some(format!("{}/v1", server.uri()));
|
provider.base_url = Some(format!("{}/v1", server.uri()));
|
||||||
@@ -231,6 +238,191 @@ async fn amazon_bedrock_managed_access_keys_sign_requests() -> anyhow::Result<()
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bedrock_credential_export_config(
|
||||||
|
credentials_path: &Path,
|
||||||
|
invocations_path: &Path,
|
||||||
|
) -> anyhow::Result<AwsCredentialExportConfig> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
let (command, args) = {
|
||||||
|
let script_path = credentials_path.with_extension("sh");
|
||||||
|
std::fs::write(
|
||||||
|
&script_path,
|
||||||
|
"#!/bin/sh\nprintf 'invoked\\n' >> \"$1\"\ncat \"$2\"\n",
|
||||||
|
)?;
|
||||||
|
let mut permissions = std::fs::metadata(&script_path)?.permissions();
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
permissions.set_mode(0o755);
|
||||||
|
}
|
||||||
|
std::fs::set_permissions(&script_path, permissions)?;
|
||||||
|
(
|
||||||
|
script_path.to_string_lossy().into_owned(),
|
||||||
|
vec![
|
||||||
|
invocations_path.to_string_lossy().into_owned(),
|
||||||
|
credentials_path.to_string_lossy().into_owned(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
let (command, args) = {
|
||||||
|
let script_path = credentials_path.with_extension("cmd");
|
||||||
|
std::fs::write(
|
||||||
|
&script_path,
|
||||||
|
"@echo off\r\n>> \"%~1\" echo invoked\r\ntype \"%~2\"\r\n",
|
||||||
|
)?;
|
||||||
|
(
|
||||||
|
"cmd.exe".to_string(),
|
||||||
|
vec![
|
||||||
|
"/D".to_string(),
|
||||||
|
"/Q".to_string(),
|
||||||
|
"/C".to_string(),
|
||||||
|
script_path.to_string_lossy().into_owned(),
|
||||||
|
invocations_path.to_string_lossy().into_owned(),
|
||||||
|
credentials_path.to_string_lossy().into_owned(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(AwsCredentialExportConfig {
|
||||||
|
command,
|
||||||
|
args: args.into_iter().map(RedactedString::from).collect(),
|
||||||
|
timeout_ms: NonZeroU64::new(5_000).expect("timeout should be non-zero"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn amazon_bedrock_credential_export_precedence_and_caching() -> anyhow::Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
let fixture = tempfile::tempdir()?;
|
||||||
|
let credentials_path = fixture.path().join("credentials.json");
|
||||||
|
let invocations_path = fixture.path().join("invocations.txt");
|
||||||
|
std::fs::write(
|
||||||
|
&credentials_path,
|
||||||
|
serde_json::to_vec(&serde_json::json!({
|
||||||
|
"Credentials": {
|
||||||
|
"AccessKeyId": "exported-access-key-id",
|
||||||
|
"SecretAccessKey": "exported-secret-access-key",
|
||||||
|
"SessionToken": "exported-session-token",
|
||||||
|
"Expiration": "2099-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
}))?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let server = start_mock_server().await;
|
||||||
|
let response_mock = mount_sse_sequence(
|
||||||
|
&server,
|
||||||
|
vec![
|
||||||
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||||
|
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut provider =
|
||||||
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
||||||
|
profile: None,
|
||||||
|
region: Some("us-east-1".to_string()),
|
||||||
|
credential_export: Some(bedrock_credential_export_config(
|
||||||
|
&credentials_path,
|
||||||
|
&invocations_path,
|
||||||
|
)?),
|
||||||
|
auth_refresh: None,
|
||||||
|
}));
|
||||||
|
provider.base_url = Some(format!("{}/v1", server.uri()));
|
||||||
|
provider.request_max_retries = Some(0);
|
||||||
|
provider.stream_max_retries = Some(2);
|
||||||
|
|
||||||
|
let mut builder = test_codex()
|
||||||
|
.with_model("openai.gpt-5.5")
|
||||||
|
.with_auth(CodexAuth::BedrockAccessKeys(BedrockAccessKeysAuth {
|
||||||
|
access_key_id: "managed-access-key-id".to_string(),
|
||||||
|
secret_access_key: "managed-secret-access-key".to_string(),
|
||||||
|
session_token: Some("managed-session-token".to_string()),
|
||||||
|
}))
|
||||||
|
.with_config(move |config| {
|
||||||
|
config.model_provider_id = provider.name.clone();
|
||||||
|
config.model_provider = provider;
|
||||||
|
});
|
||||||
|
let test = builder.build_with_auto_env(&server).await?;
|
||||||
|
|
||||||
|
test.submit_turn("first request").await?;
|
||||||
|
test.submit_turn("second request").await?;
|
||||||
|
|
||||||
|
let requests = response_mock.requests();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
for request in requests {
|
||||||
|
let authorization = request
|
||||||
|
.header("authorization")
|
||||||
|
.expect("exported AWS credentials should produce SigV4 authorization");
|
||||||
|
assert!(authorization.starts_with("AWS4-HMAC-SHA256 "));
|
||||||
|
assert!(authorization.contains("Credential=exported-access-key-id/"));
|
||||||
|
assert_eq!(
|
||||||
|
request.header("x-amz-security-token").as_deref(),
|
||||||
|
Some("exported-session-token")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let invocations = std::fs::read_to_string(&invocations_path)?;
|
||||||
|
assert_eq!(invocations.lines().collect::<Vec<_>>(), vec!["invoked"]);
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
&credentials_path,
|
||||||
|
serde_json::to_vec(&serde_json::json!({
|
||||||
|
"Credentials": {
|
||||||
|
"AccessKeyId": "rotated-access-key-id",
|
||||||
|
"SecretAccessKey": "rotated-secret-access-key",
|
||||||
|
"SessionToken": "rotated-session-token",
|
||||||
|
"Expiration": "2099-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
}))?,
|
||||||
|
)?;
|
||||||
|
let rotated_response_mock = mount_response_sequence(
|
||||||
|
&server,
|
||||||
|
vec![
|
||||||
|
ResponseTemplate::new(403).set_body_string("ExpiredTokenException"),
|
||||||
|
ResponseTemplate::new(200)
|
||||||
|
.insert_header("content-type", "text/event-stream")
|
||||||
|
.set_body_string(sse(vec![
|
||||||
|
ev_response_created("resp-3"),
|
||||||
|
ev_completed("resp-3"),
|
||||||
|
])),
|
||||||
|
ResponseTemplate::new(200)
|
||||||
|
.insert_header("content-type", "text/event-stream")
|
||||||
|
.set_body_string(sse(vec![
|
||||||
|
ev_response_created("resp-4"),
|
||||||
|
ev_completed("resp-4"),
|
||||||
|
])),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
test.submit_turn("recover expired credentials").await?;
|
||||||
|
test.submit_turn("reuse rotated credentials").await?;
|
||||||
|
|
||||||
|
let rotated_requests = rotated_response_mock.requests();
|
||||||
|
assert_eq!(rotated_requests.len(), 3);
|
||||||
|
for (request, (access_key_id, session_token)) in rotated_requests.iter().zip([
|
||||||
|
("exported-access-key-id", "exported-session-token"),
|
||||||
|
("rotated-access-key-id", "rotated-session-token"),
|
||||||
|
("rotated-access-key-id", "rotated-session-token"),
|
||||||
|
]) {
|
||||||
|
let authorization = request
|
||||||
|
.header("authorization")
|
||||||
|
.expect("exported AWS credentials should produce SigV4 authorization");
|
||||||
|
assert!(authorization.contains(&format!("Credential={access_key_id}/")));
|
||||||
|
assert_eq!(
|
||||||
|
request.header("x-amz-security-token").as_deref(),
|
||||||
|
Some(session_token)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let invocations = std::fs::read_to_string(invocations_path)?;
|
||||||
|
assert_eq!(
|
||||||
|
invocations.lines().collect::<Vec<_>>(),
|
||||||
|
vec!["invoked", "invoked"]
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn amazon_bedrock_aws_auth_refresh_resigns() -> anyhow::Result<()> {
|
async fn amazon_bedrock_aws_auth_refresh_resigns() -> anyhow::Result<()> {
|
||||||
skip_if_no_network!(Ok(()));
|
skip_if_no_network!(Ok(()));
|
||||||
@@ -252,32 +444,77 @@ async fn amazon_bedrock_aws_auth_refresh_resigns() -> anyhow::Result<()> {
|
|||||||
let inherited_path = std::env::var_os("PATH").unwrap_or_default();
|
let inherited_path = std::env::var_os("PATH").unwrap_or_default();
|
||||||
let mut paths = std::env::split_paths(&inherited_path).collect::<Vec<_>>();
|
let mut paths = std::env::split_paths(&inherited_path).collect::<Vec<_>>();
|
||||||
paths.insert(/*index*/ 0, fixture.path().to_path_buf());
|
paths.insert(/*index*/ 0, fixture.path().to_path_buf());
|
||||||
let credentials = fixture.path().join("credentials");
|
let path = std::env::join_paths(paths)?;
|
||||||
std::fs::write(&credentials, OLD)?;
|
for mode in ["profile", "export", "export-failure", "export-expired"] {
|
||||||
let mut command = Command::new(test_executable);
|
let credentials = fixture.path().join(format!("{mode}-credentials"));
|
||||||
command
|
std::fs::write(&credentials, OLD)?;
|
||||||
.arg("--exact")
|
let mut command = Command::new(&test_executable);
|
||||||
.arg(TEST_NAME)
|
command
|
||||||
.env(SUBPROCESS_ENV, "1")
|
.arg("--exact")
|
||||||
.env("PATH", std::env::join_paths(paths)?)
|
.arg(TEST_NAME)
|
||||||
.env("AWS_SHARED_CREDENTIALS_FILE", &credentials)
|
.env(SUBPROCESS_ENV, mode)
|
||||||
.env("AWS_CONFIG_FILE", &credentials)
|
.env("PATH", &path)
|
||||||
.env("AWS_EC2_METADATA_DISABLED", "true")
|
.env("AWS_SHARED_CREDENTIALS_FILE", &credentials)
|
||||||
.env_remove("AWS_ACCESS_KEY_ID")
|
.env("AWS_CONFIG_FILE", &credentials)
|
||||||
.env_remove("AWS_SECRET_ACCESS_KEY")
|
.env("AWS_EC2_METADATA_DISABLED", "true")
|
||||||
.env_remove("AWS_BEARER_TOKEN_BEDROCK")
|
.env_remove("AWS_ACCESS_KEY_ID")
|
||||||
.env_remove("AWS_WEB_IDENTITY_TOKEN_FILE")
|
.env_remove("AWS_SECRET_ACCESS_KEY")
|
||||||
.env_remove("AWS_ROLE_ARN");
|
.env_remove("AWS_BEARER_TOKEN_BEDROCK")
|
||||||
let output = command.output().await?;
|
.env_remove("AWS_WEB_IDENTITY_TOKEN_FILE")
|
||||||
assert!(output.status.success(), "{output:?}");
|
.env_remove("AWS_ROLE_ARN");
|
||||||
|
let output = command.output().await?;
|
||||||
|
assert!(output.status.success(), "{mode}: {output:?}");
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mode = std::env::var(SUBPROCESS_ENV)?;
|
||||||
|
let persistent_export_failure = mode == "export-failure";
|
||||||
|
let credentials_path = PathBuf::from(std::env::var("AWS_SHARED_CREDENTIALS_FILE")?);
|
||||||
|
let refresh_invocations = credentials_path.with_extension("refreshes");
|
||||||
if std::env::args().any(|argument| argument == HELPER_ARG) {
|
if std::env::args().any(|argument| argument == HELPER_ARG) {
|
||||||
std::fs::write(std::env::var("AWS_SHARED_CREDENTIALS_FILE")?, NEW)?;
|
std::fs::write(&credentials_path, NEW)?;
|
||||||
|
if !persistent_export_failure {
|
||||||
|
std::fs::write(
|
||||||
|
credentials_path.with_extension("json"),
|
||||||
|
serde_json::to_vec(&serde_json::json!({
|
||||||
|
"Version": 1,
|
||||||
|
"AccessKeyId": "NEW",
|
||||||
|
"SecretAccessKey": "s",
|
||||||
|
"SessionToken": "exported-session-token",
|
||||||
|
}))?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let mut invocations = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&refresh_invocations)?;
|
||||||
|
std::io::Write::write_all(&mut invocations, b"refresh\n")?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let export_mode = mode.starts_with("export");
|
||||||
|
if mode == "export-expired" {
|
||||||
|
std::fs::write(
|
||||||
|
credentials_path.with_extension("json"),
|
||||||
|
serde_json::to_vec(&serde_json::json!({
|
||||||
|
"Version": 1,
|
||||||
|
"AccessKeyId": "OLD",
|
||||||
|
"SecretAccessKey": "s",
|
||||||
|
"Expiration": "2000-01-01T00:00:00Z",
|
||||||
|
}))?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let export_invocations = credentials_path.with_extension("exports");
|
||||||
|
let credential_export = if export_mode {
|
||||||
|
// Login creates or replaces this file after the initial export fails.
|
||||||
|
Some(bedrock_credential_export_config(
|
||||||
|
&credentials_path.with_extension("json"),
|
||||||
|
&export_invocations,
|
||||||
|
)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let server = start_mock_server().await;
|
let server = start_mock_server().await;
|
||||||
Mock::given(method("POST"))
|
Mock::given(method("POST"))
|
||||||
.and(path("/v1/responses"))
|
.and(path("/v1/responses"))
|
||||||
@@ -294,14 +531,19 @@ async fn amazon_bedrock_aws_auth_refresh_resigns() -> anyhow::Result<()> {
|
|||||||
.set_body_string(sse(vec![ev_response_created("r"), ev_completed("r")]))
|
.set_body_string(sse(vec![ev_response_created("r"), ev_completed("r")]))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.expect(2)
|
.expect(match mode.as_str() {
|
||||||
|
"profile" => 2,
|
||||||
|
"export-failure" => 0,
|
||||||
|
_ => 1,
|
||||||
|
})
|
||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let mut provider =
|
let mut provider =
|
||||||
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
||||||
profile: Some("default".to_string()),
|
profile: (!export_mode).then(|| "default".to_string()),
|
||||||
region: Some("us-east-1".to_string()),
|
region: Some("us-east-1".to_string()),
|
||||||
|
credential_export,
|
||||||
auth_refresh: Some(AwsAuthRefreshConfig {
|
auth_refresh: Some(AwsAuthRefreshConfig {
|
||||||
command: "aws".to_string(),
|
command: "aws".to_string(),
|
||||||
args: Vec::from(
|
args: Vec::from(
|
||||||
@@ -312,7 +554,9 @@ async fn amazon_bedrock_aws_auth_refresh_resigns() -> anyhow::Result<()> {
|
|||||||
}));
|
}));
|
||||||
provider.base_url = Some(format!("{}/v1", server.uri()));
|
provider.base_url = Some(format!("{}/v1", server.uri()));
|
||||||
provider.request_max_retries = Some(0);
|
provider.request_max_retries = Some(0);
|
||||||
provider.stream_max_retries = Some(0);
|
let stream_max_retries = if export_mode { 2 } else { 0 };
|
||||||
|
provider.stream_max_retries = Some(stream_max_retries);
|
||||||
|
let recovery_attempts = stream_max_retries as usize + 1;
|
||||||
|
|
||||||
let mut builder = test_codex()
|
let mut builder = test_codex()
|
||||||
.with_model("openai.gpt-5.5")
|
.with_model("openai.gpt-5.5")
|
||||||
@@ -321,6 +565,47 @@ async fn amazon_bedrock_aws_auth_refresh_resigns() -> anyhow::Result<()> {
|
|||||||
config.model_provider = provider;
|
config.model_provider = provider;
|
||||||
});
|
});
|
||||||
let test = builder.build_with_auto_env(&server).await?;
|
let test = builder.build_with_auto_env(&server).await?;
|
||||||
|
if persistent_export_failure {
|
||||||
|
test.codex
|
||||||
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||||
|
text: "fail export even after login".into(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]))
|
||||||
|
.await?;
|
||||||
|
let EventMsg::TurnComplete(completed) = wait_for_event(&test.codex, |event| {
|
||||||
|
matches!(event, EventMsg::TurnComplete(_))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
unreachable!("predicate guarantees a turn complete event");
|
||||||
|
};
|
||||||
|
let error = completed.error.expect("failed export should fail the turn");
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.message
|
||||||
|
.contains("AWS credential export command exited with"),
|
||||||
|
"{error:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(refresh_invocations)?,
|
||||||
|
"refresh\n".repeat(recovery_attempts)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(export_invocations)?
|
||||||
|
.lines()
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["invoked"; 2 * recovery_attempts]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
server
|
||||||
|
.received_requests()
|
||||||
|
.await
|
||||||
|
.expect("mock server should capture requests")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
server.verify().await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
test.codex
|
test.codex
|
||||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||||
text: "hello".to_string(),
|
text: "hello".to_string(),
|
||||||
@@ -358,6 +643,86 @@ async fn amazon_bedrock_aws_auth_refresh_resigns() -> anyhow::Result<()> {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
server.verify().await;
|
server.verify().await;
|
||||||
|
assert_eq!(std::fs::read_to_string(&refresh_invocations)?, "refresh\n");
|
||||||
|
if export_mode {
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&export_invocations)?
|
||||||
|
.lines()
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["invoked", "invoked"]
|
||||||
|
);
|
||||||
|
let requests = server
|
||||||
|
.received_requests()
|
||||||
|
.await
|
||||||
|
.expect("mock server should capture the exported request");
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].headers.get("x-amz-security-token"),
|
||||||
|
Some(&HeaderValue::from_static("exported-session-token"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.reset().await;
|
||||||
|
let rejected = mount_response_sequence(
|
||||||
|
&server,
|
||||||
|
vec![
|
||||||
|
ResponseTemplate::new(403).set_body_string("ExpiredTokenException");
|
||||||
|
2 * recovery_attempts
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
test.codex
|
||||||
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||||
|
text: "reject the refreshed credentials too".into(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]))
|
||||||
|
.await?;
|
||||||
|
let EventMsg::Error(error) =
|
||||||
|
wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await
|
||||||
|
else {
|
||||||
|
unreachable!("predicate guarantees an error event");
|
||||||
|
};
|
||||||
|
assert!(error.message.contains("ExpiredTokenException"), "{error:?}");
|
||||||
|
let EventMsg::TurnComplete(completed) = wait_for_event(&test.codex, |event| {
|
||||||
|
matches!(event, EventMsg::TurnComplete(_))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
unreachable!("predicate guarantees a turn complete event");
|
||||||
|
};
|
||||||
|
assert_eq!(completed.error, Some(error));
|
||||||
|
assert_eq!(rejected.requests().len(), 2 * recovery_attempts);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(refresh_invocations)?,
|
||||||
|
"refresh\n".repeat(1 + recovery_attempts)
|
||||||
|
);
|
||||||
|
if export_mode {
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(export_invocations)?
|
||||||
|
.lines()
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["invoked"; 2 + recovery_attempts]
|
||||||
|
);
|
||||||
|
|
||||||
|
// A transient auth rejection can succeed on an outer stream retry.
|
||||||
|
server.reset().await;
|
||||||
|
let retried = mount_response_sequence(
|
||||||
|
&server,
|
||||||
|
vec![
|
||||||
|
ResponseTemplate::new(403).set_body_string("ExpiredTokenException"),
|
||||||
|
ResponseTemplate::new(403).set_body_string("ExpiredTokenException"),
|
||||||
|
ResponseTemplate::new(200)
|
||||||
|
.insert_header("content-type", "text/event-stream")
|
||||||
|
.set_body_string(sse(vec![
|
||||||
|
ev_response_created("retried"),
|
||||||
|
ev_completed("retried"),
|
||||||
|
])),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
test.submit_turn("retry a temporary auth failure").await?;
|
||||||
|
assert_eq!(retried.requests().len(), 3);
|
||||||
|
}
|
||||||
|
server.verify().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,14 @@ use serde::Serialize;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::num::NonZeroU64;
|
use std::num::NonZeroU64;
|
||||||
|
use std::path::Component;
|
||||||
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const DEFAULT_STREAM_IDLE_TIMEOUT_MS: u64 = 300_000;
|
const DEFAULT_STREAM_IDLE_TIMEOUT_MS: u64 = 300_000;
|
||||||
const DEFAULT_STREAM_MAX_RETRIES: u64 = 5;
|
const DEFAULT_STREAM_MAX_RETRIES: u64 = 5;
|
||||||
const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4;
|
const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4;
|
||||||
|
const DEFAULT_AWS_CREDENTIAL_EXPORT_TIMEOUT_MS: u64 = 30_000;
|
||||||
const DEFAULT_AWS_AUTH_REFRESH_TIMEOUT_MS: u64 = 300_000;
|
const DEFAULT_AWS_AUTH_REFRESH_TIMEOUT_MS: u64 = 300_000;
|
||||||
pub const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS: u64 = 15_000;
|
pub const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS: u64 = 15_000;
|
||||||
/// Hard cap for user-configured `stream_max_retries`.
|
/// Hard cap for user-configured `stream_max_retries`.
|
||||||
@@ -159,10 +162,39 @@ pub struct ModelProviderAwsAuthInfo {
|
|||||||
pub profile: Option<String>,
|
pub profile: Option<String>,
|
||||||
/// AWS region to use for provider-specific endpoints.
|
/// AWS region to use for provider-specific endpoints.
|
||||||
pub region: Option<String>,
|
pub region: Option<String>,
|
||||||
|
/// Optional command whose exported credentials replace the AWS SDK credential chain.
|
||||||
|
pub credential_export: Option<AwsCredentialExportConfig>,
|
||||||
/// Optional command used to reauthenticate after a refreshable AWS auth failure.
|
/// Optional command used to reauthenticate after a refreshable AWS auth failure.
|
||||||
pub auth_refresh: Option<AwsAuthRefreshConfig>,
|
pub auth_refresh: Option<AwsAuthRefreshConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Command used to export AWS signing credentials for a model provider.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[schemars(deny_unknown_fields)]
|
||||||
|
pub struct AwsCredentialExportConfig {
|
||||||
|
/// Executable to invoke directly, without a shell.
|
||||||
|
pub command: String,
|
||||||
|
/// Arguments passed to the credential export command.
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Vec<RedactedString>,
|
||||||
|
/// Maximum time to wait for the credential export command to complete.
|
||||||
|
#[serde(default = "default_aws_credential_export_timeout_ms")]
|
||||||
|
pub timeout_ms: NonZeroU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AwsCredentialExportConfig {
|
||||||
|
pub fn timeout(&self) -> Duration {
|
||||||
|
Duration::from_millis(self.timeout_ms.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_aws_credential_export_timeout_ms() -> NonZeroU64 {
|
||||||
|
match NonZeroU64::new(DEFAULT_AWS_CREDENTIAL_EXPORT_TIMEOUT_MS) {
|
||||||
|
Some(timeout_ms) => timeout_ms,
|
||||||
|
None => panic!("AWS credential export timeout must be non-zero"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Command used to refresh AWS credentials for a model provider.
|
/// Command used to refresh AWS credentials for a model provider.
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[schemars(deny_unknown_fields)]
|
#[schemars(deny_unknown_fields)]
|
||||||
@@ -192,7 +224,7 @@ fn default_aws_auth_refresh_timeout_ms() -> NonZeroU64 {
|
|||||||
|
|
||||||
impl ModelProviderInfo {
|
impl ModelProviderInfo {
|
||||||
pub fn validate(&self) -> std::result::Result<(), String> {
|
pub fn validate(&self) -> std::result::Result<(), String> {
|
||||||
if self.aws.is_some() {
|
if let Some(aws) = self.aws.as_ref() {
|
||||||
if self.supports_websockets {
|
if self.supports_websockets {
|
||||||
// TODO(celia-oai): Support AWS SigV4 signing for WebSocket
|
// TODO(celia-oai): Support AWS SigV4 signing for WebSocket
|
||||||
// upgrade requests before allowing AWS-authenticated providers
|
// upgrade requests before allowing AWS-authenticated providers
|
||||||
@@ -221,8 +253,33 @@ impl ModelProviderInfo {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(auth_refresh) = self.aws.as_ref().and_then(|aws| aws.auth_refresh.as_ref())
|
if let Some(credential_export) = aws.credential_export.as_ref() {
|
||||||
{
|
if aws.profile.is_some() {
|
||||||
|
return Err(
|
||||||
|
"provider aws.credential_export cannot be combined with aws.profile"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if credential_export.command.trim().is_empty() {
|
||||||
|
return Err(
|
||||||
|
"provider aws.credential_export.command must not be empty".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let command = Path::new(&credential_export.command);
|
||||||
|
let mut components = command.components();
|
||||||
|
let is_bare_command = matches!(
|
||||||
|
(components.next(), components.next()),
|
||||||
|
(Some(Component::Normal(name)), None) if name == command.as_os_str()
|
||||||
|
);
|
||||||
|
if !command.is_absolute() && !is_bare_command {
|
||||||
|
return Err(
|
||||||
|
"provider aws.credential_export.command must be an absolute path or a bare executable name"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(auth_refresh) = aws.auth_refresh.as_ref() {
|
||||||
if auth_refresh.command.trim().is_empty() {
|
if auth_refresh.command.trim().is_empty() {
|
||||||
return Err("provider aws.auth_refresh.command must not be empty".to_string());
|
return Err("provider aws.auth_refresh.command must not be empty".to_string());
|
||||||
}
|
}
|
||||||
@@ -437,6 +494,7 @@ impl ModelProviderInfo {
|
|||||||
aws: Some(aws.unwrap_or(ModelProviderAwsAuthInfo {
|
aws: Some(aws.unwrap_or(ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: None,
|
region: None,
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
})),
|
})),
|
||||||
wire_api: WireApi::Responses,
|
wire_api: WireApi::Responses,
|
||||||
@@ -564,7 +622,8 @@ pub fn merge_configured_model_providers(
|
|||||||
if provider != ModelProviderInfo::default() {
|
if provider != ModelProviderInfo::default() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"model_providers.{key} only supports changing \
|
"model_providers.{key} only supports changing \
|
||||||
`base_url`, `auth`, `http_headers`, `aws.profile`, `aws.region`, and `aws.auth_refresh`; \
|
`base_url`, `auth`, `http_headers`, `aws.profile`, `aws.region`, `aws.credential_export`, \
|
||||||
|
and `aws.auth_refresh`; \
|
||||||
other non-default provider fields are not supported"
|
other non-default provider fields are not supported"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,9 +227,12 @@ name = "Amazon Bedrock"
|
|||||||
base_url = "https://bedrock.example.com/v1"
|
base_url = "https://bedrock.example.com/v1"
|
||||||
|
|
||||||
[aws]
|
[aws]
|
||||||
profile = "codex-bedrock"
|
|
||||||
region = "us-west-2"
|
region = "us-west-2"
|
||||||
|
|
||||||
|
[aws.credential_export]
|
||||||
|
command = "aws-vault"
|
||||||
|
args = ["--profile", "codex-bedrock"]
|
||||||
|
|
||||||
[aws.auth_refresh]
|
[aws.auth_refresh]
|
||||||
command = "aws"
|
command = "aws"
|
||||||
args = ["login", "--profile", "codex-bedrock"]
|
args = ["login", "--profile", "codex-bedrock"]
|
||||||
@@ -240,8 +243,13 @@ args = ["login", "--profile", "codex-bedrock"]
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
provider.aws,
|
provider.aws,
|
||||||
Some(ModelProviderAwsAuthInfo {
|
Some(ModelProviderAwsAuthInfo {
|
||||||
profile: Some("codex-bedrock".to_string()),
|
profile: None,
|
||||||
region: Some("us-west-2".to_string()),
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: Some(AwsCredentialExportConfig {
|
||||||
|
command: "aws-vault".to_string(),
|
||||||
|
args: vec!["--profile".into(), "codex-bedrock".into()],
|
||||||
|
timeout_ms: NonZeroU64::new(30_000).expect("timeout should be non-zero"),
|
||||||
|
}),
|
||||||
auth_refresh: Some(AwsAuthRefreshConfig {
|
auth_refresh: Some(AwsAuthRefreshConfig {
|
||||||
command: "aws".to_string(),
|
command: "aws".to_string(),
|
||||||
args: vec!["login".into(), "--profile".into(), "codex-bedrock".into()],
|
args: vec!["login".into(), "--profile".into(), "codex-bedrock".into()],
|
||||||
@@ -265,6 +273,7 @@ fn test_create_amazon_bedrock_provider() {
|
|||||||
aws: Some(ModelProviderAwsAuthInfo {
|
aws: Some(ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: None,
|
region: None,
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}),
|
}),
|
||||||
wire_api: WireApi::Responses,
|
wire_api: WireApi::Responses,
|
||||||
@@ -303,6 +312,7 @@ fn test_create_amazon_bedrock_runtime_provider_with_aws_configuration() {
|
|||||||
ModelProviderInfo::create_amazon_bedrock_runtime_provider(Some(ModelProviderAwsAuthInfo {
|
ModelProviderInfo::create_amazon_bedrock_runtime_provider(Some(ModelProviderAwsAuthInfo {
|
||||||
profile: Some("runtime-profile".to_string()),
|
profile: Some("runtime-profile".to_string()),
|
||||||
region: Some("us-west-2".to_string()),
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -318,6 +328,7 @@ fn test_create_amazon_bedrock_runtime_provider_with_aws_configuration() {
|
|||||||
Some(ModelProviderAwsAuthInfo {
|
Some(ModelProviderAwsAuthInfo {
|
||||||
profile: Some("runtime-profile".to_string()),
|
profile: Some("runtime-profile".to_string()),
|
||||||
region: Some("us-west-2".to_string()),
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
@@ -415,6 +426,11 @@ fn test_merge_configured_model_providers_adds_custom_provider() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_merge_configured_model_providers_applies_amazon_bedrock_aws_override() {
|
fn test_merge_configured_model_providers_applies_amazon_bedrock_aws_override() {
|
||||||
|
let credential_export = AwsCredentialExportConfig {
|
||||||
|
command: "aws-vault".to_string(),
|
||||||
|
args: vec!["export".into(), "codex-bedrock".into()],
|
||||||
|
timeout_ms: NonZeroU64::new(30_000).expect("timeout should be non-zero"),
|
||||||
|
};
|
||||||
let auth_refresh = AwsAuthRefreshConfig {
|
let auth_refresh = AwsAuthRefreshConfig {
|
||||||
command: "aws".to_string(),
|
command: "aws".to_string(),
|
||||||
args: vec!["login".into(), "--profile".into(), "codex-bedrock".into()],
|
args: vec!["login".into(), "--profile".into(), "codex-bedrock".into()],
|
||||||
@@ -424,8 +440,9 @@ fn test_merge_configured_model_providers_applies_amazon_bedrock_aws_override() {
|
|||||||
AMAZON_BEDROCK_PROVIDER_ID.to_string(),
|
AMAZON_BEDROCK_PROVIDER_ID.to_string(),
|
||||||
ModelProviderInfo {
|
ModelProviderInfo {
|
||||||
aws: Some(ModelProviderAwsAuthInfo {
|
aws: Some(ModelProviderAwsAuthInfo {
|
||||||
profile: Some("codex-bedrock".to_string()),
|
profile: None,
|
||||||
region: Some("us-west-2".to_string()),
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: Some(credential_export.clone()),
|
||||||
auth_refresh: Some(auth_refresh.clone()),
|
auth_refresh: Some(auth_refresh.clone()),
|
||||||
}),
|
}),
|
||||||
..ModelProviderInfo::default()
|
..ModelProviderInfo::default()
|
||||||
@@ -437,8 +454,9 @@ fn test_merge_configured_model_providers_applies_amazon_bedrock_aws_override() {
|
|||||||
.get_mut(AMAZON_BEDROCK_PROVIDER_ID)
|
.get_mut(AMAZON_BEDROCK_PROVIDER_ID)
|
||||||
.expect("Amazon Bedrock provider should be built in")
|
.expect("Amazon Bedrock provider should be built in")
|
||||||
.aws = Some(ModelProviderAwsAuthInfo {
|
.aws = Some(ModelProviderAwsAuthInfo {
|
||||||
profile: Some("codex-bedrock".to_string()),
|
profile: None,
|
||||||
region: Some("us-west-2".to_string()),
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: Some(credential_export),
|
||||||
auth_refresh: Some(auth_refresh),
|
auth_refresh: Some(auth_refresh),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -456,6 +474,7 @@ fn test_merge_configured_model_providers_applies_runtime_overrides_independently
|
|||||||
let runtime_aws = ModelProviderAwsAuthInfo {
|
let runtime_aws = ModelProviderAwsAuthInfo {
|
||||||
profile: Some("runtime-profile".to_string()),
|
profile: Some("runtime-profile".to_string()),
|
||||||
region: Some("eu-west-1".to_string()),
|
region: Some("eu-west-1".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
};
|
};
|
||||||
let configured_model_providers = std::collections::HashMap::from([(
|
let configured_model_providers = std::collections::HashMap::from([(
|
||||||
@@ -493,6 +512,7 @@ fn test_merge_configured_model_providers_applies_amazon_bedrock_transport_overri
|
|||||||
aws: Some(ModelProviderAwsAuthInfo {
|
aws: Some(ModelProviderAwsAuthInfo {
|
||||||
profile: Some("codex-bedrock".to_string()),
|
profile: Some("codex-bedrock".to_string()),
|
||||||
region: Some("us-west-2".to_string()),
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}),
|
}),
|
||||||
http_headers: Some(maplit::hashmap! {
|
http_headers: Some(maplit::hashmap! {
|
||||||
@@ -511,6 +531,7 @@ fn test_merge_configured_model_providers_applies_amazon_bedrock_transport_overri
|
|||||||
expected_provider.aws = Some(ModelProviderAwsAuthInfo {
|
expected_provider.aws = Some(ModelProviderAwsAuthInfo {
|
||||||
profile: Some("codex-bedrock".to_string()),
|
profile: Some("codex-bedrock".to_string()),
|
||||||
region: Some("us-west-2".to_string()),
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
});
|
});
|
||||||
expected_provider
|
expected_provider
|
||||||
@@ -536,6 +557,7 @@ fn test_merge_configured_model_providers_rejects_amazon_bedrock_non_default_fiel
|
|||||||
aws: Some(ModelProviderAwsAuthInfo {
|
aws: Some(ModelProviderAwsAuthInfo {
|
||||||
profile: Some("codex-bedrock".to_string()),
|
profile: Some("codex-bedrock".to_string()),
|
||||||
region: None,
|
region: None,
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}),
|
}),
|
||||||
..ModelProviderInfo::default()
|
..ModelProviderInfo::default()
|
||||||
@@ -548,7 +570,7 @@ fn test_merge_configured_model_providers_rejects_amazon_bedrock_non_default_fiel
|
|||||||
configured_model_providers,
|
configured_model_providers,
|
||||||
),
|
),
|
||||||
Err(
|
Err(
|
||||||
"model_providers.amazon-bedrock only supports changing `base_url`, `auth`, `http_headers`, `aws.profile`, `aws.region`, and `aws.auth_refresh`; other non-default provider fields are not supported"
|
"model_providers.amazon-bedrock only supports changing `base_url`, `auth`, `http_headers`, `aws.profile`, `aws.region`, `aws.credential_export`, and `aws.auth_refresh`; other non-default provider fields are not supported"
|
||||||
.to_string()
|
.to_string()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -562,6 +584,7 @@ fn test_merge_configured_model_providers_allows_amazon_bedrock_default_fields()
|
|||||||
aws: Some(ModelProviderAwsAuthInfo {
|
aws: Some(ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: None,
|
region: None,
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}),
|
}),
|
||||||
wire_api: WireApi::Responses,
|
wire_api: WireApi::Responses,
|
||||||
@@ -584,6 +607,7 @@ fn test_validate_provider_aws_rejects_conflicting_auth() {
|
|||||||
aws: Some(ModelProviderAwsAuthInfo {
|
aws: Some(ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: None,
|
region: None,
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}),
|
}),
|
||||||
env_key: Some("AWS_BEARER_TOKEN_BEDROCK".to_string()),
|
env_key: Some("AWS_BEARER_TOKEN_BEDROCK".to_string()),
|
||||||
@@ -603,6 +627,7 @@ fn test_validate_provider_aws_rejects_websockets() {
|
|||||||
aws: Some(ModelProviderAwsAuthInfo {
|
aws: Some(ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: None,
|
region: None,
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}),
|
}),
|
||||||
requires_openai_auth: false,
|
requires_openai_auth: false,
|
||||||
@@ -633,6 +658,7 @@ fn test_validate_provider_aws_auth_refresh_command() {
|
|||||||
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: None,
|
region: None,
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: Some(AwsAuthRefreshConfig {
|
auth_refresh: Some(AwsAuthRefreshConfig {
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
@@ -644,6 +670,78 @@ fn test_validate_provider_aws_auth_refresh_command() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_provider_aws_credential_export_command() {
|
||||||
|
let absolute_command = std::env::current_exe()
|
||||||
|
.expect("current executable should have a path")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
for (command, expected) in [
|
||||||
|
(
|
||||||
|
String::new(),
|
||||||
|
Err("provider aws.credential_export.command must not be empty".to_string()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
" ".to_string(),
|
||||||
|
Err("provider aws.credential_export.command must not be empty".to_string()),
|
||||||
|
),
|
||||||
|
("aws-vault".to_string(), Ok(())),
|
||||||
|
(absolute_command, Ok(())),
|
||||||
|
(
|
||||||
|
"./scripts/export-credentials".to_string(),
|
||||||
|
Err(
|
||||||
|
"provider aws.credential_export.command must be an absolute path or a bare executable name"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"scripts/export-credentials".to_string(),
|
||||||
|
Err(
|
||||||
|
"provider aws.credential_export.command must be an absolute path or a bare executable name"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"scripts/.".to_string(),
|
||||||
|
Err(
|
||||||
|
"provider aws.credential_export.command must be an absolute path or a bare executable name"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut provider =
|
||||||
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
||||||
|
profile: None,
|
||||||
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: Some(AwsCredentialExportConfig {
|
||||||
|
command,
|
||||||
|
args: vec!["--secret-argument".into()],
|
||||||
|
timeout_ms: NonZeroU64::new(30_000).expect("timeout should be non-zero"),
|
||||||
|
}),
|
||||||
|
auth_refresh: Some(AwsAuthRefreshConfig {
|
||||||
|
command: "aws".to_string(),
|
||||||
|
args: Vec::new(),
|
||||||
|
timeout_ms: NonZeroU64::new(300_000).expect("timeout should be non-zero"),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert_eq!(provider.validate(), expected);
|
||||||
|
if expected.is_ok() {
|
||||||
|
provider.aws.as_mut().expect("AWS config").profile = Some("codex-bedrock".to_string());
|
||||||
|
assert_eq!(
|
||||||
|
provider.validate(),
|
||||||
|
Err("provider aws.credential_export cannot be combined with aws.profile".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let export = provider
|
||||||
|
.aws
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|aws| aws.credential_export.as_ref())
|
||||||
|
.expect("credential export should be configured");
|
||||||
|
assert!(!format!("{export:?}").contains("--secret-argument"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_deserialize_provider_auth_config_allows_zero_refresh_interval() {
|
fn test_deserialize_provider_auth_config_allows_zero_refresh_interval() {
|
||||||
let base_dir = tempdir().unwrap();
|
let base_dir = tempdir().unwrap();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ path = "src/lib.rs"
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = { workspace = true }
|
||||||
codex-api = { workspace = true }
|
codex-api = { workspace = true }
|
||||||
codex-agent-identity = { workspace = true }
|
codex-agent-identity = { workspace = true }
|
||||||
codex-aws-auth = { workspace = true }
|
codex-aws-auth = { workspace = true }
|
||||||
@@ -25,14 +26,16 @@ codex-otel = { workspace = true }
|
|||||||
codex-protocol = { workspace = true }
|
codex-protocol = { workspace = true }
|
||||||
codex-response-debug-context = { workspace = true }
|
codex-response-debug-context = { workspace = true }
|
||||||
http = { workspace = true }
|
http = { workspace = true }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["process", "sync", "time"] }
|
tokio = { workspace = true, features = ["io-util", "process", "sync", "time"] }
|
||||||
tracing = { workspace = true, features = ["log"] }
|
tracing = { workspace = true, features = ["log"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
codex-utils-redacted-string = { workspace = true }
|
codex-utils-redacted-string = { workspace = true }
|
||||||
pretty_assertions = { workspace = true }
|
pretty_assertions = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||||
wiremock = { workspace = true }
|
wiremock = { workspace = true }
|
||||||
@@ -19,6 +19,7 @@ use codex_protocol::error::Result;
|
|||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
|
|
||||||
use crate::BearerAuthProvider;
|
use crate::BearerAuthProvider;
|
||||||
|
use crate::shared_state::process_shared_state;
|
||||||
|
|
||||||
use super::BedrockEndpoint;
|
use super::BedrockEndpoint;
|
||||||
use super::mantle::aws_auth_config;
|
use super::mantle::aws_auth_config;
|
||||||
@@ -34,6 +35,7 @@ const AWS_DEFAULT_REGION_ENV_VAR: &str = "AWS_DEFAULT_REGION";
|
|||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(super) enum BedrockAuthSource {
|
pub(super) enum BedrockAuthSource {
|
||||||
CommandBearerToken,
|
CommandBearerToken,
|
||||||
|
CredentialExport,
|
||||||
ConfiguredAwsProfile,
|
ConfiguredAwsProfile,
|
||||||
ManagedBearerToken,
|
ManagedBearerToken,
|
||||||
ManagedAccessKeys,
|
ManagedAccessKeys,
|
||||||
@@ -55,6 +57,12 @@ pub(super) fn auth_source(
|
|||||||
) -> BedrockAuthSource {
|
) -> BedrockAuthSource {
|
||||||
if provider_info.has_command_auth() {
|
if provider_info.has_command_auth() {
|
||||||
BedrockAuthSource::CommandBearerToken
|
BedrockAuthSource::CommandBearerToken
|
||||||
|
} else if provider_info
|
||||||
|
.aws
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|aws| aws.credential_export.is_some())
|
||||||
|
{
|
||||||
|
BedrockAuthSource::CredentialExport
|
||||||
} else if provider_info
|
} else if provider_info
|
||||||
.aws
|
.aws
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -93,6 +101,24 @@ pub(super) async fn resolve_auth_method(
|
|||||||
"Amazon Bedrock command authentication must be resolved by the model provider"
|
"Amazon Bedrock command authentication must be resolved by the model provider"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
)),
|
)),
|
||||||
|
BedrockAuthSource::CredentialExport => {
|
||||||
|
let config = match endpoint {
|
||||||
|
BedrockEndpoint::Mantle => aws_auth_config(aws),
|
||||||
|
BedrockEndpoint::Runtime => runtime::aws_auth_config(aws),
|
||||||
|
};
|
||||||
|
let credential_export = process_shared_state()
|
||||||
|
.aws_credential_export(aws)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CodexErr::Fatal(
|
||||||
|
"selected Amazon Bedrock credential exporter is no longer configured"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let context = AwsAuthContext::load_with_credentials_provider(config, credential_export)
|
||||||
|
.await
|
||||||
|
.map_err(aws_auth_error_to_codex_error)?;
|
||||||
|
Ok(BedrockAuthMethod::AwsSdkAuth { context })
|
||||||
|
}
|
||||||
BedrockAuthSource::ManagedBearerToken => {
|
BedrockAuthSource::ManagedBearerToken => {
|
||||||
let Some(CodexAuth::BedrockApiKey(auth)) = managed_auth else {
|
let Some(CodexAuth::BedrockApiKey(auth)) = managed_auth else {
|
||||||
return Err(CodexErr::Fatal(
|
return Err(CodexErr::Fatal(
|
||||||
@@ -234,6 +260,17 @@ fn aws_auth_error_to_codex_error(error: AwsAuthError) -> CodexErr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn aws_auth_error_to_auth_error(error: AwsAuthError) -> AuthError {
|
fn aws_auth_error_to_auth_error(error: AwsAuthError) -> AuthError {
|
||||||
|
if let Some(source) = error.credentials_provider_error() {
|
||||||
|
// Command failures need provider recovery, not repeated HTTP transport attempts.
|
||||||
|
return AuthError::Build(if error.is_retryable() {
|
||||||
|
format!("failed to load AWS credentials: {source}")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} {source}",
|
||||||
|
super::error::CREDENTIAL_EXPORT_CONFIG_ERROR_PREFIX
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
if error.is_retryable() {
|
if error.is_retryable() {
|
||||||
AuthError::Transient(error.to_string())
|
AuthError::Transient(error.to_string())
|
||||||
} else {
|
} else {
|
||||||
@@ -303,9 +340,12 @@ impl AuthProvider for BedrockSigV4AuthProvider {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::num::NonZeroU64;
|
||||||
|
|
||||||
use codex_api::AuthProvider;
|
use codex_api::AuthProvider;
|
||||||
use codex_login::auth::BedrockAccessKeysAuth;
|
use codex_login::auth::BedrockAccessKeysAuth;
|
||||||
use codex_login::auth::BedrockApiKeyAuth;
|
use codex_login::auth::BedrockApiKeyAuth;
|
||||||
|
use codex_model_provider_info::AwsCredentialExportConfig;
|
||||||
use http::HeaderValue;
|
use http::HeaderValue;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
@@ -322,6 +362,18 @@ mod tests {
|
|||||||
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
||||||
profile: Some("configured-profile".to_string()),
|
profile: Some("configured-profile".to_string()),
|
||||||
region: Some("us-west-2".to_string()),
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: None,
|
||||||
|
auth_refresh: None,
|
||||||
|
}));
|
||||||
|
let credential_export_provider =
|
||||||
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
||||||
|
profile: None,
|
||||||
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: Some(AwsCredentialExportConfig {
|
||||||
|
command: "export-credentials".to_string(),
|
||||||
|
args: Vec::new(),
|
||||||
|
timeout_ms: NonZeroU64::new(30_000).expect("timeout should be non-zero"),
|
||||||
|
}),
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}));
|
}));
|
||||||
let managed_auth =
|
let managed_auth =
|
||||||
@@ -342,6 +394,22 @@ mod tests {
|
|||||||
&[&str],
|
&[&str],
|
||||||
BedrockAuthSource,
|
BedrockAuthSource,
|
||||||
)] = &[
|
)] = &[
|
||||||
|
(
|
||||||
|
&credential_export_provider,
|
||||||
|
Some(managed_auth.as_ref()),
|
||||||
|
&[
|
||||||
|
AWS_BEARER_TOKEN_BEDROCK_ENV_VAR,
|
||||||
|
AWS_ACCESS_KEY_ID_ENV_VAR,
|
||||||
|
AWS_SECRET_ACCESS_KEY_ENV_VAR,
|
||||||
|
],
|
||||||
|
BedrockAuthSource::CredentialExport,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&credential_export_provider,
|
||||||
|
Some(managed_access_keys.as_ref()),
|
||||||
|
&[AWS_BEARER_TOKEN_BEDROCK_ENV_VAR],
|
||||||
|
BedrockAuthSource::CredentialExport,
|
||||||
|
),
|
||||||
(
|
(
|
||||||
&configured_profile_provider,
|
&configured_profile_provider,
|
||||||
Some(managed_auth.as_ref()),
|
Some(managed_auth.as_ref()),
|
||||||
@@ -418,6 +486,7 @@ mod tests {
|
|||||||
&ModelProviderAwsAuthInfo {
|
&ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: Some(" us-west-2 ".to_string()),
|
region: Some(" us-west-2 ".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
},
|
},
|
||||||
|name| match name {
|
|name| match name {
|
||||||
@@ -450,6 +519,7 @@ mod tests {
|
|||||||
&ModelProviderAwsAuthInfo {
|
&ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: None,
|
region: None,
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
},
|
},
|
||||||
|name| match name {
|
|name| match name {
|
||||||
@@ -468,6 +538,7 @@ mod tests {
|
|||||||
&ModelProviderAwsAuthInfo {
|
&ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: None,
|
region: None,
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
},
|
},
|
||||||
|name| match name {
|
|name| match name {
|
||||||
@@ -486,6 +557,7 @@ mod tests {
|
|||||||
&ModelProviderAwsAuthInfo {
|
&ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: None,
|
region: None,
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
},
|
},
|
||||||
missing_env_var,
|
missing_env_var,
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
//! Caches Bedrock signing credentials and provides an exclusive refresh guard.
|
||||||
|
//! The caller holds the guard across login and export to coalesce auth recovery.
|
||||||
|
//! Exported secrets remain in memory; command output is bounded and never logged.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::path::Component;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::atomic::AtomicU64;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::time::Duration;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use chrono::DateTime;
|
||||||
|
use chrono::Utc;
|
||||||
|
use codex_aws_auth::AwsAccessKeys;
|
||||||
|
use codex_aws_auth::AwsCredentialsProvider;
|
||||||
|
use codex_model_provider_info::AwsCredentialExportConfig;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::sync::MutexGuard;
|
||||||
|
|
||||||
|
const DEFAULT_CREDENTIAL_LIFETIME: Duration = Duration::from_secs(60 * 60);
|
||||||
|
const CREDENTIAL_REFRESH_WINDOW: Duration = Duration::from_secs(5 * 60);
|
||||||
|
const MAX_CREDENTIAL_OUTPUT_BYTES: u64 = 64 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct AwsCredentialExport {
|
||||||
|
config: AwsCredentialExportConfig,
|
||||||
|
cached: Mutex<Option<CachedCredentials>>,
|
||||||
|
recovery_generation: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct CachedCredentials {
|
||||||
|
access_keys: AwsAccessKeys,
|
||||||
|
refresh_at: SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps cached credentials invalidated while the caller reauthenticates and exports.
|
||||||
|
pub(super) struct CredentialExportRefresh<'a> {
|
||||||
|
exporter: &'a AwsCredentialExport,
|
||||||
|
cached: MutexGuard<'a, Option<CachedCredentials>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredentialExportRefresh<'_> {
|
||||||
|
pub(super) async fn refresh(mut self) -> io::Result<()> {
|
||||||
|
*self.cached = Some(self.exporter.export_credentials().await?);
|
||||||
|
self.exporter
|
||||||
|
.recovery_generation
|
||||||
|
.fetch_add(1, Ordering::Release);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum CredentialOutput {
|
||||||
|
Sts {
|
||||||
|
#[serde(rename = "Credentials")]
|
||||||
|
credentials: ExportedCredentials,
|
||||||
|
},
|
||||||
|
Process(ExportedCredentials),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct ExportedCredentials {
|
||||||
|
access_key_id: String,
|
||||||
|
secret_access_key: String,
|
||||||
|
session_token: Option<String>,
|
||||||
|
expiration: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CachedCredentials {
|
||||||
|
fn from_output(output: &[u8], now: SystemTime) -> io::Result<Self> {
|
||||||
|
let output: CredentialOutput = serde_json::from_slice(output).map_err(|_| {
|
||||||
|
// Serde's detailed errors may quote credential values.
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"AWS credential export returned invalid credentials JSON",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let credentials = match output {
|
||||||
|
CredentialOutput::Sts { credentials } | CredentialOutput::Process(credentials) => {
|
||||||
|
credentials
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if credentials.access_key_id.trim().is_empty()
|
||||||
|
|| credentials.secret_access_key.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"AWS credential export returned empty access keys",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let refresh_at = if let Some(expiration) = credentials.expiration {
|
||||||
|
let expiration = DateTime::parse_from_rfc3339(&expiration).map_err(|_| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"AWS credential export Expiration must be an RFC 3339 timestamp",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let lifetime = expiration
|
||||||
|
.signed_duration_since(DateTime::<Utc>::from(now))
|
||||||
|
.to_std()
|
||||||
|
.ok()
|
||||||
|
.filter(|lifetime| !lifetime.is_zero())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
io::Error::other("AWS credential export returned expired credentials")
|
||||||
|
})?;
|
||||||
|
now.checked_add(lifetime.saturating_sub(CREDENTIAL_REFRESH_WINDOW))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"AWS credential export Expiration is out of range",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
now + DEFAULT_CREDENTIAL_LIFETIME
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
access_keys: AwsAccessKeys {
|
||||||
|
access_key_id: credentials.access_key_id,
|
||||||
|
secret_access_key: credentials.secret_access_key,
|
||||||
|
session_token: credentials.session_token,
|
||||||
|
},
|
||||||
|
refresh_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AwsCredentialExport {
|
||||||
|
pub(crate) fn new(config: AwsCredentialExportConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
cached: Mutex::new(None),
|
||||||
|
recovery_generation: AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns None when another overlapping recovery has already completed.
|
||||||
|
/// Dropping the guard before refresh succeeds leaves the cache empty.
|
||||||
|
pub(super) async fn begin_refresh(&self) -> Option<CredentialExportRefresh<'_>> {
|
||||||
|
let generation = self.recovery_generation.load(Ordering::Acquire);
|
||||||
|
let mut cached = self.cached.lock().await;
|
||||||
|
if self.recovery_generation.load(Ordering::Acquire) != generation {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
*cached = None;
|
||||||
|
Some(CredentialExportRefresh {
|
||||||
|
exporter: self,
|
||||||
|
cached,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn export_credentials(&self) -> io::Result<CachedCredentials> {
|
||||||
|
let program = Path::new(&self.config.command);
|
||||||
|
let mut components = program.components();
|
||||||
|
let is_bare_command = matches!(
|
||||||
|
(components.next(), components.next()),
|
||||||
|
(Some(Component::Normal(name)), None) if name == program.as_os_str()
|
||||||
|
);
|
||||||
|
if self.config.command.trim().is_empty() || (!program.is_absolute() && !is_bare_command) {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"AWS credential export command must be an absolute path or a bare executable name",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut command = Command::new(program);
|
||||||
|
command
|
||||||
|
.args(self.config.args.iter().map(Deref::deref))
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
|
||||||
|
let mut child = command.spawn().map_err(|error| {
|
||||||
|
io::Error::new(
|
||||||
|
error.kind(),
|
||||||
|
format!(
|
||||||
|
"failed to run AWS credential export command `{}`: {error}",
|
||||||
|
self.config.command
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| io::Error::other("AWS credential export stdout is unavailable"))?;
|
||||||
|
let output = tokio::time::timeout(self.config.timeout(), async {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
stdout
|
||||||
|
.take(MAX_CREDENTIAL_OUTPUT_BYTES + 1)
|
||||||
|
.read_to_end(&mut output)
|
||||||
|
.await?;
|
||||||
|
if output.len() as u64 > MAX_CREDENTIAL_OUTPUT_BYTES {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"AWS credential export output exceeds 64 KiB",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let status = child.wait().await?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(io::Error::other(format!(
|
||||||
|
"AWS credential export command exited with {status}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::TimedOut,
|
||||||
|
format!(
|
||||||
|
"AWS credential export command timed out after {} ms",
|
||||||
|
self.config.timeout_ms
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})??;
|
||||||
|
CachedCredentials::from_output(&output, SystemTime::now())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AwsCredentialsProvider for AwsCredentialExport {
|
||||||
|
#[expect(
|
||||||
|
clippy::await_holding_invalid_type,
|
||||||
|
reason = "cache misses serialize command execution to avoid duplicate credential exports"
|
||||||
|
)]
|
||||||
|
async fn credentials(&self) -> std::io::Result<AwsAccessKeys> {
|
||||||
|
let mut cached = self.cached.lock().await;
|
||||||
|
if let Some(credentials) = cached.as_ref()
|
||||||
|
&& SystemTime::now() < credentials.refresh_at
|
||||||
|
{
|
||||||
|
return Ok(credentials.access_keys.clone());
|
||||||
|
}
|
||||||
|
let credentials = self.export_credentials().await?;
|
||||||
|
let access_keys = credentials.access_keys.clone();
|
||||||
|
*cached = Some(credentials);
|
||||||
|
Ok(access_keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "credential_export_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
use std::num::NonZeroU64;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::task::Context;
|
||||||
|
use std::task::Waker;
|
||||||
|
|
||||||
|
use codex_model_provider_info::AwsAuthRefreshConfig;
|
||||||
|
use codex_model_provider_info::ModelProviderAwsAuthInfo;
|
||||||
|
use codex_model_provider_info::ModelProviderInfo;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::provider::ModelProvider;
|
||||||
|
use crate::provider::ProviderUnauthorizedRecovery;
|
||||||
|
|
||||||
|
use super::super::AmazonBedrockModelProvider;
|
||||||
|
use super::super::AwsAuthRecovery;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SECRET: &str = "credential-export-secret";
|
||||||
|
const VALID_OUTPUT: &[u8] = br#"{"AccessKeyId":"exported","SecretAccessKey":"credential-export-secret","SessionToken":"credential-export-secret"}"#;
|
||||||
|
|
||||||
|
fn exporter_fixture(
|
||||||
|
output: &[u8],
|
||||||
|
mode: &str,
|
||||||
|
) -> io::Result<(tempfile::TempDir, AwsCredentialExport)> {
|
||||||
|
let directory = tempfile::tempdir()?;
|
||||||
|
let output_path = directory.path().join("output.json");
|
||||||
|
std::fs::write(&output_path, output)?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let (command, mut args) = {
|
||||||
|
let script = directory.path().join("export.sh");
|
||||||
|
std::fs::write(
|
||||||
|
&script,
|
||||||
|
r#"printf 'invoked\n' >> "$1"
|
||||||
|
printf '%s\n' "$4" >&2
|
||||||
|
cat "$2"
|
||||||
|
case "$3" in
|
||||||
|
fail) exit 7 ;;
|
||||||
|
wait) while :; do :; done ;;
|
||||||
|
esac
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
(
|
||||||
|
"sh".to_string(),
|
||||||
|
vec![script.to_string_lossy().into_owned()],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
#[cfg(windows)]
|
||||||
|
let (command, mut args) = {
|
||||||
|
let script = directory.path().join("export.cmd");
|
||||||
|
std::fs::write(
|
||||||
|
&script,
|
||||||
|
"@echo off\r\n>> \"%~1\" echo invoked\r\n>&2 echo %~4\r\ntype \"%~2\"\r\nif \"%~3\"==\"fail\" exit /b 7\r\nif \"%~3\"==\"wait\" goto wait\r\nexit /b 0\r\n:wait\r\ngoto wait\r\n",
|
||||||
|
)?;
|
||||||
|
(
|
||||||
|
"cmd.exe".to_string(),
|
||||||
|
vec![
|
||||||
|
"/D".to_string(),
|
||||||
|
"/Q".to_string(),
|
||||||
|
"/C".to_string(),
|
||||||
|
script.to_string_lossy().into_owned(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
args.extend([
|
||||||
|
directory
|
||||||
|
.path()
|
||||||
|
.join("invocations")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
|
output_path.to_string_lossy().into_owned(),
|
||||||
|
mode.to_string(),
|
||||||
|
SECRET.to_string(),
|
||||||
|
]);
|
||||||
|
Ok((
|
||||||
|
directory,
|
||||||
|
AwsCredentialExport::new(AwsCredentialExportConfig {
|
||||||
|
command,
|
||||||
|
args: args.into_iter().map(Into::into).collect(),
|
||||||
|
timeout_ms: NonZeroU64::new(5_000).expect("non-zero timeout"),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credential_output_formats_expiration_and_sanitized_errors() {
|
||||||
|
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
|
||||||
|
for (optional_fields, refresh_after) in [
|
||||||
|
(json!({}), 3_600),
|
||||||
|
(json!({"SessionToken": null, "Expiration": null}), 3_600),
|
||||||
|
(
|
||||||
|
json!({"SessionToken": SECRET, "Expiration": "2023-11-14T23:13:20+00:00"}),
|
||||||
|
3_300,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"SessionToken": SECRET, "Expiration": "2023-11-14T22:18:20Z"}),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
(json!({"Expiration": "2023-11-14T22:15:20Z"}), 0),
|
||||||
|
] {
|
||||||
|
let mut credentials = json!({"AccessKeyId": "exported", "SecretAccessKey": SECRET});
|
||||||
|
credentials
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.extend(optional_fields.as_object().unwrap().clone());
|
||||||
|
let mut process_output = credentials.clone();
|
||||||
|
process_output["Version"] = json!(1);
|
||||||
|
for output in [
|
||||||
|
process_output,
|
||||||
|
json!({"Credentials": credentials, "AssumedRoleUser": {"Arn": "test-role"}}),
|
||||||
|
] {
|
||||||
|
let cached = CachedCredentials::from_output(output.to_string().as_bytes(), now)
|
||||||
|
.expect("valid credentials should parse");
|
||||||
|
assert_eq!(
|
||||||
|
(cached.access_keys, cached.refresh_at),
|
||||||
|
(
|
||||||
|
AwsAccessKeys {
|
||||||
|
access_key_id: "exported".to_string(),
|
||||||
|
secret_access_key: SECRET.to_string(),
|
||||||
|
session_token: optional_fields
|
||||||
|
.get("SessionToken")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(str::to_string),
|
||||||
|
},
|
||||||
|
now + Duration::from_secs(refresh_after)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut invalid_outputs = vec![(
|
||||||
|
format!("{{\"SecretAccessKey\":\"{SECRET}\""),
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
)];
|
||||||
|
for (field, value) in [
|
||||||
|
("AccessKeyId", None),
|
||||||
|
("SecretAccessKey", None),
|
||||||
|
("AccessKeyId", Some(json!(" "))),
|
||||||
|
("SecretAccessKey", Some(json!(" "))),
|
||||||
|
("AccessKeyId", Some(json!([SECRET]))),
|
||||||
|
("SecretAccessKey", Some(json!([SECRET]))),
|
||||||
|
("SessionToken", Some(json!([SECRET]))),
|
||||||
|
("Expiration", Some(json!([SECRET]))),
|
||||||
|
("Expiration", Some(json!(SECRET))),
|
||||||
|
] {
|
||||||
|
let mut credentials: serde_json::Value = serde_json::from_slice(VALID_OUTPUT).unwrap();
|
||||||
|
match value {
|
||||||
|
Some(value) => {
|
||||||
|
credentials[field] = value;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
credentials.as_object_mut().unwrap().remove(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invalid_outputs.extend(
|
||||||
|
[
|
||||||
|
credentials.to_string(),
|
||||||
|
json!({"Credentials": credentials}).to_string(),
|
||||||
|
]
|
||||||
|
.map(|output| (output, io::ErrorKind::InvalidData)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for expiration in ["2023-11-14T22:13:20Z", "2023-11-14T22:00:00Z"] {
|
||||||
|
let credentials =
|
||||||
|
json!({"AccessKeyId": "exported", "SecretAccessKey": SECRET, "Expiration": expiration});
|
||||||
|
invalid_outputs.extend(
|
||||||
|
[
|
||||||
|
credentials.to_string(),
|
||||||
|
json!({"Credentials": credentials}).to_string(),
|
||||||
|
]
|
||||||
|
.map(|output| (output, io::ErrorKind::Other)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (output, expected_kind) in invalid_outputs {
|
||||||
|
let error = CachedCredentials::from_output(output.as_bytes(), now)
|
||||||
|
.expect_err("invalid credentials should be rejected");
|
||||||
|
assert_eq!(error.kind(), expected_kind);
|
||||||
|
assert!(!error.to_string().contains(SECRET));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn credential_export_process_errors_are_bounded_and_sanitized() -> io::Result<()> {
|
||||||
|
let oversized = format!(
|
||||||
|
"{SECRET}{}",
|
||||||
|
"x".repeat(MAX_CREDENTIAL_OUTPUT_BYTES as usize)
|
||||||
|
);
|
||||||
|
for (mode, output, timeout_ms, expected_kind) in [
|
||||||
|
("missing", VALID_OUTPUT, 5_000, io::ErrorKind::NotFound),
|
||||||
|
("relative", VALID_OUTPUT, 5_000, io::ErrorKind::InvalidInput),
|
||||||
|
(
|
||||||
|
"normalized-relative",
|
||||||
|
VALID_OUTPUT,
|
||||||
|
5_000,
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
),
|
||||||
|
("fail", VALID_OUTPUT, 5_000, io::ErrorKind::Other),
|
||||||
|
("wait", VALID_OUTPUT, 100, io::ErrorKind::TimedOut),
|
||||||
|
(
|
||||||
|
"wait",
|
||||||
|
oversized.as_bytes(),
|
||||||
|
5_000,
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let (directory, mut exporter) = exporter_fixture(output, mode)?;
|
||||||
|
exporter.config.timeout_ms = NonZeroU64::new(timeout_ms).expect("non-zero timeout");
|
||||||
|
if mode == "missing" {
|
||||||
|
exporter.config.command = directory
|
||||||
|
.path()
|
||||||
|
.join("missing")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
} else if mode == "relative" {
|
||||||
|
exporter.config.command = std::path::Path::new(".")
|
||||||
|
.join("export-credentials")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
} else if mode == "normalized-relative" {
|
||||||
|
exporter.config.command = std::path::Path::new("scripts")
|
||||||
|
.join(".")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
}
|
||||||
|
let error = exporter
|
||||||
|
.credentials()
|
||||||
|
.await
|
||||||
|
.expect_err("export should fail");
|
||||||
|
assert_eq!(error.kind(), expected_kind);
|
||||||
|
assert!(!error.to_string().contains(SECRET));
|
||||||
|
assert!(!format!("{exporter:?}").contains(SECRET));
|
||||||
|
assert!(exporter.cached.lock().await.is_none());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn credential_export_reuses_cache_and_coalesces_refreshes() -> io::Result<()> {
|
||||||
|
let mut output = VALID_OUTPUT.to_vec();
|
||||||
|
output.resize(MAX_CREDENTIAL_OUTPUT_BYTES as usize, b' ');
|
||||||
|
let (directory, exporter) = exporter_fixture(&output, "success")?;
|
||||||
|
let original = AwsAccessKeys {
|
||||||
|
access_key_id: "exported".to_string(),
|
||||||
|
secret_access_key: SECRET.to_string(),
|
||||||
|
session_token: Some(SECRET.to_string()),
|
||||||
|
};
|
||||||
|
let (first, second) = tokio::join!(exporter.credentials(), exporter.credentials());
|
||||||
|
assert_eq!([first?, second?], [original.clone(), original.clone()]);
|
||||||
|
let rotated = json!({"AccessKeyId": "rotated", "SecretAccessKey": SECRET});
|
||||||
|
std::fs::write(directory.path().join("output.json"), rotated.to_string())?;
|
||||||
|
assert_eq!(exporter.credentials().await?, original);
|
||||||
|
|
||||||
|
exporter
|
||||||
|
.cached
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.as_mut()
|
||||||
|
.expect("populated cache")
|
||||||
|
.refresh_at = SystemTime::UNIX_EPOCH;
|
||||||
|
let rotated = AwsAccessKeys {
|
||||||
|
access_key_id: "rotated".to_string(),
|
||||||
|
secret_access_key: SECRET.to_string(),
|
||||||
|
session_token: None,
|
||||||
|
};
|
||||||
|
assert_eq!(exporter.credentials().await?, rotated);
|
||||||
|
|
||||||
|
let mut provider = AmazonBedrockModelProvider::new(
|
||||||
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
||||||
|
profile: None,
|
||||||
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: Some(exporter.config.clone()),
|
||||||
|
auth_refresh: None,
|
||||||
|
})),
|
||||||
|
/*auth_manager*/ None,
|
||||||
|
);
|
||||||
|
let exporter = Arc::new(exporter);
|
||||||
|
provider.credential_export = Some(exporter.clone());
|
||||||
|
{
|
||||||
|
let first = provider.recover_from_unauthorized();
|
||||||
|
let second = provider.recover_from_unauthorized();
|
||||||
|
tokio::pin!(first, second);
|
||||||
|
{
|
||||||
|
// Both callers must observe the same generation before either can export.
|
||||||
|
let _cached = exporter.cached.lock().await;
|
||||||
|
let mut context = Context::from_waker(Waker::noop());
|
||||||
|
assert!(first.as_mut().poll(&mut context).is_pending());
|
||||||
|
assert!(second.as_mut().poll(&mut context).is_pending());
|
||||||
|
}
|
||||||
|
let (first, second) = tokio::join!(first, second);
|
||||||
|
assert_eq!(
|
||||||
|
[
|
||||||
|
first.expect("first recovery"),
|
||||||
|
second.expect("second recovery")
|
||||||
|
],
|
||||||
|
[ProviderUnauthorizedRecovery::Recovered; 2],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(exporter.credentials().await?, rotated);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(directory.path().join("invocations"))?
|
||||||
|
.lines()
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["invoked", "invoked", "invoked"]
|
||||||
|
);
|
||||||
|
assert!(!format!("{exporter:?}").contains(SECRET));
|
||||||
|
|
||||||
|
provider.auth_recovery = Some(Arc::new(AwsAuthRecovery::new(AwsAuthRefreshConfig {
|
||||||
|
command: "not-aws".to_string(),
|
||||||
|
args: Vec::new(),
|
||||||
|
timeout_ms: NonZeroU64::new(5_000).expect("non-zero timeout"),
|
||||||
|
})));
|
||||||
|
let credentials = exporter.credentials();
|
||||||
|
let refresh = provider.recover_from_unauthorized();
|
||||||
|
tokio::pin!(credentials, refresh);
|
||||||
|
{
|
||||||
|
// A normal export completing must not make a queued login recovery look completed.
|
||||||
|
let mut cached = exporter.cached.lock().await;
|
||||||
|
cached.as_mut().expect("populated cache").refresh_at = SystemTime::UNIX_EPOCH;
|
||||||
|
let mut context = Context::from_waker(Waker::noop());
|
||||||
|
assert!(credentials.as_mut().poll(&mut context).is_pending());
|
||||||
|
assert!(refresh.as_mut().poll(&mut context).is_pending());
|
||||||
|
}
|
||||||
|
let (credentials, refresh) = tokio::join!(credentials, refresh);
|
||||||
|
assert_eq!(credentials?, rotated);
|
||||||
|
assert!(
|
||||||
|
!refresh
|
||||||
|
.expect_err("queued login recovery must still run")
|
||||||
|
.is_retryable()
|
||||||
|
);
|
||||||
|
assert!(exporter.cached.lock().await.is_none());
|
||||||
|
|
||||||
|
// Cancellation also leaves the invalidated cache empty and releases the guard.
|
||||||
|
exporter.credentials().await?;
|
||||||
|
let refresh = exporter.begin_refresh().await.expect("new recovery");
|
||||||
|
let credentials = exporter.credentials();
|
||||||
|
tokio::pin!(credentials);
|
||||||
|
assert!(
|
||||||
|
credentials
|
||||||
|
.as_mut()
|
||||||
|
.poll(&mut Context::from_waker(Waker::noop()))
|
||||||
|
.is_pending()
|
||||||
|
);
|
||||||
|
drop(refresh);
|
||||||
|
assert_eq!(credentials.await?, rotated);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ use codex_protocol::error::CodexErr;
|
|||||||
use codex_protocol::error::CodexErrorDetails;
|
use codex_protocol::error::CodexErrorDetails;
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
|
|
||||||
|
pub(super) const CREDENTIAL_EXPORT_CONFIG_ERROR_PREFIX: &str =
|
||||||
|
"AWS credential export configuration error:";
|
||||||
|
|
||||||
pub(super) const BEDROCK_EXPIRED_SIGNATURE_MESSAGE: &str = concat!(
|
pub(super) const BEDROCK_EXPIRED_SIGNATURE_MESSAGE: &str = concat!(
|
||||||
"Amazon Bedrock rejected the request because its AWS signature has expired. ",
|
"Amazon Bedrock rejected the request because its AWS signature has expired. ",
|
||||||
"Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, ",
|
"Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, ",
|
||||||
@@ -11,6 +14,11 @@ pub(super) const BEDROCK_EXPIRED_SIGNATURE_MESSAGE: &str = concat!(
|
|||||||
);
|
);
|
||||||
|
|
||||||
pub(super) fn map_api_error(error: ApiError) -> CodexErr {
|
pub(super) fn map_api_error(error: ApiError) -> CodexErr {
|
||||||
|
if let ApiError::Transport(TransportError::Build(message)) = &error
|
||||||
|
&& message.starts_with(CREDENTIAL_EXPORT_CONFIG_ERROR_PREFIX)
|
||||||
|
{
|
||||||
|
return CodexErr::InvalidRequest(message.clone());
|
||||||
|
}
|
||||||
let error = codex_api::map_api_error(error);
|
let error = codex_api::map_api_error(error);
|
||||||
if let CodexErrorDetails::UnexpectedStatus(response) = error.details()
|
if let CodexErrorDetails::UnexpectedStatus(response) = error.details()
|
||||||
&& response.status == StatusCode::UNAUTHORIZED
|
&& response.status == StatusCode::UNAUTHORIZED
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
|
use std::num::NonZeroU64;
|
||||||
|
|
||||||
use codex_api::ApiError;
|
use codex_api::ApiError;
|
||||||
use codex_api::TransportError;
|
use codex_api::TransportError;
|
||||||
|
use codex_model_provider_info::AwsCredentialExportConfig;
|
||||||
|
use codex_model_provider_info::ModelProviderAwsAuthInfo;
|
||||||
|
use codex_model_provider_info::ModelProviderInfo;
|
||||||
use codex_protocol::error::CodexErrorDetails;
|
use codex_protocol::error::CodexErrorDetails;
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
use http::HeaderValue;
|
use http::HeaderValue;
|
||||||
use http::StatusCode;
|
use http::StatusCode;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
use super::AmazonBedrockModelProvider;
|
||||||
use super::error::BEDROCK_EXPIRED_SIGNATURE_MESSAGE;
|
use super::error::BEDROCK_EXPIRED_SIGNATURE_MESSAGE;
|
||||||
|
use super::error::CREDENTIAL_EXPORT_CONFIG_ERROR_PREFIX;
|
||||||
use super::error::is_refreshable_auth_error;
|
use super::error::is_refreshable_auth_error;
|
||||||
use super::error::map_api_error;
|
use super::error::map_api_error;
|
||||||
|
use crate::ModelProvider;
|
||||||
|
|
||||||
const BEDROCK_RESPONSES_URL: &str = "https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses";
|
const BEDROCK_RESPONSES_URL: &str = "https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses";
|
||||||
|
|
||||||
@@ -144,3 +152,62 @@ fn classifies_only_refreshable_bedrock_auth_failures() {
|
|||||||
assert_eq!(is_refreshable_auth_error(&error), expected, "{error}");
|
assert_eq!(is_refreshable_auth_error(&error), expected, "{error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credential_export_errors_distinguish_configuration_failures_from_retryable_auth() {
|
||||||
|
let provider = AmazonBedrockModelProvider::new(
|
||||||
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
||||||
|
profile: None,
|
||||||
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: Some(AwsCredentialExportConfig {
|
||||||
|
command: "export-credentials".to_string(),
|
||||||
|
args: Vec::new(),
|
||||||
|
timeout_ms: NonZeroU64::new(5_000).expect("non-zero timeout"),
|
||||||
|
}),
|
||||||
|
auth_refresh: None,
|
||||||
|
})),
|
||||||
|
/*auth_manager*/ None,
|
||||||
|
);
|
||||||
|
for (error, recoverable, retryable) in [
|
||||||
|
(
|
||||||
|
ApiError::Transport(TransportError::Build(format!(
|
||||||
|
"{CREDENTIAL_EXPORT_CONFIG_ERROR_PREFIX} invalid credentials JSON"
|
||||||
|
))),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ApiError::Transport(TransportError::Build(
|
||||||
|
"failed to load AWS credentials: export command exited with status 1".to_string(),
|
||||||
|
)),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
http_error(StatusCode::UNAUTHORIZED, "ExpiredTokenException"),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
http_error(StatusCode::FORBIDDEN, "ExpiredTokenException"),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
http_error(StatusCode::FORBIDDEN, "AccessDeniedException"),
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ApiError::Transport(TransportError::Network("offline".to_string())),
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let ApiError::Transport(transport) = &error else {
|
||||||
|
unreachable!("all test cases are transport errors");
|
||||||
|
};
|
||||||
|
assert_eq!(provider.is_recoverable_auth_error(transport), recoverable);
|
||||||
|
assert_eq!(provider.map_api_error(error).is_retryable(), retryable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ mod tests {
|
|||||||
aws_auth_config(&ModelProviderAwsAuthInfo {
|
aws_auth_config(&ModelProviderAwsAuthInfo {
|
||||||
profile: Some("codex-bedrock".to_string()),
|
profile: Some("codex-bedrock".to_string()),
|
||||||
region: None,
|
region: None,
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}),
|
}),
|
||||||
AwsAuthConfig {
|
AwsAuthConfig {
|
||||||
@@ -110,6 +111,7 @@ mod tests {
|
|||||||
aws_auth_config(&ModelProviderAwsAuthInfo {
|
aws_auth_config(&ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: Some(" us-west-2 ".to_string()),
|
region: Some(" us-west-2 ".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}),
|
}),
|
||||||
AwsAuthConfig {
|
AwsAuthConfig {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod auth_refresh;
|
mod auth_refresh;
|
||||||
mod catalog;
|
mod catalog;
|
||||||
|
mod credential_export;
|
||||||
mod error;
|
mod error;
|
||||||
mod mantle;
|
mod mantle;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
@@ -43,6 +44,7 @@ use auth::resolve_provider_auth as resolve_bedrock_provider_auth;
|
|||||||
pub(crate) use auth_refresh::AwsAuthRecovery;
|
pub(crate) use auth_refresh::AwsAuthRecovery;
|
||||||
use catalog::normalize_bedrock_catalog;
|
use catalog::normalize_bedrock_catalog;
|
||||||
pub(crate) use catalog::static_model_catalog;
|
pub(crate) use catalog::static_model_catalog;
|
||||||
|
pub(crate) use credential_export::AwsCredentialExport;
|
||||||
use mantle::bedrock_mantle_runtime_base_url;
|
use mantle::bedrock_mantle_runtime_base_url;
|
||||||
pub use mantle::is_supported_amazon_bedrock_region;
|
pub use mantle::is_supported_amazon_bedrock_region;
|
||||||
use runtime::bedrock_runtime_base_url;
|
use runtime::bedrock_runtime_base_url;
|
||||||
@@ -61,6 +63,7 @@ pub(crate) struct AmazonBedrockModelProvider {
|
|||||||
aws: ModelProviderAwsAuthInfo,
|
aws: ModelProviderAwsAuthInfo,
|
||||||
endpoint: BedrockEndpoint,
|
endpoint: BedrockEndpoint,
|
||||||
auth_manager: Option<Arc<AuthManager>>,
|
auth_manager: Option<Arc<AuthManager>>,
|
||||||
|
credential_export: Option<Arc<AwsCredentialExport>>,
|
||||||
auth_recovery: Option<Arc<AwsAuthRecovery>>,
|
auth_recovery: Option<Arc<AwsAuthRecovery>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,11 +83,20 @@ impl AmazonBedrockModelProvider {
|
|||||||
.unwrap_or(ModelProviderAwsAuthInfo {
|
.unwrap_or(ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: None,
|
region: None,
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
});
|
});
|
||||||
|
let auth_source = auth::auth_source(&provider_info, auth_manager.as_deref(), std::env::var);
|
||||||
|
let credential_export = if auth_source == auth::BedrockAuthSource::CredentialExport {
|
||||||
|
process_shared_state().aws_credential_export(&aws)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let uses_aws_sdk_auth = matches!(
|
let uses_aws_sdk_auth = matches!(
|
||||||
auth::auth_source(&provider_info, auth_manager.as_deref(), std::env::var),
|
auth_source,
|
||||||
auth::BedrockAuthSource::ConfiguredAwsProfile | auth::BedrockAuthSource::AwsSdk
|
auth::BedrockAuthSource::CredentialExport
|
||||||
|
| auth::BedrockAuthSource::ConfiguredAwsProfile
|
||||||
|
| auth::BedrockAuthSource::AwsSdk
|
||||||
);
|
);
|
||||||
let auth_recovery = if uses_aws_sdk_auth && aws.auth_refresh.is_some() {
|
let auth_recovery = if uses_aws_sdk_auth && aws.auth_refresh.is_some() {
|
||||||
process_shared_state().aws_auth_recovery(&aws)
|
process_shared_state().aws_auth_recovery(&aws)
|
||||||
@@ -97,6 +109,7 @@ impl AmazonBedrockModelProvider {
|
|||||||
aws,
|
aws,
|
||||||
endpoint,
|
endpoint,
|
||||||
auth_manager,
|
auth_manager,
|
||||||
|
credential_export,
|
||||||
auth_recovery,
|
auth_recovery,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,11 +138,13 @@ impl AmazonBedrockModelProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn uses_aws_auth_recovery(&self) -> bool {
|
fn uses_aws_auth_recovery(&self) -> bool {
|
||||||
self.auth_recovery.is_some()
|
let source = self.auth_source();
|
||||||
&& matches!(
|
source == auth::BedrockAuthSource::CredentialExport
|
||||||
self.auth_source(),
|
|| (self.auth_recovery.is_some()
|
||||||
auth::BedrockAuthSource::ConfiguredAwsProfile | auth::BedrockAuthSource::AwsSdk
|
&& matches!(
|
||||||
)
|
source,
|
||||||
|
auth::BedrockAuthSource::ConfiguredAwsProfile | auth::BedrockAuthSource::AwsSdk
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn auth(&self) -> Option<CodexAuth> {
|
async fn auth(&self) -> Option<CodexAuth> {
|
||||||
@@ -140,7 +155,8 @@ impl AmazonBedrockModelProvider {
|
|||||||
},
|
},
|
||||||
auth::BedrockAuthSource::ManagedBearerToken
|
auth::BedrockAuthSource::ManagedBearerToken
|
||||||
| auth::BedrockAuthSource::ManagedAccessKeys => self.managed_auth(),
|
| auth::BedrockAuthSource::ManagedAccessKeys => self.managed_auth(),
|
||||||
auth::BedrockAuthSource::ConfiguredAwsProfile
|
auth::BedrockAuthSource::CredentialExport
|
||||||
|
| auth::BedrockAuthSource::ConfiguredAwsProfile
|
||||||
| auth::BedrockAuthSource::EnvBearerToken
|
| auth::BedrockAuthSource::EnvBearerToken
|
||||||
| auth::BedrockAuthSource::EnvAwsCredentials
|
| auth::BedrockAuthSource::EnvAwsCredentials
|
||||||
| auth::BedrockAuthSource::AwsSdk => None,
|
| auth::BedrockAuthSource::AwsSdk => None,
|
||||||
@@ -231,7 +247,8 @@ impl ModelProvider for AmazonBedrockModelProvider {
|
|||||||
auth::BedrockAuthSource::CommandBearerToken
|
auth::BedrockAuthSource::CommandBearerToken
|
||||||
| auth::BedrockAuthSource::ManagedBearerToken
|
| auth::BedrockAuthSource::ManagedBearerToken
|
||||||
| auth::BedrockAuthSource::ManagedAccessKeys => self.auth_manager.clone(),
|
| auth::BedrockAuthSource::ManagedAccessKeys => self.auth_manager.clone(),
|
||||||
auth::BedrockAuthSource::ConfiguredAwsProfile
|
auth::BedrockAuthSource::CredentialExport
|
||||||
|
| auth::BedrockAuthSource::ConfiguredAwsProfile
|
||||||
| auth::BedrockAuthSource::EnvBearerToken
|
| auth::BedrockAuthSource::EnvBearerToken
|
||||||
| auth::BedrockAuthSource::EnvAwsCredentials
|
| auth::BedrockAuthSource::EnvAwsCredentials
|
||||||
| auth::BedrockAuthSource::AwsSdk => None,
|
| auth::BedrockAuthSource::AwsSdk => None,
|
||||||
@@ -257,16 +274,39 @@ impl ModelProvider for AmazonBedrockModelProvider {
|
|||||||
&self,
|
&self,
|
||||||
) -> ModelProviderFuture<'_, Result<ProviderUnauthorizedRecovery>> {
|
) -> ModelProviderFuture<'_, Result<ProviderUnauthorizedRecovery>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let Some(recovery) = self
|
if !self.uses_aws_auth_recovery() {
|
||||||
.auth_recovery
|
|
||||||
.as_ref()
|
|
||||||
.filter(|_| self.uses_aws_auth_recovery())
|
|
||||||
else {
|
|
||||||
return Ok(ProviderUnauthorizedRecovery::NotConfigured);
|
return Ok(ProviderUnauthorizedRecovery::NotConfigured);
|
||||||
};
|
}
|
||||||
|
|
||||||
recovery.refresh().await.map_err(|error| {
|
// Hold the cache guard across both steps so concurrent callers share recovery.
|
||||||
if error.kind() == std::io::ErrorKind::InvalidInput {
|
let export_refresh = if let Some(exporter) = &self.credential_export {
|
||||||
|
let refresh = exporter.begin_refresh().await;
|
||||||
|
if refresh.is_none() {
|
||||||
|
// Another caller completed recovery while we were waiting.
|
||||||
|
return Ok(ProviderUnauthorizedRecovery::Recovered);
|
||||||
|
}
|
||||||
|
refresh
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let result: std::io::Result<()> = async {
|
||||||
|
if let Some(recovery) = &self.auth_recovery {
|
||||||
|
recovery.refresh().await?;
|
||||||
|
}
|
||||||
|
if let Some(exporter) = export_refresh {
|
||||||
|
exporter.refresh().await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
result.map_err(|error| {
|
||||||
|
if matches!(
|
||||||
|
error.kind(),
|
||||||
|
std::io::ErrorKind::InvalidData
|
||||||
|
| std::io::ErrorKind::InvalidInput
|
||||||
|
| std::io::ErrorKind::NotFound
|
||||||
|
| std::io::ErrorKind::PermissionDenied
|
||||||
|
) {
|
||||||
CodexErr::InvalidRequest(error.to_string())
|
CodexErr::InvalidRequest(error.to_string())
|
||||||
} else {
|
} else {
|
||||||
CodexErr::Io(error)
|
CodexErr::Io(error)
|
||||||
@@ -388,6 +428,7 @@ mod tests {
|
|||||||
provider_info.aws = Some(ModelProviderAwsAuthInfo {
|
provider_info.aws = Some(ModelProviderAwsAuthInfo {
|
||||||
profile: Some("aws-profile-that-should-not-be-loaded".to_string()),
|
profile: Some("aws-profile-that-should-not-be-loaded".to_string()),
|
||||||
region: Some("us-west-2".to_string()),
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: Some(AwsAuthRefreshConfig {
|
auth_refresh: Some(AwsAuthRefreshConfig {
|
||||||
command: "aws".to_string(),
|
command: "aws".to_string(),
|
||||||
args: vec!["login".into()],
|
args: vec!["login".into()],
|
||||||
@@ -423,6 +464,7 @@ mod tests {
|
|||||||
regional_provider_info.aws = Some(ModelProviderAwsAuthInfo {
|
regional_provider_info.aws = Some(ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: Some("us-west-2".to_string()),
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
});
|
});
|
||||||
let regional_provider =
|
let regional_provider =
|
||||||
@@ -448,6 +490,7 @@ mod tests {
|
|||||||
let aws = ModelProviderAwsAuthInfo {
|
let aws = ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: Some("us-west-2".to_string()),
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: Some(AwsAuthRefreshConfig {
|
auth_refresh: Some(AwsAuthRefreshConfig {
|
||||||
command: "aws".to_string(),
|
command: "aws".to_string(),
|
||||||
args: vec!["login".into()],
|
args: vec!["login".into()],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ fn aws_auth_config_uses_bedrock_service() {
|
|||||||
aws_auth_config(&ModelProviderAwsAuthInfo {
|
aws_auth_config(&ModelProviderAwsAuthInfo {
|
||||||
profile: Some("runtime-profile".to_string()),
|
profile: Some("runtime-profile".to_string()),
|
||||||
region: Some(" eu-west-1 ".to_string()),
|
region: Some(" eu-west-1 ".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}),
|
}),
|
||||||
AwsAuthConfig {
|
AwsAuthConfig {
|
||||||
|
|||||||
@@ -514,13 +514,17 @@ impl ModelProvider for ConfiguredModelProvider {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::future::Future;
|
||||||
use std::num::NonZeroU64;
|
use std::num::NonZeroU64;
|
||||||
|
use std::task::Context;
|
||||||
|
use std::task::Waker;
|
||||||
|
|
||||||
use codex_http_client::HttpClientFactory;
|
use codex_http_client::HttpClientFactory;
|
||||||
use codex_http_client::OutboundProxyPolicy;
|
use codex_http_client::OutboundProxyPolicy;
|
||||||
use codex_login::auth::AgentIdentityAuthPolicy;
|
use codex_login::auth::AgentIdentityAuthPolicy;
|
||||||
use codex_login::auth::BedrockApiKeyAuth;
|
use codex_login::auth::BedrockApiKeyAuth;
|
||||||
use codex_model_provider_info::AwsAuthRefreshConfig;
|
use codex_model_provider_info::AwsAuthRefreshConfig;
|
||||||
|
use codex_model_provider_info::AwsCredentialExportConfig;
|
||||||
use codex_model_provider_info::ModelProviderAwsAuthInfo;
|
use codex_model_provider_info::ModelProviderAwsAuthInfo;
|
||||||
use codex_model_provider_info::WireApi;
|
use codex_model_provider_info::WireApi;
|
||||||
use codex_model_provider_info::create_oss_provider_with_base_url;
|
use codex_model_provider_info::create_oss_provider_with_base_url;
|
||||||
@@ -766,6 +770,7 @@ mod tests {
|
|||||||
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
||||||
profile: Some("codex-bedrock".to_string()),
|
profile: Some("codex-bedrock".to_string()),
|
||||||
region: None,
|
region: None,
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
})),
|
})),
|
||||||
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
|
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
|
||||||
@@ -823,6 +828,7 @@ mod tests {
|
|||||||
let aws = ModelProviderAwsAuthInfo {
|
let aws = ModelProviderAwsAuthInfo {
|
||||||
profile: Some("codex-bedrock".to_string()),
|
profile: Some("codex-bedrock".to_string()),
|
||||||
region: Some("us-west-2".to_string()),
|
region: Some("us-west-2".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: Some(AwsAuthRefreshConfig {
|
auth_refresh: Some(AwsAuthRefreshConfig {
|
||||||
command: "aws".to_string(),
|
command: "aws".to_string(),
|
||||||
args: Vec::from(
|
args: Vec::from(
|
||||||
@@ -903,6 +909,93 @@ mod tests {
|
|||||||
ProviderUnauthorizedRecovery::Recovered
|
ProviderUnauthorizedRecovery::Recovered
|
||||||
);
|
);
|
||||||
assert_eq!(read_counter(), "11");
|
assert_eq!(read_counter(), "11");
|
||||||
|
|
||||||
|
let fixture = tempfile::tempdir().expect("export fixture should be created");
|
||||||
|
let export_counter = fixture.path().join("exports");
|
||||||
|
let export_gate = fixture.path().join("release");
|
||||||
|
#[cfg(unix)]
|
||||||
|
let (command, mut args) = (
|
||||||
|
"sh".to_string(),
|
||||||
|
vec![
|
||||||
|
"-c".to_string(),
|
||||||
|
r#"printf '1\n' >> "$1"
|
||||||
|
while [ ! -e "$2" ]; do :; done
|
||||||
|
printf '%s\n' '{"AccessKeyId":"exported","SecretAccessKey":"secret"}'
|
||||||
|
"#
|
||||||
|
.to_string(),
|
||||||
|
"export-credentials".to_string(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
#[cfg(windows)]
|
||||||
|
let (command, mut args) = {
|
||||||
|
let script = fixture.path().join("export.cmd");
|
||||||
|
std::fs::write(
|
||||||
|
&script,
|
||||||
|
concat!(
|
||||||
|
"@echo off\r\n>> \"%~1\" echo 1\r\n",
|
||||||
|
":wait\r\nif not exist \"%~2\" goto wait\r\n",
|
||||||
|
"echo {\"AccessKeyId\":\"exported\",\"SecretAccessKey\":\"secret\"}\r\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("export script should be written");
|
||||||
|
(
|
||||||
|
"cmd.exe".to_string(),
|
||||||
|
vec![
|
||||||
|
"/D".to_string(),
|
||||||
|
"/Q".to_string(),
|
||||||
|
"/C".to_string(),
|
||||||
|
script.to_string_lossy().into_owned(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
args.extend(
|
||||||
|
[&export_counter, &export_gate].map(|path| path.to_string_lossy().into_owned()),
|
||||||
|
);
|
||||||
|
let provider_info =
|
||||||
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
||||||
|
profile: None,
|
||||||
|
credential_export: Some(AwsCredentialExportConfig {
|
||||||
|
command,
|
||||||
|
args: args.into_iter().map(RedactedString::from).collect(),
|
||||||
|
timeout_ms: NonZeroU64::new(5_000).expect("timeout should be non-zero"),
|
||||||
|
}),
|
||||||
|
..aws.clone()
|
||||||
|
}));
|
||||||
|
let first_export = create_model_provider(provider_info.clone(), /*auth_manager*/ None);
|
||||||
|
let second_export = create_model_provider(provider_info, /*auth_manager*/ None);
|
||||||
|
let first_refresh = first_export.recover_from_unauthorized();
|
||||||
|
let second_refresh = second_export.recover_from_unauthorized();
|
||||||
|
tokio::pin!(first_refresh, second_refresh);
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||||
|
tokio::select! {
|
||||||
|
result = &mut first_refresh => panic!("export should wait for its gate: {result:?}"),
|
||||||
|
() = async {
|
||||||
|
while !export_counter.exists() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
} => {}
|
||||||
|
}
|
||||||
|
}).await.expect("export should start after login");
|
||||||
|
assert_eq!(read_counter(), "111");
|
||||||
|
{
|
||||||
|
// Queue another recovery after login finishes, while export still holds the lock.
|
||||||
|
let mut context = Context::from_waker(Waker::noop());
|
||||||
|
assert!(second_refresh.as_mut().poll(&mut context).is_pending());
|
||||||
|
}
|
||||||
|
std::fs::write(&export_gate, []).expect("export gate should open");
|
||||||
|
let (first_result, second_result) = tokio::join!(first_refresh, second_refresh);
|
||||||
|
assert_eq!(
|
||||||
|
[first_result, second_result].map(|result| result.expect("provider should recover")),
|
||||||
|
[ProviderUnauthorizedRecovery::Recovered; 2]
|
||||||
|
);
|
||||||
|
assert_eq!(read_counter(), "111");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(export_counter)
|
||||||
|
.expect("read export counter")
|
||||||
|
.lines()
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["1"]
|
||||||
|
);
|
||||||
std::fs::remove_file(&counter).expect("refresh invocation counter should be removed");
|
std::fs::remove_file(&counter).expect("refresh invocation counter should be removed");
|
||||||
|
|
||||||
let different_profile = ModelProviderAwsAuthInfo {
|
let different_profile = ModelProviderAwsAuthInfo {
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ use std::sync::Weak;
|
|||||||
use codex_model_provider_info::ModelProviderAwsAuthInfo;
|
use codex_model_provider_info::ModelProviderAwsAuthInfo;
|
||||||
|
|
||||||
use crate::amazon_bedrock::AwsAuthRecovery;
|
use crate::amazon_bedrock::AwsAuthRecovery;
|
||||||
|
use crate::amazon_bedrock::AwsCredentialExport;
|
||||||
|
|
||||||
/// Provider-owned runtime state shared across independently configured sessions.
|
/// Provider-owned runtime state shared across independently configured sessions.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub(crate) struct ModelProviderSharedState {
|
pub(crate) struct ModelProviderSharedState {
|
||||||
|
aws_credential_exports: Mutex<Vec<(ModelProviderAwsAuthInfo, Weak<AwsCredentialExport>)>>,
|
||||||
aws_auth_recoveries: Mutex<Vec<(ModelProviderAwsAuthInfo, Weak<AwsAuthRecovery>)>>,
|
aws_auth_recoveries: Mutex<Vec<(ModelProviderAwsAuthInfo, Weak<AwsAuthRecovery>)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +21,30 @@ pub(crate) fn process_shared_state() -> &'static ModelProviderSharedState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ModelProviderSharedState {
|
impl ModelProviderSharedState {
|
||||||
|
pub(crate) fn aws_credential_export(
|
||||||
|
&self,
|
||||||
|
aws: &ModelProviderAwsAuthInfo,
|
||||||
|
) -> Option<Arc<AwsCredentialExport>> {
|
||||||
|
let config = aws.credential_export.as_ref()?;
|
||||||
|
let mut exports = self
|
||||||
|
.aws_credential_exports
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
exports.retain(|(_, export)| export.strong_count() != 0);
|
||||||
|
|
||||||
|
if let Some(export) = exports
|
||||||
|
.iter()
|
||||||
|
.find(|(cached_aws, _)| cached_aws == aws)
|
||||||
|
.and_then(|(_, export)| export.upgrade())
|
||||||
|
{
|
||||||
|
return Some(export);
|
||||||
|
}
|
||||||
|
|
||||||
|
let export = Arc::new(AwsCredentialExport::new(config.clone()));
|
||||||
|
exports.push((aws.clone(), Arc::downgrade(&export)));
|
||||||
|
Some(export)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn aws_auth_recovery(
|
pub(crate) fn aws_auth_recovery(
|
||||||
&self,
|
&self,
|
||||||
aws: &ModelProviderAwsAuthInfo,
|
aws: &ModelProviderAwsAuthInfo,
|
||||||
|
|||||||
@@ -1378,10 +1378,13 @@ async fn failed_root_switch_keeps_background_requests_on_the_active_session() ->
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn root_switch_loads_local_preferences_from_disk() -> Result<()> {
|
async fn root_switch_loads_local_preferences_from_disk() -> Result<()> {
|
||||||
let mut app = make_test_app().await;
|
// Keep the large setup and root-switch futures off the test thread's stack.
|
||||||
let mut app_server =
|
let mut app = Box::pin(make_test_app()).await;
|
||||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||||||
let previous = app_server.start_thread(&app.config).await?;
|
app.chat_widget.config_ref(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
let previous = Box::pin(app_server.start_thread(&app.config)).await?;
|
||||||
app.enqueue_primary_thread_session(previous.session, previous.turns)
|
app.enqueue_primary_thread_session(previous.session, previous.turns)
|
||||||
.await?;
|
.await?;
|
||||||
let target_thread_id = ThreadId::from_string(
|
let target_thread_id = ThreadId::from_string(
|
||||||
@@ -1399,13 +1402,11 @@ async fn root_switch_loads_local_preferences_from_disk() -> Result<()> {
|
|||||||
app.local_settings.user_config_path.as_path(),
|
app.local_settings.user_config_path.as_path(),
|
||||||
"[tui]\ntheme = \"dracula\"\nresume_cwd = \"session\"\n[history]\npersistence = \"none\"\n",
|
"[tui]\ntheme = \"dracula\"\nresume_cwd = \"session\"\n[history]\npersistence = \"none\"\n",
|
||||||
)?;
|
)?;
|
||||||
let config = app
|
let config = Box::pin(app.rebuild_config_for_cwd(app.config.cwd.to_path_buf())).await?;
|
||||||
.rebuild_config_for_cwd(app.config.cwd.to_path_buf())
|
|
||||||
.await?;
|
|
||||||
let expected = crate::local_settings::LocalSettings::from(&config);
|
let expected = crate::local_settings::LocalSettings::from(&config);
|
||||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||||
|
|
||||||
app.select_agents_overview_thread(&mut tui, &mut app_server, target_thread_id)
|
Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, target_thread_id))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
assert_eq!(app.current_displayed_thread_id(), Some(target_thread_id));
|
assert_eq!(app.current_displayed_thread_id(), Some(target_thread_id));
|
||||||
|
|||||||
@@ -2377,8 +2377,9 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -
|
|||||||
.enable_all()
|
.enable_all()
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
runtime.block_on(async {
|
// Keep the scenario and its large setup/resume futures off the test thread's stack.
|
||||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
runtime.block_on(Box::pin(async {
|
||||||
|
let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||||||
let root_thread_id = ThreadId::new();
|
let root_thread_id = ThreadId::new();
|
||||||
let rollout_dir = app
|
let rollout_dir = app
|
||||||
.config
|
.config
|
||||||
@@ -2450,16 +2451,17 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cold-resume the persisted V2 root so its children are registered before selection.
|
// Cold-resume the persisted V2 root so its children are registered before selection.
|
||||||
let mut app_server =
|
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
app.chat_widget.config_ref(),
|
||||||
let root = app_server
|
))
|
||||||
.resume_thread(
|
.await?;
|
||||||
&app.local_settings,
|
let root = Box::pin(app_server.resume_thread(
|
||||||
app.config.clone(),
|
&app.local_settings,
|
||||||
root_thread_id,
|
app.config.clone(),
|
||||||
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
|
root_thread_id,
|
||||||
)
|
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
|
||||||
.await?;
|
))
|
||||||
|
.await?;
|
||||||
app.enqueue_primary_thread_session(root.session, root.turns)
|
app.enqueue_primary_thread_session(root.session, root.turns)
|
||||||
.await?;
|
.await?;
|
||||||
for (child_thread_id, multi_agent_version) in child_thread_ids
|
for (child_thread_id, multi_agent_version) in child_thread_ids
|
||||||
@@ -2531,14 +2533,13 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -
|
|||||||
.any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })))
|
.any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })))
|
||||||
);
|
);
|
||||||
|
|
||||||
let resumed = app_server
|
let resumed = Box::pin(app_server.resume_thread(
|
||||||
.resume_thread(
|
&app.local_settings,
|
||||||
&app.local_settings,
|
app.config.clone(),
|
||||||
app.config.clone(),
|
child_thread_ids[1],
|
||||||
child_thread_ids[1],
|
app.resume_model_settings(),
|
||||||
app.resume_model_settings(),
|
))
|
||||||
)
|
.await?;
|
||||||
.await?;
|
|
||||||
assert!(resumed.blocks_direct_input);
|
assert!(resumed.blocks_direct_input);
|
||||||
app.replace_chat_widget_with_app_server_thread(
|
app.replace_chat_widget_with_app_server_thread(
|
||||||
&mut tui,
|
&mut tui,
|
||||||
@@ -2560,7 +2561,7 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -
|
|||||||
.any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })))
|
.any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })))
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -286,7 +286,8 @@ async fn cached_legacy_resume_revalidates_history_across_migration_settings() ->
|
|||||||
[(false, false), (false, true), (true, false), (true, true)]
|
[(false, false), (false, true), (true, false), (true, true)]
|
||||||
{
|
{
|
||||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||||
let config = build_config(&codex_home).await;
|
// Keep the large setup futures off the test thread's stack.
|
||||||
|
let config = Box::pin(build_config(&codex_home)).await;
|
||||||
let legacy_thread_id = ThreadId::from_string(
|
let legacy_thread_id = ThreadId::from_string(
|
||||||
&create_fake_rollout(
|
&create_fake_rollout(
|
||||||
codex_home.path(),
|
codex_home.path(),
|
||||||
@@ -314,7 +315,8 @@ async fn cached_legacy_resume_revalidates_history_across_migration_settings() ->
|
|||||||
let maintenance_guard =
|
let maintenance_guard =
|
||||||
codex_rollout::try_acquire_rollout_maintenance_lock(codex_home.path())?
|
codex_rollout::try_acquire_rollout_maintenance_lock(codex_home.path())?
|
||||||
.expect("acquire rollout maintenance lock");
|
.expect("acquire rollout maintenance lock");
|
||||||
let mut app_server = crate::start_embedded_app_server_for_picker(&startup_config).await?;
|
let mut app_server =
|
||||||
|
Box::pin(crate::start_embedded_app_server_for_picker(&startup_config)).await?;
|
||||||
app_server.remember_thread_history_mode(legacy_thread_id, ThreadHistoryMode::Legacy);
|
app_server.remember_thread_history_mode(legacy_thread_id, ThreadHistoryMode::Legacy);
|
||||||
let local_settings = crate::local_settings::LocalSettings::from(&resume_config);
|
let local_settings = crate::local_settings::LocalSettings::from(&resume_config);
|
||||||
let next_request_id = app_server.next_request_id;
|
let next_request_id = app_server.next_request_id;
|
||||||
|
|||||||
@@ -744,6 +744,7 @@ async fn status_uses_server_provider_id_and_auth_requirement() {
|
|||||||
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo {
|
||||||
profile: None,
|
profile: None,
|
||||||
region: Some("eu-west-1".to_string()),
|
region: Some("eu-west-1".to_string()),
|
||||||
|
credential_export: None,
|
||||||
auth_refresh: None,
|
auth_refresh: None,
|
||||||
}));
|
}));
|
||||||
config.model_provider.base_url =
|
config.model_provider.base_url =
|
||||||
|
|||||||
Reference in new issue
Block a user