From ef67b447b3106931bfd0994229a459fa9100024e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 15 Apr 2026 06:54:59 -0700 Subject: [PATCH] Pre-validate AWS SSO session on host during container startup For Bedrock Profile projects, SSO credentials are now checked and refreshed on the host before the container starts, so the entrypoint copies already-valid tokens. This eliminates the delay where users had to wait for the terminal to open before being prompted to login. The terminal-time fallback remains for mid-session credential expiry. Also consolidates duplicated profile resolution logic into a shared helper in aws_commands. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src-tauri/src/commands/aws_commands.rs | 75 ++++++++++++++++--- .../src/commands/project_commands.rs | 73 +++++++++++++++++- .../src/commands/terminal_commands.rs | 12 ++- app/src-tauri/src/web_terminal/ws_handler.rs | 11 ++- 4 files changed, 145 insertions(+), 26 deletions(-) diff --git a/app/src-tauri/src/commands/aws_commands.rs b/app/src-tauri/src/commands/aws_commands.rs index c1b8db7..73ba069 100644 --- a/app/src-tauri/src/commands/aws_commands.rs +++ b/app/src-tauri/src/commands/aws_commands.rs @@ -1,23 +1,58 @@ use tauri::State; + +use crate::models::Project; use crate::AppState; -#[tauri::command] -pub async fn aws_sso_refresh( - project_id: String, - state: State<'_, AppState>, -) -> Result<(), String> { - let project = state.projects_store.get(&project_id) - .ok_or_else(|| format!("Project {} not found", project_id))?; - - let profile = project.bedrock_config.as_ref() +/// Resolve AWS profile: project-level → global settings → "default". +pub fn resolve_profile_for_project(project: &Project, global_profile: Option<&str>) -> String { + project + .bedrock_config + .as_ref() .and_then(|b| b.aws_profile.clone()) - .or_else(|| state.settings_store.get().global_aws.aws_profile.clone()) - .unwrap_or_else(|| "default".to_string()); + .or_else(|| global_profile.map(|s| s.to_string())) + .unwrap_or_else(|| "default".to_string()) +} +/// Check if the AWS session is valid for the given profile on the host. +/// Returns `Ok(true)` if valid, `Ok(false)` if expired/invalid. +pub async fn check_sso_session(profile: &str) -> Result { + let output = tokio::process::Command::new("aws") + .args(["sts", "get-caller-identity", "--profile", profile]) + .output() + .await + .map_err(|e| format!("Failed to run aws sts get-caller-identity: {}", e))?; + Ok(output.status.success()) +} + +/// Check if the given AWS profile uses SSO (has sso_start_url or sso_session configured). +pub async fn is_sso_profile(profile: &str) -> Result { + let check_start_url = tokio::process::Command::new("aws") + .args(["configure", "get", "sso_start_url", "--profile", profile]) + .output() + .await; + if let Ok(out) = check_start_url { + if out.status.success() { + return Ok(true); + } + } + let check_session = tokio::process::Command::new("aws") + .args(["configure", "get", "sso_session", "--profile", profile]) + .output() + .await; + if let Ok(out) = check_session { + if out.status.success() { + return Ok(true); + } + } + Ok(false) +} + +/// Run `aws sso login --profile X` on the host. This is interactive (opens a browser). +pub async fn run_sso_login(profile: &str) -> Result<(), String> { log::info!("Running host-side AWS SSO login for profile '{}'", profile); let status = tokio::process::Command::new("aws") - .args(["sso", "login", "--profile", &profile]) + .args(["sso", "login", "--profile", profile]) .status() .await .map_err(|e| format!("Failed to run aws sso login: {}", e))?; @@ -28,3 +63,19 @@ pub async fn aws_sso_refresh( Ok(()) } + +#[tauri::command] +pub async fn aws_sso_refresh( + project_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let project = state.projects_store.get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + let profile = resolve_profile_for_project( + &project, + state.settings_store.get().global_aws.aws_profile.as_deref(), + ); + + run_sso_login(&profile).await +} diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 211a396..b3a3972 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1,7 +1,8 @@ use tauri::{Emitter, State}; +use crate::commands::aws_commands; use crate::docker; -use crate::models::{container_config, Backend, McpServer, Project, ProjectPath, ProjectStatus}; +use crate::models::{container_config, Backend, BedrockAuthMethod, McpServer, Project, ProjectPath, ProjectStatus}; use crate::storage::secure; use crate::AppState; @@ -208,6 +209,76 @@ pub async fn start_project_container( // Update status to starting state.projects_store.update_status(&project_id, ProjectStatus::Starting)?; + // Pre-validate AWS SSO session on the host for Bedrock Profile projects. + // If the session is expired, trigger `aws sso login` before starting the container + // so the entrypoint copies already-fresh credentials from the host mount. + if project.backend == Backend::Bedrock { + if let Some(ref bedrock) = project.bedrock_config { + if bedrock.auth_method == BedrockAuthMethod::Profile { + let profile = aws_commands::resolve_profile_for_project( + &project, + settings.global_aws.aws_profile.as_deref(), + ); + + emit_progress(&app_handle, &project_id, "Validating AWS session..."); + + let session_valid = tokio::time::timeout( + std::time::Duration::from_secs(10), + aws_commands::check_sso_session(&profile), + ) + .await; + + match session_valid { + Ok(Ok(true)) => { + emit_progress(&app_handle, &project_id, "AWS session valid."); + } + Ok(Ok(false)) => { + // Session expired — check if this is an SSO profile + if aws_commands::is_sso_profile(&profile).await.unwrap_or(false) { + emit_progress( + &app_handle, + &project_id, + "AWS session expired. Starting SSO login (check your browser)...", + ); + match aws_commands::run_sso_login(&profile).await { + Ok(()) => { + emit_progress( + &app_handle, + &project_id, + "SSO login successful.", + ); + } + Err(e) => { + log::warn!( + "SSO login failed for profile '{}': {} — continuing anyway", + profile, + e + ); + emit_progress( + &app_handle, + &project_id, + "SSO login failed or cancelled. Continuing...", + ); + } + } + } else { + log::warn!( + "AWS session invalid for profile '{}' (not SSO). Continuing...", + profile + ); + } + } + Ok(Err(e)) => { + log::warn!("Failed to check AWS session: {} — continuing anyway", e); + } + Err(_) => { + log::warn!("AWS session check timed out — continuing anyway"); + } + } + } + } + } + // Wrap container operations so that any failure resets status to Stopped. let result: Result = async { // Ensure image exists diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index eced0e0..00faab4 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -1,5 +1,6 @@ use tauri::{AppHandle, Emitter, State}; +use crate::commands::aws_commands; use crate::models::{Backend, BedrockAuthMethod, Project}; use crate::AppState; @@ -24,13 +25,10 @@ fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec { return cmd; } - // Resolve AWS profile: project-level → global settings → "default" - let profile = project - .bedrock_config - .as_ref() - .and_then(|b| b.aws_profile.clone()) - .or_else(|| state.settings_store.get().global_aws.aws_profile.clone()) - .unwrap_or_else(|| "default".to_string()); + let profile = aws_commands::resolve_profile_for_project( + project, + state.settings_store.get().global_aws.aws_profile.as_deref(), + ); // Build a bash wrapper that validates credentials, re-auths if needed, // then exec's into claude. diff --git a/app/src-tauri/src/web_terminal/ws_handler.rs b/app/src-tauri/src/web_terminal/ws_handler.rs index 6619e80..3a49b18 100644 --- a/app/src-tauri/src/web_terminal/ws_handler.rs +++ b/app/src-tauri/src/web_terminal/ws_handler.rs @@ -7,6 +7,7 @@ use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; +use crate::commands::aws_commands; use crate::models::{Backend, BedrockAuthMethod, Project, ProjectStatus}; use super::server::WebTerminalState; @@ -212,12 +213,10 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin return cmd; } - let profile = project - .bedrock_config - .as_ref() - .and_then(|b| b.aws_profile.clone()) - .or_else(|| settings_store.get().global_aws.aws_profile.clone()) - .unwrap_or_else(|| "default".to_string()); + let profile = aws_commands::resolve_profile_for_project( + project, + settings_store.get().global_aws.aws_profile.as_deref(), + ); let claude_cmd = if project.full_permissions { "exec claude --dangerously-skip-permissions"