diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index 8020246..85a5ae5 100644 --- a/app/src-tauri/src/commands/file_commands.rs +++ b/app/src-tauri/src/commands/file_commands.rs @@ -254,10 +254,11 @@ rm -rf "$STAGE""#; StartExecResults::Detached => return Err("Backup exec started detached".to_string()), }; - use std::io::Write; - let file = - std::fs::File::create(&host_path).map_err(|e| format!("Failed to create backup file: {}", e))?; - let mut writer = std::io::BufWriter::new(file); + use tokio::io::AsyncWriteExt; + let file = tokio::fs::File::create(&host_path) + .await + .map_err(|e| format!("Failed to create backup file: {}", e))?; + let mut writer = tokio::io::BufWriter::new(file); let mut total: u64 = 0; let mut stderr_text = String::new(); let mut stream_err: Option = None; @@ -265,7 +266,7 @@ rm -rf "$STAGE""#; while let Some(msg) = output.next().await { match msg { Ok(LogOutput::StdOut { message }) => { - if let Err(e) = writer.write_all(&message) { + if let Err(e) = writer.write_all(&message).await { stream_err = Some(format!("Failed to write backup file: {}", e)); break; } @@ -282,7 +283,7 @@ rm -rf "$STAGE""#; } } if stream_err.is_none() { - if let Err(e) = writer.flush() { + if let Err(e) = writer.flush().await { stream_err = Some(format!("Failed to finalize backup file: {}", e)); } } @@ -321,7 +322,7 @@ rm -rf "$STAGE""#; if let Some(err) = stream_err { // Don't leave a partial/corrupt archive behind. - let _ = std::fs::remove_file(&host_path); + let _ = tokio::fs::remove_file(&host_path).await; return Err(err); } diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 5d8cacf..c947a64 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -432,11 +432,11 @@ pub async fn start_project_container( new_id }; - // Refresh Bedrock static/session credentials on every start so rotated - // keys are picked up without a full container recreation. No-op for - // other backends / auth methods. - if let Err(e) = docker::write_bedrock_static_credentials(&container_id, &project).await { - log::warn!("Failed to refresh AWS credentials for project {}: {}", project.id, e); + // Sync Bedrock credentials on every start: refresh static/session creds + // so rotated keys are picked up without a full container recreation, and + // clear stale creds when the project no longer uses static-cred Bedrock. + if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await { + log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e); } Ok(container_id) diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index c6004b1..82dc7fd 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -196,7 +196,8 @@ pub async fn upload_host_file_to_terminal( ) -> Result { let container_id = state.exec_manager.get_container_id(&session_id).await?; - let meta = std::fs::metadata(&host_path) + let meta = tokio::fs::metadata(&host_path) + .await .map_err(|e| format!("Cannot access {}: {}", host_path, e))?; if meta.is_dir() { return Err(format!("{} is a directory — drop individual files", host_path)); @@ -213,8 +214,9 @@ pub async fn upload_host_file_to_terminal( )); } - let data = - std::fs::read(&host_path).map_err(|e| format!("Failed to read {}: {}", host_path, e))?; + let data = tokio::fs::read(&host_path) + .await + .map_err(|e| format!("Failed to read {}: {}", host_path, e))?; let base = std::path::Path::new(&host_path) .file_name() @@ -222,6 +224,14 @@ pub async fn upload_host_file_to_terminal( .filter(|s| !s.is_empty()) .unwrap_or_else(|| "dropped-file".to_string()); + // Ensure the destination directory exists rather than relying on Docker's + // archive extractor to create the parent for the uploaded tar entry. + crate::docker::exec::exec_oneshot( + &container_id, + vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()], + ) + .await?; + let file_name = format!("triple-c-drops/{}", base); state .exec_manager diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index b44661f..19a840a 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -1097,36 +1097,61 @@ pub fn get_snapshot_image_name(project: &Project) -> String { format!("triple-c-snapshot-{}:latest", project.id) } -/// Write Bedrock static/session credentials into the running container's -/// ~/.aws/credentials file. Called on every container start (not just creation) -/// so rotated keys or refreshed session tokens are picked up without recreating -/// the container. Credentials are passed via the exec environment (not argv) and -/// the file is written with 0600 permissions. No-op unless the project uses -/// Bedrock with static-credential auth. -pub async fn write_bedrock_static_credentials( +/// Keep the container's `~/.aws/credentials` in sync with the project's Bedrock +/// auth on every container start: +/// - **Bedrock + static credentials**: (re)write `~/.aws/credentials` from the +/// latest keychain values and drop a stale `~/.aws/config` left by a prior +/// profile/SSO session, so rotated keys are picked up without recreating the +/// container. +/// - **Any other backend / auth method**: remove a stale `~/.aws/credentials` +/// written by a previous static-credential session, so the secrets don't +/// linger unused in the persistent home volume after switching away. +/// +/// Both cleanups are skipped when `/tmp/.host-aws` is mounted (a global +/// `aws_config_path` is configured), since the entrypoint already refreshes +/// `~/.aws` from the host on every start in that case. +pub async fn sync_bedrock_credentials( container_id: &str, project: &Project, ) -> Result<(), String> { - if project.backend != Backend::Bedrock { - return Ok(()); - } - let bedrock = match project.bedrock_config { - Some(ref b) if b.auth_method == BedrockAuthMethod::StaticCredentials => b, - _ => return Ok(()), + let static_bedrock = if project.backend == Backend::Bedrock { + project + .bedrock_config + .as_ref() + .filter(|b| b.auth_method == BedrockAuthMethod::StaticCredentials) + } else { + None }; - let key_id = match bedrock.aws_access_key_id.as_deref() { - Some(k) if !k.is_empty() => k, + let bedrock = match static_bedrock { + Some(b) if b.aws_access_key_id.as_deref().is_some_and(|k| !k.is_empty()) => b, _ => { - log::warn!("Bedrock static auth selected but no AWS access key id is set"); + // Not static-credential Bedrock (or static selected but no key set): + // remove a stale credentials file from a previous static session. + if matches!(static_bedrock, Some(_)) { + log::warn!("Bedrock static auth selected but no AWS access key id is set"); + } + let script = r#"if [ ! -d /tmp/.host-aws ]; then rm -f "$HOME/.aws/credentials"; fi"#; + let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()]; + let env = vec!["HOME=/home/claude".to_string()]; + if let Err(e) = crate::docker::exec::exec_oneshot_env(container_id, cmd, env).await { + log::warn!( + "Failed to clear stale AWS credentials in container {}: {}", + container_id, + e + ); + } return Ok(()); } }; + + let key_id = bedrock.aws_access_key_id.as_deref().unwrap_or(""); let secret = bedrock.aws_secret_access_key.as_deref().unwrap_or(""); // Pass secrets via the exec environment, then have the shell write them to // the file. This keeps them out of the process argv (visible via `ps`). let mut env = vec![ + "HOME=/home/claude".to_string(), format!("TC_AWS_KEY_ID={}", key_id), format!("TC_AWS_SECRET={}", secret), ]; diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index efb28fe..4013a53 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -196,7 +196,7 @@ export default function ProjectCard({ project }: Props) { setError(null); const bytes = await commands.downloadContainerBackup(project.id, hostPath); const mb = (bytes / (1024 * 1024)).toFixed(1); - setProgressMsg(`Backup saved (${mb} MB)`); + setProgressMsg(`Backup saved (${mb} MB). Note: includes MCP/config — may contain MCP API keys. Keep it private.`); } catch (e) { setError(String(e)); } finally { @@ -531,7 +531,12 @@ export default function ProjectCard({ project }: Props) { setShowFileManager(true)} disabled={loading} label="Files" /> - + ) : ( <> @@ -1221,12 +1226,14 @@ function ActionButton({ label, accent, danger, + title, }: { onClick: (e?: React.MouseEvent) => void; disabled: boolean; label: string; accent?: boolean; danger?: boolean; + title?: string; }) { let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"; if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]"; @@ -1236,6 +1243,7 @@ function ActionButton({