Address remaining review items: L4/M1 + L2/L5 cleanup
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 57s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 2m54s
Build App / build-linux (pull_request) Successful in 5m44s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped

- L4: sync_bedrock_credentials (renamed from write_bedrock_static_
  credentials) now also clears a stale ~/.aws/credentials when the
  project no longer uses static-credential Bedrock, so static keys don't
  linger unused in the persistent home volume after switching backends.
  Skipped when /tmp/.host-aws is mounted (host-managed ~/.aws). HOME is
  also set explicitly on the exec env for robustness.
- M1: the Backup button now has a tooltip and the success toast notes
  that the archive includes MCP/config which may contain MCP-embedded
  API keys (OAuth tokens are excluded) — keep it private.
- L2: backup now uses async file IO (tokio::fs::File + AsyncWriteExt,
  tokio::fs::remove_file) instead of blocking std::fs between awaits;
  dropped-file reads use tokio::fs::metadata/read.
- L5: upload_host_file_to_terminal explicitly `mkdir -p`s
  /tmp/triple-c-drops instead of relying on Docker's tar extractor to
  create the parent dir.

Verified L4 cleanup guard, L5 mkdir, async IO, and exit-code paths
against real containers. cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 14:37:33 -07:00
parent edf0698774
commit d65872dc94
5 changed files with 77 additions and 33 deletions
+8 -7
View File
@@ -254,10 +254,11 @@ rm -rf "$STAGE""#;
StartExecResults::Detached => return Err("Backup exec started detached".to_string()), StartExecResults::Detached => return Err("Backup exec started detached".to_string()),
}; };
use std::io::Write; use tokio::io::AsyncWriteExt;
let file = let file = tokio::fs::File::create(&host_path)
std::fs::File::create(&host_path).map_err(|e| format!("Failed to create backup file: {}", e))?; .await
let mut writer = std::io::BufWriter::new(file); .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 total: u64 = 0;
let mut stderr_text = String::new(); let mut stderr_text = String::new();
let mut stream_err: Option<String> = None; let mut stream_err: Option<String> = None;
@@ -265,7 +266,7 @@ rm -rf "$STAGE""#;
while let Some(msg) = output.next().await { while let Some(msg) = output.next().await {
match msg { match msg {
Ok(LogOutput::StdOut { message }) => { 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)); stream_err = Some(format!("Failed to write backup file: {}", e));
break; break;
} }
@@ -282,7 +283,7 @@ rm -rf "$STAGE""#;
} }
} }
if stream_err.is_none() { 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)); stream_err = Some(format!("Failed to finalize backup file: {}", e));
} }
} }
@@ -321,7 +322,7 @@ rm -rf "$STAGE""#;
if let Some(err) = stream_err { if let Some(err) = stream_err {
// Don't leave a partial/corrupt archive behind. // 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); return Err(err);
} }
@@ -432,11 +432,11 @@ pub async fn start_project_container(
new_id new_id
}; };
// Refresh Bedrock static/session credentials on every start so rotated // Sync Bedrock credentials on every start: refresh static/session creds
// keys are picked up without a full container recreation. No-op for // so rotated keys are picked up without a full container recreation, and
// other backends / auth methods. // clear stale creds when the project no longer uses static-cred Bedrock.
if let Err(e) = docker::write_bedrock_static_credentials(&container_id, &project).await { if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await {
log::warn!("Failed to refresh AWS credentials for project {}: {}", project.id, e); log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
} }
Ok(container_id) Ok(container_id)
@@ -196,7 +196,8 @@ pub async fn upload_host_file_to_terminal(
) -> Result<String, String> { ) -> Result<String, String> {
let container_id = state.exec_manager.get_container_id(&session_id).await?; 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))?; .map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
if meta.is_dir() { if meta.is_dir() {
return Err(format!("{} is a directory — drop individual files", host_path)); 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 = let data = tokio::fs::read(&host_path)
std::fs::read(&host_path).map_err(|e| format!("Failed to read {}: {}", host_path, e))?; .await
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
let base = std::path::Path::new(&host_path) let base = std::path::Path::new(&host_path)
.file_name() .file_name()
@@ -222,6 +224,14 @@ pub async fn upload_host_file_to_terminal(
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.unwrap_or_else(|| "dropped-file".to_string()); .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); let file_name = format!("triple-c-drops/{}", base);
state state
.exec_manager .exec_manager
+41 -16
View File
@@ -1097,36 +1097,61 @@ pub fn get_snapshot_image_name(project: &Project) -> String {
format!("triple-c-snapshot-{}:latest", project.id) format!("triple-c-snapshot-{}:latest", project.id)
} }
/// Write Bedrock static/session credentials into the running container's /// Keep the container's `~/.aws/credentials` in sync with the project's Bedrock
/// ~/.aws/credentials file. Called on every container start (not just creation) /// auth on every container start:
/// so rotated keys or refreshed session tokens are picked up without recreating /// - **Bedrock + static credentials**: (re)write `~/.aws/credentials` from the
/// the container. Credentials are passed via the exec environment (not argv) and /// latest keychain values and drop a stale `~/.aws/config` left by a prior
/// the file is written with 0600 permissions. No-op unless the project uses /// profile/SSO session, so rotated keys are picked up without recreating the
/// Bedrock with static-credential auth. /// container.
pub async fn write_bedrock_static_credentials( /// - **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, container_id: &str,
project: &Project, project: &Project,
) -> Result<(), String> { ) -> Result<(), String> {
if project.backend != Backend::Bedrock { let static_bedrock = if project.backend == Backend::Bedrock {
return Ok(()); project
} .bedrock_config
let bedrock = match project.bedrock_config { .as_ref()
Some(ref b) if b.auth_method == BedrockAuthMethod::StaticCredentials => b, .filter(|b| b.auth_method == BedrockAuthMethod::StaticCredentials)
_ => return Ok(()), } else {
None
}; };
let key_id = match bedrock.aws_access_key_id.as_deref() { let bedrock = match static_bedrock {
Some(k) if !k.is_empty() => k, 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(()); 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(""); let secret = bedrock.aws_secret_access_key.as_deref().unwrap_or("");
// Pass secrets via the exec environment, then have the shell write them to // 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`). // the file. This keeps them out of the process argv (visible via `ps`).
let mut env = vec![ let mut env = vec![
"HOME=/home/claude".to_string(),
format!("TC_AWS_KEY_ID={}", key_id), format!("TC_AWS_KEY_ID={}", key_id),
format!("TC_AWS_SECRET={}", secret), format!("TC_AWS_SECRET={}", secret),
]; ];
+10 -2
View File
@@ -196,7 +196,7 @@ export default function ProjectCard({ project }: Props) {
setError(null); setError(null);
const bytes = await commands.downloadContainerBackup(project.id, hostPath); const bytes = await commands.downloadContainerBackup(project.id, hostPath);
const mb = (bytes / (1024 * 1024)).toFixed(1); 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) { } catch (e) {
setError(String(e)); setError(String(e));
} finally { } finally {
@@ -531,7 +531,12 @@ export default function ProjectCard({ project }: Props) {
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent /> <ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" /> <ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" /> <ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
<ActionButton onClick={handleBackup} disabled={loading || backingUp} label={backingUp ? "Backing up…" : "Backup"} /> <ActionButton
onClick={handleBackup}
disabled={loading || backingUp}
label={backingUp ? "Backing up…" : "Backup"}
title="Downloads /workspace plus a sanitized home config (MCP servers, settings, skills). OAuth tokens are excluded, but MCP server configs may embed their own API keys/tokens — keep the archive private."
/>
</> </>
) : ( ) : (
<> <>
@@ -1221,12 +1226,14 @@ function ActionButton({
label, label,
accent, accent,
danger, danger,
title,
}: { }: {
onClick: (e?: React.MouseEvent) => void; onClick: (e?: React.MouseEvent) => void;
disabled: boolean; disabled: boolean;
label: string; label: string;
accent?: boolean; accent?: boolean;
danger?: boolean; danger?: boolean;
title?: string;
}) { }) {
let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"; let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]";
if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]"; if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]";
@@ -1236,6 +1243,7 @@ function ActionButton({
<button <button
onClick={(e) => { e.stopPropagation(); onClick(e); }} onClick={(e) => { e.stopPropagation(); onClick(e); }}
disabled={disabled} disabled={disabled}
title={title}
className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`} className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`}
> >
{label} {label}