fix bedrock authentication refresh

This commit is contained in:
Josh Woodcock
2026-10-14 12:43:32 -05:00
parent 55eb87a983
commit 04cff91509
19 changed files with 777 additions and 237 deletions
+40 -16
View File
@@ -14,6 +14,10 @@ use tokio::sync::Mutex;
use vec1::vec1; use vec1::vec1;
use crate::settings::{AISettings, AISettingsChangedEvent}; use crate::settings::{AISettings, AISettingsChangedEvent};
use crate::ai::provider::authentication::{
BedrockAuthConnection, connection_revision, credentials_are_current,
};
use crate::ai::provider::client::BedrockClientConfig;
use crate::terminal::event::{AfterBlockCompletedEvent, BlockType, UserBlockCompleted}; use crate::terminal::event::{AfterBlockCompletedEvent, BlockType, UserBlockCompleted};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
@@ -56,7 +60,7 @@ fn aws_profile_reference_for_message(profile: &str, capitalize_first_word: bool)
} }
} }
fn user_facing_aws_credentials_error_message(err: &CredentialsError, profile: &str) -> String { pub(crate) fn user_facing_aws_credentials_error_message(err: &CredentialsError, profile: &str) -> String {
match err { match err {
CredentialsError::CredentialsNotLoaded(_) => format!( CredentialsError::CredentialsNotLoaded(_) => format!(
"AWS credentials were not found for {}. Log in with the AWS CLI or update your AWS credentials configuration, then refresh.", "AWS credentials were not found for {}. Log in with the AWS CLI or update your AWS credentials configuration, then refresh.",
@@ -129,15 +133,17 @@ fn aws_credentials_state_for_error(err: LoadAwsCredentialsError) -> AwsCredentia
/// Loads AWS credentials from the AWS SDK. /// Loads AWS credentials from the AWS SDK.
/// ///
/// # Arguments /// # Arguments
/// * `profile` - AWS profile name. If empty, uses the default AWS SDK behavior /// * `connection` - Effective AWS connection. If its profile is empty, uses the default AWS SDK behavior
/// (checks AWS_PROFILE env var, then uses "default"). /// (checks AWS_PROFILE env var, then uses "default").
pub async fn load_aws_credentials_from_sdk( pub async fn load_aws_credentials_from_sdk(
profile: &str, connection: &BedrockClientConfig,
) -> Result<AwsCredentials, LoadAwsCredentialsError> { ) -> Result<AwsCredentials, LoadAwsCredentialsError> {
let region_provider = aws_config::meta::region::RegionProviderChain::default_provider(); let profile = &connection.profile;
let loader = let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
aws_config::defaults(aws_config::BehaviorVersion::latest()).region(region_provider); if !connection.region.is_empty() {
let loader = if profile.trim().is_empty() || profile.trim() == "default" { loader = loader.region(aws_config::Region::new(connection.region.clone()));
}
let loader = if profile.trim().is_empty() {
loader // Let AWS SDK use its default behavior loader // Let AWS SDK use its default behavior
} else { } else {
loader.profile_name(profile) loader.profile_name(profile)
@@ -150,14 +156,15 @@ pub async fn load_aws_credentials_from_sdk(
let creds = provider.provide_credentials().await.map_err(|e| { let creds = provider.provide_credentials().await.map_err(|e| {
let message = user_facing_aws_credentials_error_message(&e, profile); let message = user_facing_aws_credentials_error_message(&e, profile);
log::warn!("{e}"); log::warn!("AWS credential reload failed: {message}");
// TODO(isaiah): turn this full SDK dump back down to debug once we've resolved
// the current customer-facing AWS credential issue and no longer need prod-visible
// provider internals for support debugging.
log::warn!("{e:#?}");
log::info!("AWS credential load failure message shown to user: {message}"); log::info!("AWS credential load failure message shown to user: {message}");
LoadAwsCredentialsError::CredentialsLoadFailed(message) LoadAwsCredentialsError::CredentialsLoadFailed(message)
})?; })?;
if !credentials_are_current(creds.expiry(), SystemTime::now()) {
return Err(LoadAwsCredentialsError::CredentialsLoadFailed(
"AWS credentials have expired. Refresh your AWS login.".to_owned(),
));
}
Ok(AwsCredentials::new( Ok(AwsCredentials::new(
creds.access_key_id().to_string(), creds.access_key_id().to_string(),
@@ -198,8 +205,10 @@ impl AwsCredentialRefresher for ApiKeyManager {
.. ..
}) = event }) = event
{ {
let auth_command = &AISettings::as_ref(ctx).bedrock_auth_refresh_command; let auth_command = BedrockAuthConnection::current(ctx).command;
if command.trim().starts_with(auth_command.trim()) { if !auth_command.trim().is_empty()
&& command.trim().starts_with(auth_command.trim())
{
log::debug!("Detected AWS auth command completion, refreshing credentials"); log::debug!("Detected AWS auth command completion, refreshing credentials");
drop(refresh_aws_credentials(manager, ctx)); drop(refresh_aws_credentials(manager, ctx));
} }
@@ -225,6 +234,8 @@ impl AwsCredentialRefresher for ApiKeyManager {
if matches!( if matches!(
event, event,
AISettingsChangedEvent::BedrockProfile { .. } AISettingsChangedEvent::BedrockProfile { .. }
| AISettingsChangedEvent::BedrockAuthMethod { .. }
| AISettingsChangedEvent::BedrockRegion { .. }
| AISettingsChangedEvent::BedrockAuthRefreshCommand { .. } | AISettingsChangedEvent::BedrockAuthRefreshCommand { .. }
| AISettingsChangedEvent::BedrockEnabled { .. } | AISettingsChangedEvent::BedrockEnabled { .. }
) { ) {
@@ -266,15 +277,28 @@ fn refresh_aws_credentials_local_chain(
return Box::pin(async { Ok(()) }); return Box::pin(async { Ok(()) });
} }
let profile = (*AISettings::as_ref(ctx).bedrock_profile).clone(); let connection = BedrockAuthConnection::current(ctx);
if connection.config.auth_method == crate::settings::BedrockAuthMethod::StaticKeys {
manager.set_aws_credentials_state(AwsCredentialsState::Missing, ctx);
return Box::pin(async { Ok(()) });
}
let config = connection.config.clone();
let revision = connection_revision(ctx);
manager.set_aws_credentials_state(AwsCredentialsState::Refreshing, ctx); manager.set_aws_credentials_state(AwsCredentialsState::Refreshing, ctx);
let (tx, rx) = channel(); let (tx, rx) = channel();
// credential fetch from aws cli's disk cache // credential fetch from aws cli's disk cache
let _ = ctx.spawn( let _ = ctx.spawn(
async move { load_aws_credentials_from_sdk(&profile).await }, async move { load_aws_credentials_from_sdk(&config).await },
move |manager, result, ctx| { move |manager, result, ctx| {
if connection_revision(ctx) != revision
|| BedrockAuthConnection::current(ctx) != connection
|| manager.aws_credentials_refresh_strategy() != AwsCredentialsRefreshStrategy::LocalChain
{
let _ = tx.send(Err("AWS connection changed during credential reload".to_owned()));
return;
}
let (new_state, tx_result) = match result { let (new_state, tx_result) = match result {
Ok(credentials) => ( Ok(credentials) => (
AwsCredentialsState::Loaded { AwsCredentialsState::Loaded {
+2 -2
View File
@@ -4439,7 +4439,7 @@ impl AIBlock {
} }
let ai_settings = AISettings::as_ref(ctx); let ai_settings = AISettings::as_ref(ctx);
let login_command = ai_settings.bedrock_auth_refresh_command.value().clone(); let login_command = crate::ai::provider::authentication::BedrockAuthConnection::current(ctx).command;
// Recreating a historical error view must not restart authentication. // Recreating a historical error view must not restart authentication.
let auto_login_enabled = attempt_auto_login && *ai_settings.bedrock_auto_login.value(); let auto_login_enabled = attempt_auto_login && *ai_settings.bedrock_auto_login.value();
@@ -4452,7 +4452,7 @@ impl AIBlock {
let model_name = model_name.clone(); let model_name = model_name.clone();
let view = ctx.add_typed_action_view(|ctx| { let view = ctx.add_typed_action_view(|ctx| {
AwsBedrockCredentialsErrorView::new(model_name, login_command, auto_login_enabled, ctx) AwsBedrockCredentialsErrorView::new(model_name, login_command, ctx)
}); });
// Subscribe to events from the view and emit AIBlockEvents directly // Subscribe to events from the view and emit AIBlockEvents directly
+22 -7
View File
@@ -6610,7 +6610,11 @@ impl BlocklistAIController {
let prepared = match result { let prepared = match result {
Ok(prepared) => prepared, Ok(prepared) => prepared,
Err(error) => { Err(error) => {
self.fail_provider_startup(conversation_id, stream_id, error.to_string(), ctx); let renderable = crate::ai::provider::authentication::provider_startup_error(
&error,
slot.response_stream.as_ref(ctx).model_id(),
);
self.fail_provider_startup_with_error(conversation_id, stream_id, renderable, ctx);
return; return;
} }
}; };
@@ -8916,6 +8920,21 @@ impl BlocklistAIController {
stream_id: ResponseStreamId, stream_id: ResponseStreamId,
message: String, message: String,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) {
self.fail_provider_startup_with_error(
conversation_id,
stream_id,
RenderableAIError::other(message, false),
ctx,
);
}
fn fail_provider_startup_with_error(
&mut self,
conversation_id: AIConversationId,
stream_id: ResponseStreamId,
error: RenderableAIError,
ctx: &mut ModelContext<Self>,
) { ) {
let response_stream = self let response_stream = self
.active_provider_runs .active_provider_runs
@@ -8925,14 +8944,10 @@ impl BlocklistAIController {
let Some(response_stream) = response_stream else { let Some(response_stream) = response_stream else {
return; return;
}; };
let message = error.to_string();
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.mark_response_stream_completed_with_error( history_model.mark_response_stream_completed_with_error(
RenderableAIError::Other { error,
error_message: message.clone(),
will_attempt_resume: false,
waiting_for_network: false,
is_user_error: false,
},
false, false,
&stream_id, &stream_id,
conversation_id, conversation_id,
@@ -187,30 +187,12 @@ impl ResponseStream {
// Fall back to Bedrock // Fall back to Bedrock
if *settings.bedrock_enabled.value() { if *settings.bedrock_enabled.value() {
let auth_method = *settings.bedrock_auth_method.value();
let region = settings.bedrock_region.value().clone();
let cross_region_inference = *settings.bedrock_cross_region_inference.value();
let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx); let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx);
let mut config = BedrockClientConfig { let config = BedrockClientConfig::from_settings(settings).with_managed_credentials(
auth_method, &api_key_manager.aws_credentials_refresh_strategy(),
profile: settings.bedrock_profile.value().clone(), api_key_manager.aws_credentials_state(),
region, );
access_key_id: settings.bedrock_access_key_id.value().clone(), return ProviderConfig::Bedrock(config);
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
session_token: None,
cross_region_inference,
};
if let ::ai::api_keys::AwsCredentialsState::Loaded { credentials, .. } =
api_key_manager.aws_credentials_state()
{
config.auth_method = crate::settings::BedrockAuthMethod::StaticKeys;
config.access_key_id = credentials.access_key().to_string();
config.secret_access_key = credentials.secret_key().to_string();
config.session_token = credentials.session_token().map(|s| s.to_string());
}
return ProviderConfig::Bedrock(config.with_external_fallbacks());
} }
ProviderConfig::None ProviderConfig::None
@@ -35,8 +35,6 @@ pub struct AwsBedrockCredentialsErrorView {
model_name: String, model_name: String,
login_command: String, login_command: String,
/// Whether auto-login was triggered, which shows a simpler "running command" message.
auto_login_triggered: bool,
// Run button // Run button
run_button: ViewHandle<ActionButton>, run_button: ViewHandle<ActionButton>,
@@ -52,7 +50,6 @@ impl AwsBedrockCredentialsErrorView {
pub fn new( pub fn new(
model_name: String, model_name: String,
login_command: String, login_command: String,
auto_login_triggered: bool,
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) -> Self { ) -> Self {
// Run button // Run button
@@ -83,7 +80,6 @@ impl AwsBedrockCredentialsErrorView {
Self { Self {
model_name, model_name,
login_command, login_command,
auto_login_triggered,
run_button, run_button,
configure_button, configure_button,
auto_login_checkbox_handle: MouseStateHandle::default(), auto_login_checkbox_handle: MouseStateHandle::default(),
@@ -104,23 +100,6 @@ impl View for AwsBedrockCredentialsErrorView {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let theme = appearance.theme(); let theme = appearance.theme();
// If auto-login was triggered, show a simple "running command" message
if self.auto_login_triggered {
return Flex::row()
.with_spacing(8.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new(
format!("Running `{}`...", self.login_command),
appearance.ui_font_family(),
14.,
)
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
.with_selectable(false)
.finish(),
)
.finish();
}
let auto_login_enabled = *AISettings::as_ref(app).bedrock_auto_login.value(); let auto_login_enabled = *AISettings::as_ref(app).bedrock_auto_login.value();
@@ -149,11 +128,19 @@ impl View for AwsBedrockCredentialsErrorView {
let make_detail_text = || { let make_detail_text = || {
Text::new( Text::new(
if self.login_command.is_empty() {
format!(
"Failed to authenticate with AWS Bedrock when using {}. \
Refresh credentials for the AWS profile selected in Providers, then retry.",
self.model_name
)
} else {
format!( format!(
"Failed to authenticate with AWS Bedrock when using {}. \ "Failed to authenticate with AWS Bedrock when using {}. \
Run `{}` to refresh credentials.", Run `{}` to refresh credentials.",
self.model_name, self.login_command self.model_name, self.login_command
), )
},
appearance.ui_font_family(), appearance.ui_font_family(),
14., 14.,
) )
+6 -21
View File
@@ -181,28 +181,13 @@ impl CrosscheckReviewer {
// Fall back to Bedrock via external config // Fall back to Bedrock via external config
if *settings.bedrock_enabled.value() { if *settings.bedrock_enabled.value() {
let auth_method = *settings.bedrock_auth_method.value();
let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx); let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx);
let mut config = crate::ai::provider::client::BedrockClientConfig { let config = crate::ai::provider::client::BedrockClientConfig::from_settings(settings)
auth_method, .with_managed_credentials(
profile: settings.bedrock_profile.value().clone(), &api_key_manager.aws_credentials_refresh_strategy(),
region: settings.bedrock_region.value().clone(), api_key_manager.aws_credentials_state(),
access_key_id: settings.bedrock_access_key_id.value().clone(), );
secret_access_key: settings.bedrock_secret_access_key.value().clone(), return ProviderConfig::Bedrock(config);
session_token: None,
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
};
if let ::ai::api_keys::AwsCredentialsState::Loaded { credentials, .. } =
api_key_manager.aws_credentials_state()
{
config.auth_method = crate::settings::BedrockAuthMethod::StaticKeys;
config.access_key_id = credentials.access_key().to_string();
config.secret_access_key = credentials.secret_key().to_string();
config.session_token = credentials.session_token().map(|s| s.to_string());
}
return ProviderConfig::Bedrock(config.with_external_fallbacks());
} }
ProviderConfig::None ProviderConfig::None
+1 -9
View File
@@ -3471,13 +3471,5 @@ mod tests;
fn bedrock_discovery_config( fn bedrock_discovery_config(
settings: &AISettings, settings: &AISettings,
) -> crate::ai::provider::client::BedrockClientConfig { ) -> crate::ai::provider::client::BedrockClientConfig {
crate::ai::provider::client::BedrockClientConfig { crate::ai::provider::client::BedrockClientConfig::from_settings(settings)
auth_method: *settings.bedrock_auth_method.value(),
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
session_token: None,
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
}
} }
+2
View File
@@ -85,6 +85,8 @@ pub mod outline;
pub(crate) use ai::paths; pub(crate) use ai::paths;
pub fn init(app: &mut AppContext) { pub fn init(app: &mut AppContext) {
#[cfg(not(target_family = "wasm"))]
app.add_singleton_model(provider::authentication::BedrockAuthentication::new);
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
app.add_singleton_model(acp::AcpRuntimeModel::new); app.add_singleton_model(acp::AcpRuntimeModel::new);
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
+316
View File
@@ -0,0 +1,316 @@
use std::collections::HashMap;
use std::time::SystemTime;
use ai::api_keys::{ApiKeyManager, AwsCredentials, AwsCredentialsRefreshStrategy, AwsCredentialsState};
use futures::channel::oneshot;
use futures::future::{BoxFuture, Shared};
use futures::FutureExt;
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
use settings::Setting;
use super::client::{BedrockClientConfig, BedrockError};
use crate::ai::agent::RenderableAIError;
use crate::settings::{AISettings, AISettingsChangedEvent, BedrockAuthMethod};
impl BedrockClientConfig {
pub(crate) fn from_settings(settings: &AISettings) -> Self {
Self {
auth_method: *settings.bedrock_auth_method.value(),
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
session_token: None,
credentials_expiry: None,
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
}
}
pub(crate) fn with_managed_credentials(
mut self,
strategy: &AwsCredentialsRefreshStrategy,
state: &AwsCredentialsState,
) -> Self {
match strategy {
AwsCredentialsRefreshStrategy::LocalChain => self,
AwsCredentialsRefreshStrategy::OidcManaged { region, .. } => {
self.auth_method = BedrockAuthMethod::StaticKeys;
self.region.clone_from(region);
self.access_key_id.clear();
self.secret_access_key.clear();
self.session_token = None;
self.credentials_expiry = None;
if let AwsCredentialsState::Loaded { credentials, .. } = state {
self.access_key_id = credentials.access_key().to_owned();
self.secret_access_key = credentials.secret_key().to_owned();
self.session_token = credentials.session_token().map(str::to_owned);
self.credentials_expiry = Some(
credentials.expires_at().unwrap_or(SystemTime::UNIX_EPOCH),
);
}
self
}
}
}
}
pub(crate) fn credentials_are_current(expiry: Option<SystemTime>, now: SystemTime) -> bool {
expiry.is_none_or(|expiry| expiry > now)
}
pub(crate) fn provider_startup_error(error: &anyhow::Error, model: &str) -> RenderableAIError {
match error.downcast_ref::<BedrockError>() {
Some(BedrockError::CredentialsNotConfigured | BedrockError::CredentialsLoadFailed(_)) => {
RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid {
model_name: model.to_owned(),
}
}
Some(
BedrockError::RegionNotConfigured
| BedrockError::ApiError(_)
| BedrockError::ModelNotFound(_)
| BedrockError::AccessDenied(_)
| BedrockError::Throttling(_)
| BedrockError::ValidationError(_),
) | None => RenderableAIError::other(error.to_string(), false),
}
}
pub(crate) fn login_command(
configured: &str,
profile: &str,
) -> String {
if configured != "aws sso login" {
return configured.to_owned();
}
if profile.is_empty() {
configured.to_owned()
} else {
format!("aws sso login --profile {}", shell_words::quote(profile))
}
}
pub(crate) fn login_command_parts(command: &str) -> Result<Vec<String>, String> {
let parts = shell_words::split(command)
.map_err(|_| "AWS login command contains invalid quoting".to_owned())?;
if parts.is_empty() {
return Err("AWS login command is not configured".to_owned());
}
Ok(parts)
}
#[derive(Clone, PartialEq)]
pub(crate) struct BedrockAuthConnection {
pub(crate) config: BedrockClientConfig,
pub(crate) enabled: bool,
pub(crate) auto_login: bool,
pub(crate) command: String,
}
impl BedrockAuthConnection {
pub(crate) fn current(ctx: &AppContext) -> Self {
let settings = AISettings::as_ref(ctx);
let config = BedrockClientConfig::from_settings(settings);
Self {
command: match config.auth_method {
BedrockAuthMethod::Sso => login_command(
settings.bedrock_auth_refresh_command.value(),
&config.profile,
),
BedrockAuthMethod::Profile | BedrockAuthMethod::StaticKeys => String::new(),
},
config,
enabled: *settings.bedrock_enabled.value(),
auto_login: *settings.bedrock_auto_login.value(),
}
}
fn identity(&self) -> String {
format!(
"{:?}:{}:{}:{}",
self.config.auth_method, self.config.profile, self.config.region, self.command
)
}
}
#[derive(Default)]
struct RecoveryAttempts {
exchanges: HashMap<String, (String, String)>,
}
impl RecoveryAttempts {
fn begin(&mut self, conversation: &str, exchange: &str, connection: &str, manual: bool) -> bool {
let attempt = (exchange.to_owned(), connection.to_owned());
if !manual && self.exchanges.get(conversation) == Some(&attempt) {
return false;
}
self.exchanges.insert(conversation.to_owned(), attempt);
true
}
}
type CredentialResult = Result<AwsCredentials, String>;
type CredentialFuture = Shared<BoxFuture<'static, CredentialResult>>;
#[derive(Default)]
pub(crate) struct BedrockAuthentication {
attempts: RecoveryAttempts,
active: Vec<(u64, BedrockAuthConnection, CredentialFuture)>,
generation: u64,
revision: u64,
}
impl BedrockAuthentication {
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, event, _| {
if matches!(
event,
AISettingsChangedEvent::BedrockEnabled { .. }
| AISettingsChangedEvent::BedrockAuthMethod { .. }
| AISettingsChangedEvent::BedrockProfile { .. }
| AISettingsChangedEvent::BedrockRegion { .. }
| AISettingsChangedEvent::BedrockAuthRefreshCommand { .. }
| AISettingsChangedEvent::BedrockAccessKeyId { .. }
| AISettingsChangedEvent::BedrockSecretAccessKey { .. }
) {
me.revision += 1;
me.active.clear();
}
});
Self::default()
}
pub(crate) fn revision(&self) -> u64 {
self.revision
}
pub(crate) fn recover(
&mut self,
conversation: &str,
exchange: &str,
manual: bool,
ctx: &mut ModelContext<Self>,
) -> BoxFuture<'static, Result<(), String>> {
let connection = BedrockAuthConnection::current(ctx);
if !connection.enabled || (!manual && !connection.auto_login) {
return Box::pin(async { Err("Automatic AWS login is disabled".to_owned()) });
}
if !self.attempts.begin(conversation, exchange, &connection.identity(), manual) {
return Box::pin(async {
Err("AWS authentication still failed after recovery. Use Refresh AWS Credentials to retry.".to_owned())
});
}
let strategy = ApiKeyManager::as_ref(ctx).aws_credentials_refresh_strategy();
if matches!(strategy, AwsCredentialsRefreshStrategy::OidcManaged { .. }) {
return ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
crate::ai::aws_credentials::refresh_aws_credentials(manager, ctx)
});
}
if connection.config.auth_method == BedrockAuthMethod::StaticKeys {
return Box::pin(async {
Err("Update the configured AWS static keys to authenticate.".to_owned())
});
}
let (generation, future) = match self.active.iter().find(|(_, active, _)| active == &connection) {
Some((generation, _, future)) => (*generation, future.clone()),
None => {
self.generation += 1;
let config = connection.config.clone();
let command = connection.command.clone();
let future = recover_credentials(
connection.config.auth_method,
manual,
move || {
let config = config.clone();
async move {
crate::ai::aws_credentials::load_aws_credentials_from_sdk(&config)
.await
.map_err(|error| error.to_string())
}.boxed()
},
move || {
let command = command.clone();
async move {
let parts = login_command_parts(&command)?;
let status = tokio::process::Command::new(&parts[0])
.args(&parts[1..])
.status()
.await
.map_err(|error| format!("Failed to start AWS login: {error}"))?;
if !status.success() {
return Err(format!("AWS login command exited with status: {status}"));
}
Ok(())
}.boxed()
},
).boxed().shared();
self.active.push((self.generation, connection.clone(), future.clone()));
(self.generation, future)
}
};
let revision = self.revision;
let (tx, rx) = oneshot::channel();
let _ = ctx.spawn(future, move |me, result, ctx| {
me.active.retain(|(active, _, _)| *active != generation);
if me.revision != revision
|| BedrockAuthConnection::current(ctx) != connection
|| ApiKeyManager::as_ref(ctx).aws_credentials_refresh_strategy() != strategy
{
let _ = tx.send(Err("AWS connection changed during authentication".to_owned()));
return;
}
let outcome = result.as_ref().map(|_| ()).map_err(Clone::clone);
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
let state = match result {
Ok(credentials) => AwsCredentialsState::Loaded {
credentials,
loaded_at: SystemTime::now(),
},
Err(message) => AwsCredentialsState::Failed { message },
};
manager.set_aws_credentials_state(state, ctx);
});
let _ = tx.send(outcome);
});
async move {
rx.await.unwrap_or_else(|_| Err("AWS authentication was interrupted".to_owned()))
}.boxed()
}
}
async fn recover_credentials(
auth_method: BedrockAuthMethod,
login_first: bool,
load: impl Fn() -> BoxFuture<'static, CredentialResult>,
login: impl Fn() -> BoxFuture<'static, Result<(), String>>,
) -> CredentialResult {
match auth_method {
BedrockAuthMethod::Profile => return load().await,
BedrockAuthMethod::StaticKeys => {
return Err("Update the configured AWS static keys to authenticate.".to_owned());
}
BedrockAuthMethod::Sso => {}
}
if !login_first {
if let Ok(credentials) = load().await {
return Ok(credentials);
}
}
login().await?;
load().await
}
impl Entity for BedrockAuthentication {
type Event = ();
}
impl SingletonEntity for BedrockAuthentication {}
pub(crate) fn connection_revision(ctx: &AppContext) -> u64 {
ctx.try_get_singleton_model_as_ref::<BedrockAuthentication>()
.map_or(0, |authentication| authentication.revision())
}
#[cfg(test)]
#[path = "authentication_tests.rs"]
mod tests;
+277
View File
@@ -0,0 +1,277 @@
use std::time::{Duration, SystemTime};
use ai::api_keys::{AwsCredentials, AwsCredentialsRefreshStrategy, AwsCredentialsState};
use super::*;
fn config(auth_method: BedrockAuthMethod) -> BedrockClientConfig {
BedrockClientConfig {
auth_method,
profile: "default".to_owned(),
region: String::new(),
access_key_id: "configured-key".to_owned(),
secret_access_key: "configured-secret".to_owned(),
session_token: None,
credentials_expiry: None,
cross_region_inference: true,
}
}
#[test]
fn provider_profile_and_region_are_authoritative_including_defaults() {
galaxyui::App::test((), |mut app| async move {
crate::test_util::settings::initialize_settings_for_tests(&mut app);
for (profile, region) in [("default", ""), ("selected-profile", "eu-west-1")] {
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings.bedrock_profile.set_value(profile.to_owned(), ctx).unwrap();
settings.bedrock_region.set_value(region.to_owned(), ctx).unwrap();
});
app.read(|ctx| {
let resolved = BedrockClientConfig::from_settings(AISettings::as_ref(ctx));
assert_eq!(resolved.profile, profile);
assert_eq!(resolved.region, region);
});
}
});
}
#[test]
fn custom_login_commands_are_not_rewritten() {
for command in ["", "company-login --interactive", "aws sso login --profile custom"] {
assert_eq!(login_command(command, "selected"), command);
}
}
#[test]
fn default_login_targets_and_quotes_the_effective_profile() {
assert_eq!(
login_command("aws sso login", "default"),
"aws sso login --profile default"
);
let command = login_command(
"aws sso login",
"team profile",
);
assert_eq!(
login_command_parts(&command).unwrap(),
["aws", "sso", "login", "--profile", "team profile"]
);
assert!(login_command_parts("").is_err());
assert!(login_command_parts("aws 'unterminated").is_err());
}
#[test]
fn local_credential_snapshots_never_override_configured_authentication() {
let now = SystemTime::now();
for auth in [
BedrockAuthMethod::Profile,
BedrockAuthMethod::Sso,
BedrockAuthMethod::StaticKeys,
] {
for expiry in [None, Some(now - Duration::from_secs(1)), Some(now + Duration::from_secs(60))] {
let configured = config(auth);
let loaded = AwsCredentialsState::Loaded {
credentials: AwsCredentials::new(
"snapshot-key".to_owned(),
"snapshot-secret".to_owned(),
Some("snapshot-token".to_owned()),
expiry,
),
loaded_at: now,
};
assert_eq!(
configured.clone().with_managed_credentials(
&AwsCredentialsRefreshStrategy::LocalChain,
&loaded,
),
configured
);
}
}
}
#[test]
fn managed_oidc_credentials_preserve_expiry_and_do_not_fall_back_to_local_keys() {
let now = SystemTime::now();
let strategy = AwsCredentialsRefreshStrategy::OidcManaged {
task_id: Some("task".to_owned()),
role_arn: "test-role".to_owned(),
region: "us-east-1".to_owned(),
};
let loaded = AwsCredentialsState::Loaded {
credentials: AwsCredentials::new(
"managed-key".to_owned(),
"managed-secret".to_owned(),
Some("managed-token".to_owned()),
Some(now + Duration::from_secs(60)),
),
loaded_at: now,
};
let resolved = config(BedrockAuthMethod::Profile).with_managed_credentials(&strategy, &loaded);
assert_eq!(resolved.auth_method, BedrockAuthMethod::StaticKeys);
assert_eq!(resolved.region, "us-east-1");
assert_eq!(resolved.access_key_id, "managed-key");
assert_eq!(resolved.credentials_expiry, Some(now + Duration::from_secs(60)));
for state in [
AwsCredentialsState::Missing,
AwsCredentialsState::Disabled,
AwsCredentialsState::Refreshing,
AwsCredentialsState::Failed { message: "failed".to_owned() },
] {
let resolved = config(BedrockAuthMethod::Profile).with_managed_credentials(&strategy, &state);
assert!(resolved.access_key_id.is_empty());
assert!(resolved.secret_access_key.is_empty());
}
}
#[test]
fn automatic_recovery_is_bounded_per_user_exchange_but_manual_retry_is_available() {
let mut attempts = RecoveryAttempts::default();
assert!(attempts.begin("conversation", "query-1", "connection", false));
assert!(!attempts.begin("conversation", "query-1", "connection", false));
assert!(attempts.begin("conversation", "query-1", "connection", true));
assert!(attempts.begin("conversation", "query-2", "connection", false));
assert!(attempts.begin("conversation", "query-2", "new-connection", false));
assert!(attempts.begin("another-conversation", "query-2", "connection", false));
}
#[test]
fn credential_expiry_is_checked_at_the_boundary() {
let now = SystemTime::now();
assert!(credentials_are_current(None, now));
assert!(credentials_are_current(Some(now + Duration::from_secs(1)), now));
assert!(!credentials_are_current(Some(now), now));
assert!(!credentials_are_current(Some(now - Duration::from_secs(1)), now));
}
fn test_credentials() -> AwsCredentials {
AwsCredentials::new("test-key".to_owned(), "test-secret".to_owned(), None, None)
}
#[tokio::test]
async fn external_refresh_recovers_without_running_login() {
let credentials = recover_credentials(
BedrockAuthMethod::Sso,
false,
|| futures::future::ready(Ok(test_credentials())).boxed(),
|| futures::future::ready(Err("Login must not run".to_owned())).boxed(),
).await.unwrap();
assert_eq!(credentials, test_credentials());
}
#[tokio::test]
async fn expired_session_runs_login_then_awaits_a_fresh_reload() {
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
let calls = Arc::new(AtomicUsize::new(0));
let load_calls = calls.clone();
let login_calls = calls.clone();
let result = recover_credentials(
BedrockAuthMethod::Sso,
false,
move || {
let index = load_calls.fetch_add(1, Ordering::SeqCst);
let result = match index {
0 => Err("expired".to_owned()),
2 => Ok(test_credentials()),
index => panic!("Unexpected reload order: {index}"),
};
futures::future::ready(result).boxed()
},
move || {
assert_eq!(login_calls.fetch_add(1, Ordering::SeqCst), 1);
futures::future::ready(Ok(())).boxed()
},
).await;
assert_eq!(result, Ok(test_credentials()));
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn manual_login_failure_does_not_reload_or_resume() {
let result = recover_credentials(
BedrockAuthMethod::Sso,
true,
|| panic!("Credentials must not reload after failed login"),
|| futures::future::ready(Err("login failed".to_owned())).boxed(),
).await;
assert_eq!(result, Err("login failed".to_owned()));
}
#[tokio::test]
async fn login_success_with_failed_reload_remains_an_error() {
let result = recover_credentials(
BedrockAuthMethod::Sso,
true,
|| futures::future::ready(Err("reload failed".to_owned())).boxed(),
|| futures::future::ready(Ok(())).boxed(),
).await;
assert_eq!(result, Err("reload failed".to_owned()));
}
#[tokio::test]
async fn shared_recovery_executes_login_once_for_overlapping_waiters() {
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
let calls = Arc::new(AtomicUsize::new(0));
let login_calls = calls.clone();
let recovery = recover_credentials(
BedrockAuthMethod::Sso,
true,
|| futures::future::ready(Ok(test_credentials())).boxed(),
move || {
login_calls.fetch_add(1, Ordering::SeqCst);
futures::future::ready(Ok(())).boxed()
},
).boxed().shared();
let (first, second) = futures::join!(recovery.clone(), recovery);
assert_eq!(first, Ok(test_credentials()));
assert_eq!(second, Ok(test_credentials()));
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn credential_preparation_failures_surface_authentication_recovery() {
use super::super::client::BedrockError;
for error in [
BedrockError::CredentialsNotConfigured,
BedrockError::CredentialsLoadFailed("SSO session invalid".to_owned()),
] {
let error = provider_startup_error(&anyhow::Error::new(error), "selected-model");
assert!(error.is_aws_bedrock_credentials_error());
}
for error in [
BedrockError::RegionNotConfigured,
BedrockError::ApiError("transport".to_owned()),
BedrockError::ModelNotFound("model".to_owned()),
BedrockError::AccessDenied("policy".to_owned()),
BedrockError::Throttling("busy".to_owned()),
BedrockError::ValidationError("request".to_owned()),
] {
let error = provider_startup_error(&anyhow::Error::new(error), "selected-model");
assert!(!error.is_aws_bedrock_credentials_error());
}
assert!(!provider_startup_error(&anyhow::anyhow!("unrelated"), "selected-model")
.is_aws_bedrock_credentials_error());
}
#[tokio::test]
async fn profile_authentication_never_starts_a_login_flow() {
for manual in [false, true] {
let result = recover_credentials(
BedrockAuthMethod::Profile,
manual,
|| futures::future::ready(Ok(test_credentials())).boxed(),
|| panic!("Profile authentication must not execute login commands"),
).await;
assert_eq!(result, Ok(test_credentials()));
let result = recover_credentials(
BedrockAuthMethod::Profile,
manual,
|| futures::future::ready(Err("profile needs fresh credentials".to_owned())).boxed(),
|| panic!("A failed profile reload must not execute login commands"),
).await;
assert_eq!(result, Err("profile needs fresh credentials".to_owned()));
}
}
+19 -29
View File
@@ -1,4 +1,5 @@
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use anyhow::Result; use anyhow::Result;
use aws_config::BehaviorVersion; use aws_config::BehaviorVersion;
@@ -37,40 +38,17 @@ pub struct BedrockClientConfig {
pub access_key_id: String, pub access_key_id: String,
pub secret_access_key: String, pub secret_access_key: String,
pub session_token: Option<String>, pub session_token: Option<String>,
pub credentials_expiry: Option<SystemTime>,
pub cross_region_inference: bool, pub cross_region_inference: bool,
} }
impl BedrockClientConfig {
/// Applies external config (from Claude Code / OpenCode) as fallback values
/// when Galaxy's own settings are at their defaults.
pub fn with_external_fallbacks(mut self) -> Self {
let external = ExternalBedrockConfig::load();
if external.is_empty() {
return self;
}
if self.profile == "default" {
if let Some(profile) = external.profile {
log::info!("[bedrock] Using profile from external config: {profile}");
self.profile = profile;
}
}
if self.region.is_empty() {
if let Some(region) = external.region {
log::info!("[bedrock] Using region from external config: {region}");
self.region = region;
}
}
self
}
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum BedrockError { pub enum BedrockError {
#[error("Bedrock credentials not configured")] #[error("Bedrock credentials not configured")]
CredentialsNotConfigured, CredentialsNotConfigured,
#[error("Failed to load AWS credentials: {0}")]
CredentialsLoadFailed(String),
#[error("Bedrock region not configured and could not be auto-detected")] #[error("Bedrock region not configured and could not be auto-detected")]
RegionNotConfigured, RegionNotConfigured,
#[error("Bedrock API error: {0}")] #[error("Bedrock API error: {0}")]
@@ -115,12 +93,20 @@ impl BedrockClient {
if config.access_key_id.is_empty() || config.secret_access_key.is_empty() { if config.access_key_id.is_empty() || config.secret_access_key.is_empty() {
return Err(BedrockError::CredentialsNotConfigured); return Err(BedrockError::CredentialsNotConfigured);
} }
if !super::authentication::credentials_are_current(
config.credentials_expiry,
SystemTime::now(),
) {
return Err(BedrockError::CredentialsLoadFailed(
"AWS credentials have expired".to_owned(),
));
}
let creds = aws_credential_types::Credentials::new( let creds = aws_credential_types::Credentials::new(
&config.access_key_id, &config.access_key_id,
&config.secret_access_key, &config.secret_access_key,
config.session_token, config.session_token,
None, config.credentials_expiry,
"warp-bedrock-static", "warp-bedrock-static",
); );
@@ -148,11 +134,15 @@ impl BedrockClient {
); );
} }
Err(e) => { Err(e) => {
log::warn!("[bedrock] Failed to resolve AWS credentials from provider: {e:?}"); let message = crate::ai::aws_credentials::user_facing_aws_credentials_error_message(
&e,
&config.profile,
);
return Err(BedrockError::CredentialsLoadFailed(message));
} }
} }
} else { } else {
log::warn!("[bedrock] No credentials provider found in resolved AWS config"); return Err(BedrockError::CredentialsNotConfigured);
} }
let region = aws_config let region = aws_config
+1 -1
View File
@@ -161,7 +161,7 @@ async fn load_aws_config(
&config.access_key_id, &config.access_key_id,
&config.secret_access_key, &config.secret_access_key,
config.session_token.clone(), config.session_token.clone(),
None, config.credentials_expiry,
"galaxy-bedrock-discovery", "galaxy-bedrock-discovery",
); );
let mut loader = let mut loader =
+1
View File
@@ -268,6 +268,7 @@ fn get_test_config() -> Option<BedrockClientConfig> {
access_key_id: String::new(), access_key_id: String::new(),
secret_access_key: String::new(), secret_access_key: String::new(),
session_token: None, session_token: None,
credentials_expiry: None,
cross_region_inference: false, cross_region_inference: false,
}) })
} }
+1
View File
@@ -23,6 +23,7 @@ fn get_test_config() -> Option<BedrockClientConfig> {
access_key_id: String::new(), access_key_id: String::new(),
secret_access_key: String::new(), secret_access_key: String::new(),
session_token: None, session_token: None,
credentials_expiry: None,
cross_region_inference: false, cross_region_inference: false,
}) })
} }
+2
View File
@@ -16,6 +16,8 @@ pub enum ProviderConfig {
// Provider-neutral module boundary; Bedrock-specific transport helpers live here without a // Provider-neutral module boundary; Bedrock-specific transport helpers live here without a
// provider-named directory so the provider layer can be reorganized independently of callers. // provider-named directory so the provider layer can be reorganized independently of callers.
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub(crate) mod authentication;
#[cfg(not(target_family = "wasm"))]
pub mod client; pub mod client;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub mod convert; pub mod convert;
@@ -1152,6 +1152,7 @@ impl ProviderSetupView {
access_key_id: self.draft_bedrock.access_key_id.clone(), access_key_id: self.draft_bedrock.access_key_id.clone(),
secret_access_key: self.draft_bedrock.secret_access_key.clone(), secret_access_key: self.draft_bedrock.secret_access_key.clone(),
session_token: None, session_token: None,
credentials_expiry: None,
cross_region_inference: self.draft_bedrock.cross_region_inference, cross_region_inference: self.draft_bedrock.cross_region_inference,
}; };
let requested_config = config.clone(); let requested_config = config.clone();
+45 -101
View File
@@ -29,6 +29,8 @@ use warp_util::remote_path::RemotePath;
use warp_util::standardized_path::StandardizedPath; use warp_util::standardized_path::StandardizedPath;
use crate::ai::block_context::BlockContext; use crate::ai::block_context::BlockContext;
#[cfg(not(target_family = "wasm"))]
use crate::ai::provider::authentication::{BedrockAuthConnection, BedrockAuthentication};
use crate::global_resource_handles::GlobalResourceHandlesProvider; use crate::global_resource_handles::GlobalResourceHandlesProvider;
pub(crate) mod docker_sandbox; pub(crate) mod docker_sandbox;
mod link_detection; mod link_detection;
@@ -10910,89 +10912,55 @@ impl TerminalView {
self.remove_aws_bedrock_login_banner(ctx); self.remove_aws_bedrock_login_banner(ctx);
} }
/// Runs the AWS login command configured in settings to refresh Bedrock credentials. /// Refreshes the configured AWS connection and resumes only its originating conversation.
/// Doing this in PTY vs just a subprocess allows the user to see any output/errors
/// from the command directly in the terminal. Also, `aws login` commands may require
/// user interaction (e.g. "do you want to override X profile? y/n" is common)
fn run_aws_login_command(&mut self, ctx: &mut ViewContext<Self>) { fn run_aws_login_command(&mut self, ctx: &mut ViewContext<Self>) {
let settings_command = AISettings::as_ref(ctx)
.bedrock_auth_refresh_command
.value()
.clone();
// Use the configured command, but if it's the bare default ("aws sso login")
// prefer the external config's auth_refresh_command which includes --profile.
let login_command = if settings_command == "aws sso login" {
use crate::ai::provider::external_config::ExternalBedrockConfig;
let external = ExternalBedrockConfig::load();
external.auth_refresh_command.unwrap_or(settings_command)
} else {
settings_command
};
if login_command.is_empty() {
log::warn!("AWS login command is not configured");
return;
}
log::info!("[bedrock] Running AWS login command: {login_command}");
// Spawn the login command as an async subprocess so it can open the browser
// for SSO authentication. Once it completes, refresh credentials and resume
// the conversation.
let _ = ctx.spawn(
async move {
let parts: Vec<&str> = login_command.split_whitespace().collect();
let Some((cmd, args)) = parts.split_first() else {
return Err("Empty login command".to_string());
};
let result = tokio::process::Command::new(cmd)
.args(args)
.status()
.await;
match result {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(format!("AWS login command exited with status: {status}")),
Err(e) => Err(format!("Failed to spawn AWS login command: {e}")),
}
},
|_me, result, ctx| {
match result {
Ok(()) => {
log::info!("[bedrock] AWS login completed successfully; reloading credentials before resuming");
// Refresh through ApiKeyManager and wait for its completion. A fixed
// delay can resume the request while the SDK still has stale SSO data.
let refresh = ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
crate::ai::aws_credentials::refresh_aws_credentials(manager, ctx)
});
let _ = ctx.spawn(refresh, |me, result, ctx| {
match result {
Ok(()) => {
log::info!("[bedrock] AWS credentials refreshed; resuming conversation");
let conversation_id = if FeatureFlag::AgentView.is_enabled() { let conversation_id = if FeatureFlag::AgentView.is_enabled() {
me.agent_view_controller self.agent_view_controller
.as_ref(ctx) .as_ref(ctx)
.agent_view_state() .agent_view_state()
.active_conversation_id() .active_conversation_id()
} else { } else {
BlocklistAIHistoryModel::as_ref(ctx) BlocklistAIHistoryModel::as_ref(ctx).last_conversation_id(self.id())
.last_conversation_id(me.id())
}; };
if let Some(conversation_id) = conversation_id { self.recover_aws_authentication(conversation_id, true, ctx);
}
fn recover_aws_authentication(
&mut self,
conversation_id: Option<AIConversationId>,
manual: bool,
ctx: &mut ViewContext<Self>,
) {
let exchange_id = conversation_id
.and_then(|id| Self::thread_start_exchange_id(&id, ctx));
let connection = BedrockAuthConnection::current(ctx);
let conversation_key = conversation_id
.map(|id| id.to_string())
.unwrap_or_else(|| format!("login-only-{}", self.view_id));
let exchange_key = exchange_id.map(|id| id.to_string()).unwrap_or_default();
let recovery = BedrockAuthentication::handle(ctx).update(ctx, |authentication, ctx| {
authentication.recover(&conversation_key, &exchange_key, manual, ctx)
});
let _ = ctx.spawn(
recovery,
move |me, result, ctx| {
match result {
Ok(()) => {
let Some(conversation_id) = conversation_id else {
return;
};
if BedrockAuthConnection::current(ctx) != connection
|| Self::thread_start_exchange_id(&conversation_id, ctx) != exchange_id
|| BlocklistAIHistoryModel::as_ref(ctx)
.conversation_status(&conversation_id) != Some(&ConversationStatus::Error)
{
return;
}
log::info!("[bedrock] AWS credentials refreshed; resuming originating conversation");
me.handle_resume_conversation(&conversation_id, ctx); me.handle_resume_conversation(&conversation_id, ctx);
} }
}
Err(error) => { Err(error) => {
log::error!( log::warn!("[bedrock] AWS authentication recovery stopped: {error}");
"[bedrock] AWS login succeeded but credential reload failed; not resuming: {error}"
);
}
}
});
}
Err(e) => {
log::error!("[bedrock] AWS login failed: {e}");
} }
} }
}, },
@@ -20741,36 +20709,12 @@ impl TerminalView {
ctx.emit(Event::OpenThemeChooser); ctx.emit(Event::OpenThemeChooser);
} }
AIBlockEvent::RunAwsLoginCommand => { AIBlockEvent::RunAwsLoginCommand => {
self.run_aws_login_command(ctx); let conversation_id = block.as_ref(ctx).conversation_id();
self.recover_aws_authentication(Some(conversation_id), true, ctx);
} }
AIBlockEvent::RefreshAwsCredentials => { AIBlockEvent::RefreshAwsCredentials => {
let refresh = ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| { let conversation_id = block.as_ref(ctx).conversation_id();
crate::ai::aws_credentials::refresh_aws_credentials(manager, ctx) self.recover_aws_authentication(Some(conversation_id), false, ctx);
});
let _ = ctx.spawn(refresh, |me, result, ctx| {
match result {
Ok(()) => {
log::info!("[bedrock] AWS credentials reloaded; resuming conversation");
let conversation_id = if FeatureFlag::AgentView.is_enabled() {
me.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
} else {
BlocklistAIHistoryModel::as_ref(ctx).last_conversation_id(me.id())
};
if let Some(conversation_id) = conversation_id {
me.handle_resume_conversation(&conversation_id, ctx);
}
}
Err(error) => {
log::warn!(
"[bedrock] AWS credential reload failed; starting configured login command: {error}"
);
me.run_aws_login_command(ctx);
}
}
});
} }
} }
ctx.notify(); ctx.notify();
+5
View File
@@ -512,6 +512,11 @@ fn text_indicates_authentication_failure(text: &str) -> bool {
"unrecognized client", "unrecognized client",
"security token included in the request is invalid", "security token included in the request is invalid",
"unable to locate credentials", "unable to locate credentials",
"unauthorizedexception",
"session token not found or invalid",
"sso session has expired",
"failed to load aws credentials",
"bedrock credentials not configured",
"credential should be scoped to a valid region", "credential should be scoped to a valid region",
] ]
.iter() .iter()
@@ -105,6 +105,10 @@ fn flattened_bedrock_credential_errors_are_authentication_failures() {
r#"{"__type":"ExpiredTokenException","message":"The security token included in the request is expired"}"#, r#"{"__type":"ExpiredTokenException","message":"The security token included in the request is expired"}"#,
"UnrecognizedClientException: The security token included in the request is invalid", "UnrecognizedClientException: The security token included in the request is invalid",
"AccessDeniedException: not authorized to perform bedrock:ConverseStream", "AccessDeniedException: not authorized to perform bedrock:ConverseStream",
"UnauthorizedException: Session token not found or invalid",
"SSO session has expired or is otherwise invalid",
"Failed to load AWS credentials: credentials were not loaded",
"Bedrock credentials not configured",
] { ] {
let mapped = map_completion_error(CompletionError::ProviderError(message.to_string())); let mapped = map_completion_error(CompletionError::ProviderError(message.to_string()));
assert_eq!(mapped.kind, AgentErrorKind::Authentication); assert_eq!(mapped.kind, AgentErrorKind::Authentication);
@@ -112,3 +116,15 @@ fn flattened_bedrock_credential_errors_are_authentication_failures() {
assert!(mapped.user_message.is_some()); assert!(mapped.user_message.is_some());
} }
} }
#[test]
fn unrelated_provider_failures_do_not_become_authentication_failures() {
for message in [
"HTTP client error: connection reset",
"ProviderError: temporarily unavailable",
"ValidationException: invalid model",
] {
let mapped = map_completion_error(CompletionError::ProviderError(message.to_owned()));
assert_ne!(mapped.kind, AgentErrorKind::Authentication);
}
}