From 424ab04ca8fc407cf741b7442007960ce370f5e2 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 30 Jun 2026 13:34:43 -0700 Subject: [PATCH 1/9] Fix stale AWS/Bedrock auth carrying over on backend switch When a project switched backends (e.g. Bedrock -> Anthropic), the recreated container kept authenticating against Bedrock, and SSO kept firing after switching away. Three root causes, all fixed: 1. Recreation builds the new container from a `docker commit` snapshot. commit always bakes the previous container's full ENV into the image (an empty commit Config does NOT strip it, and the commit API cannot remove env). So CLAUDE_CODE_USE_BEDROCK=1 / AWS_* survived into the new container. Fix: create_container now explicitly clears every managed auth key the active backend does not set (MANAGED_AUTH_KEYS), so create-time env overrides the stale baked-in values. 2. awsAuthRefresh was written into ~/.claude.json (persisted home volume) and never removed, so Claude Code kept invoking triple-c-sso-refresh after switching to a non-SSO backend. Fix: entrypoint now deletes awsAuthRefresh when AWS_SSO_AUTH_REFRESH_CMD is unset, idempotent both ways. 3. Static/session AWS creds were baked into Config.Env at create time, so a stop/start kept stale creds and rotated keys never refreshed without a full recreation. Fix: static creds are no longer injected as env vars; write_bedrock_static_credentials() writes ~/.aws/credentials (0600, secrets via exec env not argv) on every start, and removes a stale ~/.aws/config left from a prior profile/SSO session. Static creds also dropped from the bedrock fingerprint so a key rotation refreshes in place instead of forcing recreation. Adds exec_oneshot_env() for env-carrying one-shot execs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/commands/project_commands.rs | 7 + app/src-tauri/src/docker/container.rs | 147 ++++++++++++++++-- app/src-tauri/src/docker/exec.rs | 13 ++ container/entrypoint.sh | 17 +- 4 files changed, 167 insertions(+), 17 deletions(-) diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index bbdc63d..5d8cacf 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -432,6 +432,13 @@ 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); + } + Ok(container_id) }.await; diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 83ba096..85e455c 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -275,12 +275,15 @@ fn compute_bedrock_fingerprint(project: &Project, global_aws: &GlobalAwsSettings bedrock.model_id.as_deref(), global_aws.default_model_id.as_deref(), ).unwrap_or("").to_string(); + // NOTE: the static credential fields (access key / secret / session + // token) are intentionally NOT part of the fingerprint. They are + // written to ~/.aws/credentials on every start by + // write_bedrock_static_credentials(), so a key rotation should refresh + // in place rather than force a full container recreation. Region, + // profile, and bearer token remain env-based and so stay here. let parts = vec![ format!("{:?}", bedrock.auth_method), bedrock.aws_region.clone(), - bedrock.aws_access_key_id.as_deref().unwrap_or("").to_string(), - bedrock.aws_secret_access_key.as_deref().unwrap_or("").to_string(), - bedrock.aws_session_token.as_deref().unwrap_or("").to_string(), bedrock.aws_profile.as_deref().unwrap_or("").to_string(), bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(), effective_model, @@ -669,15 +672,14 @@ pub async fn create_container( match bedrock.auth_method { BedrockAuthMethod::StaticCredentials => { - if let Some(ref key_id) = bedrock.aws_access_key_id { - env_vars.push(format!("AWS_ACCESS_KEY_ID={}", key_id)); - } - if let Some(ref secret) = bedrock.aws_secret_access_key { - env_vars.push(format!("AWS_SECRET_ACCESS_KEY={}", secret)); - } - if let Some(ref token) = bedrock.aws_session_token { - env_vars.push(format!("AWS_SESSION_TOKEN={}", token)); - } + // Static/session credentials are NOT injected as env vars. + // They are written to ~/.aws/credentials by + // write_bedrock_static_credentials() on every container + // start, so rotated/updated keys are picked up without a + // full container recreation (and never get baked into the + // snapshot image). The empty values set by the + // MANAGED_AUTH_KEYS neutralization pass below are ignored by + // the AWS SDK, which falls through to the credentials file. } BedrockAuthMethod::Profile => { // Per-project profile overrides global @@ -755,6 +757,41 @@ pub async fn create_container( } } + // ── Neutralize stale backend auth env vars ────────────────────────────── + // When a project switches backends (e.g. Bedrock → Anthropic) the container + // is recreated *from a snapshot image* committed off the previous container. + // `docker commit` always bakes the previous container's full ENV into that + // image, and the commit API cannot strip it. So any auth var set under the + // old backend (e.g. CLAUDE_CODE_USE_BEDROCK=1, AWS_*) survives in the image + // ENV and stays active unless we explicitly override it at create time. + // Create-time env takes precedence over image ENV, so we set every managed + // auth key the *current* backend did NOT set to an empty value, clearing the + // stale baked-in one. + const MANAGED_AUTH_KEYS: &[&str] = &[ + "CLAUDE_CODE_USE_BEDROCK", + "AWS_REGION", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_SSO_AUTH_REFRESH_CMD", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_MODEL", + "DISABLE_PROMPT_CACHING", + "ANTHROPIC_BEDROCK_SERVICE_TIER", + ]; + let already_set: std::collections::HashSet = env_vars + .iter() + .filter_map(|e| e.split('=').next().map(|k| k.to_string())) + .collect(); + for key in MANAGED_AUTH_KEYS { + if !already_set.contains(*key) { + env_vars.push(format!("{}=", key)); + } + } + // Custom environment variables (global + per-project, project overrides global for same key) let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars); let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; @@ -1060,10 +1097,92 @@ 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( + 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 key_id = match bedrock.aws_access_key_id.as_deref() { + Some(k) if !k.is_empty() => k, + _ => { + log::warn!("Bedrock static auth selected but no AWS access key id is set"); + return Ok(()); + } + }; + 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![ + format!("TC_AWS_KEY_ID={}", key_id), + format!("TC_AWS_SECRET={}", secret), + ]; + if let Some(token) = bedrock.aws_session_token.as_deref() { + if !token.is_empty() { + env.push(format!("TC_AWS_TOKEN={}", token)); + } + } + + // umask 077 + explicit chmod guarantees 0600. The session-token line is only + // emitted when the variable is non-empty. + // + // We also remove a stale ~/.aws/config left over from a previous + // profile/SSO session on this project (the home volume persists across + // backend switches), so its sso_session/profile settings don't shadow the + // static [default] credentials. This is skipped when /tmp/.host-aws is + // mounted (a global aws_config_path is configured) — in that case the + // entrypoint already refreshes ~/.aws from the host on every start and the + // config is intentional. + let script = r#"set -e +umask 077 +mkdir -p "$HOME/.aws" +if [ ! -d /tmp/.host-aws ] && [ -f "$HOME/.aws/config" ]; then + rm -f "$HOME/.aws/config" +fi +{ + printf '[default]\n' + printf 'aws_access_key_id=%s\n' "$TC_AWS_KEY_ID" + printf 'aws_secret_access_key=%s\n' "$TC_AWS_SECRET" + if [ -n "${TC_AWS_TOKEN:-}" ]; then + printf 'aws_session_token=%s\n' "$TC_AWS_TOKEN" + fi +} > "$HOME/.aws/credentials" +chmod 600 "$HOME/.aws/credentials""#; + + let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()]; + crate::docker::exec::exec_oneshot_env(container_id, cmd, env) + .await + .map(|_| ()) + .map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?; + + log::info!("Wrote Bedrock static credentials into container {}", container_id); + Ok(()) +} + /// Commit the container's filesystem to a snapshot image so that system-level /// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container -/// removal. The Config is left empty so that secrets injected as env vars are -/// NOT baked into the image. +/// removal. +/// +/// NOTE: `docker commit` always bakes the *running container's* full ENV into +/// the resulting image — passing an empty Config here does NOT strip it, and +/// the commit API gives no way to remove env vars. As a result auth vars (e.g. +/// CLAUDE_CODE_USE_BEDROCK, AWS_*) are present in this snapshot image's ENV. +/// `create_container` defends against that by explicitly overriding every +/// managed auth key for the active backend (see MANAGED_AUTH_KEYS), so a +/// backend switch does not inherit the previous backend's stale credentials. pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> { let docker = get_docker()?; let image_name = get_snapshot_image_name(project); diff --git a/app/src-tauri/src/docker/exec.rs b/app/src-tauri/src/docker/exec.rs index f7ca2df..64a97e8 100644 --- a/app/src-tauri/src/docker/exec.rs +++ b/app/src-tauri/src/docker/exec.rs @@ -281,6 +281,18 @@ impl ExecSessionManager { /// Run a one-shot (non-interactive) exec command in a container and collect stdout. pub async fn exec_oneshot(container_id: &str, cmd: Vec) -> Result { + exec_oneshot_env(container_id, cmd, Vec::new()).await +} + +/// Like `exec_oneshot`, but passes additional environment variables to the exec +/// process. Secrets passed this way live only in `/proc//environ` (readable +/// by the same user / root) rather than in the process argv, so they are not +/// exposed via `ps`. +pub async fn exec_oneshot_env( + container_id: &str, + cmd: Vec, + env: Vec, +) -> Result { let docker = get_docker()?; let exec = docker @@ -290,6 +302,7 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec) -> Result/dev/null) if [ -n "$MERGED" ]; then @@ -227,6 +231,13 @@ if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then chown claude:claude "$CLAUDE_JSON" chmod 600 "$CLAUDE_JSON" unset AWS_SSO_AUTH_REFRESH_CMD +elif [ -f "$CLAUDE_JSON" ]; then + MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null) + if [ -n "$MERGED" ]; then + printf '%s\n' "$MERGED" > "$CLAUDE_JSON" + chown claude:claude "$CLAUDE_JSON" + chmod 600 "$CLAUDE_JSON" + fi fi # ── Docker socket permissions ──────────────────────────────────────────────── From d07dcdfea9fdacece06bb17716bcf4b9b7b0e0ff Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 30 Jun 2026 13:48:04 -0700 Subject: [PATCH 2/9] Add "Backup" action to download a project's /workspace as .tar.gz Adds a manual backup button on each project card (next to Start/Reset when stopped, and next to Files when running) that saves a gzipped tarball of the container's /workspace to a host path via the native save dialog. Backend: download_container_backup runs `tar czf -` inside the container (so excludes + compression happen there rather than streaming a 16 GB workspace) and pipes stdout straight to the chosen file. Regenerable build artifacts (node_modules, target, .git/objects) are excluded so the archive stays restore-sized. Returns bytes written; stderr is captured for error reporting and a zero-byte result is treated as failure. Works whether the container is running or stopped (only requires that it exists). Verified on the Ubuntu/GNU-tar container base. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src-tauri/src/commands/file_commands.rs | 114 +++++++++++++++++++- app/src-tauri/src/lib.rs | 1 + app/src/components/projects/ProjectCard.tsx | 35 +++++- app/src/lib/tauri-commands.ts | 2 + 4 files changed, 150 insertions(+), 2 deletions(-) diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index 37b2ef6..cc312c8 100644 --- a/app/src-tauri/src/commands/file_commands.rs +++ b/app/src-tauri/src/commands/file_commands.rs @@ -1,4 +1,5 @@ -use bollard::container::{DownloadFromContainerOptions, UploadToContainerOptions}; +use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContainerOptions}; +use bollard::exec::{CreateExecOptions, StartExecResults}; use futures_util::StreamExt; use serde::Serialize; use tauri::State; @@ -151,6 +152,117 @@ pub async fn download_container_file( Ok(()) } +/// Create a `.tar.gz` backup of the container's /workspace and stream it to a +/// host file. Regenerable build artifacts (node_modules, target, .git/objects) +/// are excluded so the archive stays restore-sized. Requires the container to +/// exist (it can be stopped or running). Returns the number of bytes written. +#[tauri::command] +pub async fn download_container_backup( + project_id: String, + host_path: String, + container_path: Option, + state: State<'_, AppState>, +) -> Result { + let project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + let container_id = project + .container_id + .as_ref() + .ok_or_else(|| "No container exists for this project yet — start it first".to_string())?; + + let docker = get_docker()?; + let path = container_path.unwrap_or_else(|| "/workspace".to_string()); + + // Build + gzip the archive inside the container (excludes happen there, so a + // 16 GB workspace doesn't get streamed in full), then pipe stdout to the + // chosen host file. --ignore-failed-read keeps a transient unreadable file + // from aborting the whole backup. + let cmd = vec![ + "tar".to_string(), + "czf".to_string(), + "-".to_string(), + "--ignore-failed-read".to_string(), + "--exclude=*/node_modules".to_string(), + "--exclude=*/target".to_string(), + "--exclude=*/.git/objects".to_string(), + "-C".to_string(), + path, + ".".to_string(), + ]; + + let exec = docker + .create_exec( + container_id, + CreateExecOptions { + attach_stdout: Some(true), + attach_stderr: Some(true), + cmd: Some(cmd), + user: Some("claude".to_string()), + ..Default::default() + }, + ) + .await + .map_err(|e| format!("Failed to create backup exec: {}", e))?; + + let result = docker + .start_exec(&exec.id, None) + .await + .map_err(|e| format!("Failed to start backup exec: {}", e))?; + + let mut output = match result { + StartExecResults::Attached { output, .. } => output, + 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); + let mut total: u64 = 0; + let mut stderr_text = String::new(); + + while let Some(msg) = output.next().await { + match msg.map_err(|e| format!("Backup stream error: {}", e))? { + LogOutput::StdOut { message } => { + writer + .write_all(&message) + .map_err(|e| format!("Failed to write backup file: {}", e))?; + total += message.len() as u64; + } + LogOutput::StdErr { message } => { + stderr_text.push_str(&String::from_utf8_lossy(&message)); + } + _ => {} + } + } + writer + .flush() + .map_err(|e| format!("Failed to finalize backup file: {}", e))?; + + if total == 0 { + let _ = std::fs::remove_file(&host_path); + return Err(format!( + "Backup produced no data{}", + if stderr_text.trim().is_empty() { + String::new() + } else { + format!(": {}", stderr_text.trim()) + } + )); + } + + log::info!( + "Wrote {} byte backup for project {} to {}", + total, + project_id, + host_path + ); + Ok(total) +} + #[tauri::command] pub async fn upload_file_to_container( project_id: String, diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 5b041e5..7d42655 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -184,6 +184,7 @@ pub fn run() { // Files commands::file_commands::list_container_files, commands::file_commands::download_container_file, + commands::file_commands::download_container_backup, commands::file_commands::upload_file_to_container, // MCP commands::mcp_commands::list_mcp_servers, diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index a3e575d..ef40b4a 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; -import { open } from "@tauri-apps/plugin-dialog"; +import { open, save } from "@tauri-apps/plugin-dialog"; +import * as commands from "../../lib/tauri-commands"; import { listen } from "@tauri-apps/api/event"; import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types"; import { useProjects } from "../../hooks/useProjects"; @@ -37,6 +38,7 @@ export default function ProjectCard({ project }: Props) { const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null); const [operationCompleted, setOperationCompleted] = useState(false); const [showRemoveModal, setShowRemoveModal] = useState(false); + const [backingUp, setBackingUp] = useState(false); const [isEditingName, setIsEditingName] = useState(false); const [editName, setEditName] = useState(project.name); const isSelected = selectedProjectId === project.id; @@ -177,6 +179,31 @@ export default function ProjectCard({ project }: Props) { } }; + const handleBackup = async () => { + if (!project.container_id) { + setError("Start the project at least once before backing up."); + return; + } + const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-"); + const safeName = project.name.replace(/[^a-zA-Z0-9_-]+/g, "_"); + try { + const hostPath = await save({ + defaultPath: `${safeName}-backup-${stamp}.tar.gz`, + filters: [{ name: "Gzipped tarball", extensions: ["tar.gz"] }], + }); + if (!hostPath) return; + setBackingUp(true); + setError(null); + const bytes = await commands.downloadContainerBackup(project.id, hostPath); + const mb = (bytes / (1024 * 1024)).toFixed(1); + setProgressMsg(`Backup saved (${mb} MB)`); + } catch (e) { + setError(String(e)); + } finally { + setBackingUp(false); + } + }; + const closeModal = () => { setActiveOperation(null); setOperationCompleted(false); @@ -484,6 +511,11 @@ export default function ProjectCard({ project }: Props) { {isStopped ? ( <> + { setLoading(true); @@ -504,6 +536,7 @@ export default function ProjectCard({ project }: Props) { setShowFileManager(true)} disabled={loading} label="Files" /> + ) : ( <> diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 4fdd6da..c0f7a84 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -76,6 +76,8 @@ export const listContainerFiles = (projectId: string, path: string) => invoke("list_container_files", { projectId, path }); export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) => invoke("download_container_file", { projectId, containerPath, hostPath }); +export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) => + invoke("download_container_backup", { projectId, hostPath, containerPath }); export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) => invoke("upload_file_to_container", { projectId, hostPath, containerDir }); From 10e689eaa6f30dec8e36c15fc47035a1cd597636 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 30 Jun 2026 14:04:19 -0700 Subject: [PATCH 3/9] Include sanitized home config in project backup Extends download_container_backup to also capture the container's home config so MCP servers, settings, and skills set up directly via Claude Code (stored in ~/.claude.json / ~/.claude, on the home/config volumes that a Reset wipes) survive a backup/restore cycle. Secrets are stripped per the "exclude secrets" choice: ~/.claude.json is filtered through jq to drop primaryApiKey/oauthAccount/customApiKeyResponses (mcpServers and settings are kept), and ~/.claude/.credentials.json (the OAuth tokens) is omitted. Staged config is archived under home-claude/ in the tarball. Verified on the Ubuntu/jq container base. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src-tauri/src/commands/file_commands.rs | 58 ++++++++++++++------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index cc312c8..624cab0 100644 --- a/app/src-tauri/src/commands/file_commands.rs +++ b/app/src-tauri/src/commands/file_commands.rs @@ -152,10 +152,17 @@ pub async fn download_container_file( Ok(()) } -/// Create a `.tar.gz` backup of the container's /workspace and stream it to a -/// host file. Regenerable build artifacts (node_modules, target, .git/objects) -/// are excluded so the archive stays restore-sized. Requires the container to -/// exist (it can be stopped or running). Returns the number of bytes written. +/// Create a `.tar.gz` backup of the container and stream it to a host file. +/// The archive contains: +/// - the workspace (default /workspace), minus regenerable build artifacts +/// (node_modules, target, .git/objects), at the archive root, and +/// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json +/// with secret-bearing keys removed (mcpServers/settings kept) and ~/.claude/ +/// minus the OAuth `.credentials.json`, so MCP servers, settings and skills +/// set up via Claude Code survive a Reset. +/// Build + gzip happen inside the container so a large workspace isn't streamed +/// in full. Requires the container to exist (running or stopped). Returns the +/// number of bytes written. #[tauri::command] pub async fn download_container_backup( project_id: String, @@ -176,22 +183,29 @@ pub async fn download_container_backup( let docker = get_docker()?; let path = container_path.unwrap_or_else(|| "/workspace".to_string()); - // Build + gzip the archive inside the container (excludes happen there, so a - // 16 GB workspace doesn't get streamed in full), then pipe stdout to the - // chosen host file. --ignore-failed-read keeps a transient unreadable file - // from aborting the whole backup. - let cmd = vec![ - "tar".to_string(), - "czf".to_string(), - "-".to_string(), - "--ignore-failed-read".to_string(), - "--exclude=*/node_modules".to_string(), - "--exclude=*/target".to_string(), - "--exclude=*/.git/objects".to_string(), - "-C".to_string(), - path, - ".".to_string(), - ]; + // Stage a sanitized home config, then tar+gzip workspace + staged config to + // stdout. mktemp/jq output go nowhere near stdout, so the only thing the + // exec emits on stdout is the archive itself. --ignore-failed-read keeps a + // transient unreadable file from aborting the whole backup. + let script = r#"set -e +STAGE=$(mktemp -d) +mkdir -p "$STAGE/home-claude" +if [ -f "$HOME/.claude.json" ]; then + jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \ + > "$STAGE/home-claude/.claude.json" 2>/dev/null \ + || cp "$HOME/.claude.json" "$STAGE/home-claude/.claude.json" +fi +if [ -d "$HOME/.claude" ]; then + cp -a "$HOME/.claude" "$STAGE/home-claude/.claude" 2>/dev/null || true + rm -f "$STAGE/home-claude/.claude/.credentials.json" +fi +tar czf - --ignore-failed-read \ + --exclude='*/node_modules' --exclude='*/target' --exclude='*/.git/objects' \ + -C "$TC_BACKUP_SRC" . \ + -C "$STAGE" home-claude +rm -rf "$STAGE""#; + + let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()]; let exec = docker .create_exec( @@ -200,6 +214,10 @@ pub async fn download_container_backup( attach_stdout: Some(true), attach_stderr: Some(true), cmd: Some(cmd), + env: Some(vec![ + "HOME=/home/claude".to_string(), + format!("TC_BACKUP_SRC={}", path), + ]), user: Some("claude".to_string()), ..Default::default() }, From 84e0bdf7b4abb63313711497964a6f5c94922fa6 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 30 Jun 2026 14:11:09 -0700 Subject: [PATCH 4/9] Add file drag-and-drop onto the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop files onto a terminal pane and they're copied into the container and their in-container paths typed into the prompt, so Claude Code can read them for reference — mirroring the existing image-paste flow. Backend: upload_host_file_to_terminal reads the dropped host file and writes it under /tmp/triple-c-drops/ in the session's container, returning that path. Rejects directories and unreadable paths. Frontend: TerminalView subscribes to Tauri's webview onDragDropEvent (OS file drops are intercepted at the webview level, so HTML5 ondrop wouldn't expose paths). The window-wide event is guarded by the pane's `active` flag plus a bounds hit-test so a drop only affects the terminal it landed on; multiple files are uploaded and their paths inserted space-separated (quoted when they contain spaces). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/commands/terminal_commands.rs | 35 ++++++++++ app/src-tauri/src/lib.rs | 1 + app/src/components/terminal/TerminalView.tsx | 66 ++++++++++++++++++- app/src/lib/tauri-commands.ts | 2 + 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index dbc1b27..64e95a3 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -183,6 +183,41 @@ pub async fn paste_image_to_terminal( .await } +/// Copy a host file (e.g. dragged onto the terminal) into the container so +/// Claude Code can read it, and return the in-container path. Mirrors the +/// image-paste flow: the file is placed under /tmp/triple-c-drops/ keeping its +/// original name. Returns an error for paths that aren't readable regular files +/// (e.g. a dropped directory). +#[tauri::command] +pub async fn upload_host_file_to_terminal( + session_id: String, + host_path: String, + state: State<'_, AppState>, +) -> Result { + let container_id = state.exec_manager.get_container_id(&session_id).await?; + + let meta = std::fs::metadata(&host_path) + .map_err(|e| format!("Cannot access {}: {}", host_path, e))?; + if meta.is_dir() { + return Err(format!("{} is a directory — drop individual files", host_path)); + } + + let data = + std::fs::read(&host_path).map_err(|e| format!("Failed to read {}: {}", host_path, e))?; + + let base = std::path::Path::new(&host_path) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "dropped-file".to_string()); + + let file_name = format!("triple-c-drops/{}", base); + state + .exec_manager + .write_file_to_container(&container_id, &file_name, &data) + .await +} + #[tauri::command] pub async fn start_audio_bridge( session_id: String, diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 7d42655..527e9b5 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -178,6 +178,7 @@ pub fn run() { commands::terminal_commands::terminal_resize, commands::terminal_commands::close_terminal_session, commands::terminal_commands::paste_image_to_terminal, + commands::terminal_commands::upload_host_file_to_terminal, commands::terminal_commands::start_audio_bridge, commands::terminal_commands::send_audio_data, commands::terminal_commands::stop_audio_bridge, diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index ee871b6..8c682c6 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -7,7 +7,8 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import "@xterm/xterm/css/xterm.css"; import { useTerminal } from "../../hooks/useTerminal"; import { useAppState } from "../../store/appState"; -import { awsSsoRefresh } from "../../lib/tauri-commands"; +import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands"; +import { getCurrentWebview } from "@tauri-apps/api/webview"; import { UrlDetector } from "../../lib/urlDetector"; import UrlToast from "./UrlToast"; import { trimSelection } from "./trimSelection"; @@ -47,6 +48,67 @@ export default function TerminalView({ sessionId, active }: Props) { const autoFollowRef = useRef(true); const lastUserScrollTimeRef = useRef(0); + // Keep latest `active` readable inside long-lived listeners (drag-drop below, + // and the unmount-cleanup effect further down). + const activeRef = useRef(active); + activeRef.current = active; + + // File drag-and-drop: dropped files are copied into the container and their + // in-container paths typed into the prompt so Claude Code can read them. + // Tauri intercepts OS file drops at the webview level, so we use + // onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths). + // The listener is window-wide, so we guard on `active` + a hit-test against + // this terminal's bounds to ignore drops meant for another pane. + useEffect(() => { + let unlisten: (() => void) | undefined; + let cancelled = false; + + const insideThisTerminal = (pos: { x: number; y: number }): boolean => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return false; + const dpr = window.devicePixelRatio || 1; + const x = pos.x / dpr; + const y = pos.y / dpr; + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + }; + + const quote = (p: string) => (/\s/.test(p) ? `'${p.replace(/'/g, "'\\''")}'` : p); + + (async () => { + const un = await getCurrentWebview().onDragDropEvent(async (event) => { + if (event.payload.type !== "drop") return; + if (!activeRef.current) return; + if (!insideThisTerminal(event.payload.position)) return; + + const paths = event.payload.paths ?? []; + if (paths.length === 0) return; + + setImagePasteMsg(`Adding ${paths.length} file${paths.length > 1 ? "s" : ""}…`); + const containerPaths: string[] = []; + for (const p of paths) { + try { + containerPaths.push(await uploadHostFileToTerminal(sessionId, p)); + } catch (err) { + console.error("File drop upload failed for", p, err); + } + } + if (containerPaths.length === 0) { + setImagePasteMsg("File drop failed"); + return; + } + sendInput(sessionId, containerPaths.map(quote).join(" ") + " "); + setImagePasteMsg(`Added ${containerPaths.length} file path${containerPaths.length > 1 ? "s" : ""}`); + }); + if (cancelled) un(); + else unlisten = un; + })(); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, [sessionId, sendInput]); + useEffect(() => { if (!containerRef.current) return; @@ -403,8 +465,6 @@ export default function TerminalView({ sessionId, active }: Props) { // state so it doesn't point at a disposed terminal. (Tab switches don't // unmount — the deactivating terminal stays mounted but hidden — so this // only fires when the active session is actually closed.) - const activeRef = useRef(active); - activeRef.current = active; useEffect(() => { return () => { if (activeRef.current) { diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index c0f7a84..c894db2 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -55,6 +55,8 @@ export const closeTerminalSession = (sessionId: string) => invoke("close_terminal_session", { sessionId }); export const pasteImageToTerminal = (sessionId: string, imageData: number[]) => invoke("paste_image_to_terminal", { sessionId, imageData }); +export const uploadHostFileToTerminal = (sessionId: string, hostPath: string) => + invoke("upload_host_file_to_terminal", { sessionId, hostPath }); export const startAudioBridge = (sessionId: string) => invoke("start_audio_bridge", { sessionId }); export const sendAudioData = (sessionId: string, data: number[]) => From edf069877420764263a04d9c0c28026858c56b83 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 30 Jun 2026 14:28:00 -0700 Subject: [PATCH 5/9] Address PR review: backup correctness/security + drop hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from the code review of this branch: - Backup requires a running container (it runs via `docker exec`, which can't run on a stopped one). Removed the misleading "Backup" button from the stopped-project actions, added an explicit running check with a clear error, and corrected the doc comment. (H1) - jq sanitization fallback no longer leaks secrets: if ~/.claude.json can't be parsed, the backup substitutes an empty object and warns to stderr instead of copying the raw file (which held primaryApiKey / oauthAccount). Verified the raw key never reaches the archive. (H2) - Dropped-file paths typed into the terminal are now always single-quoted (with '\'' escaping), not only when they contain whitespace — a name like `foo$(whoami).txt` was previously sent raw into the shell. (M2) - write_bedrock_static_credentials checks the exec exit code via the new exec_oneshot_env_status and fails loudly on a write/chmod error instead of silently reporting success. exec_oneshot keeps its ignore-exit-code behavior so list_container_files is unaffected. (M4) - Backup removes a partial/truncated archive on any stream error and treats a non-zero tar exit code as failure (a truncated gzip was previously reported as success). (L1) - Dropped files are capped at 256 MiB to avoid ballooning host RAM (the file is read fully into memory then re-tarred). (M3) - Stopped excluding .git/objects from the backup so git history, including unpushed commits, is preserved faithfully. (L3) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src-tauri/src/commands/file_commands.rs | 97 ++++++++++++++----- .../src/commands/terminal_commands.rs | 11 +++ app/src-tauri/src/docker/container.rs | 15 ++- app/src-tauri/src/docker/exec.rs | 34 ++++++- app/src/components/projects/ProjectCard.tsx | 5 - app/src/components/terminal/TerminalView.tsx | 5 +- 6 files changed, 131 insertions(+), 36 deletions(-) diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index 624cab0..8020246 100644 --- a/app/src-tauri/src/commands/file_commands.rs +++ b/app/src-tauri/src/commands/file_commands.rs @@ -155,14 +155,15 @@ pub async fn download_container_file( /// Create a `.tar.gz` backup of the container and stream it to a host file. /// The archive contains: /// - the workspace (default /workspace), minus regenerable build artifacts -/// (node_modules, target, .git/objects), at the archive root, and +/// (node_modules, target), at the archive root, and /// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json /// with secret-bearing keys removed (mcpServers/settings kept) and ~/.claude/ /// minus the OAuth `.credentials.json`, so MCP servers, settings and skills /// set up via Claude Code survive a Reset. -/// Build + gzip happen inside the container so a large workspace isn't streamed -/// in full. Requires the container to exist (running or stopped). Returns the -/// number of bytes written. +/// `.git` is kept in full so the backup faithfully preserves git history, +/// including unpushed commits. Build + gzip happen inside the container so a +/// large workspace isn't streamed in full. The container must be RUNNING (the +/// backup runs via `docker exec`). Returns the number of bytes written. #[tauri::command] pub async fn download_container_backup( project_id: String, @@ -181,26 +182,44 @@ pub async fn download_container_backup( .ok_or_else(|| "No container exists for this project yet — start it first".to_string())?; let docker = get_docker()?; + + // The backup runs inside the container via `docker exec`, which requires it + // to be running. Fail with a clear message rather than a raw Docker error. + let running = docker + .inspect_container(container_id, None) + .await + .ok() + .and_then(|info| info.state) + .and_then(|s| s.running) + .unwrap_or(false); + if !running { + return Err("Start the project before backing up — the backup runs inside the running container.".to_string()); + } + let path = container_path.unwrap_or_else(|| "/workspace".to_string()); // Stage a sanitized home config, then tar+gzip workspace + staged config to // stdout. mktemp/jq output go nowhere near stdout, so the only thing the // exec emits on stdout is the archive itself. --ignore-failed-read keeps a - // transient unreadable file from aborting the whole backup. + // transient unreadable file from aborting the whole backup. If jq can't + // parse ~/.claude.json we substitute an empty object — never the raw file — + // so secrets can't leak through the sanitization fallback. let script = r#"set -e STAGE=$(mktemp -d) mkdir -p "$STAGE/home-claude" if [ -f "$HOME/.claude.json" ]; then - jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \ - > "$STAGE/home-claude/.claude.json" 2>/dev/null \ - || cp "$HOME/.claude.json" "$STAGE/home-claude/.claude.json" + if ! jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \ + > "$STAGE/home-claude/.claude.json" 2>/dev/null; then + echo "warning: could not sanitize .claude.json; omitting it from backup" >&2 + printf '{}' > "$STAGE/home-claude/.claude.json" + fi fi if [ -d "$HOME/.claude" ]; then cp -a "$HOME/.claude" "$STAGE/home-claude/.claude" 2>/dev/null || true rm -f "$STAGE/home-claude/.claude/.credentials.json" fi tar czf - --ignore-failed-read \ - --exclude='*/node_modules' --exclude='*/target' --exclude='*/.git/objects' \ + --exclude='*/node_modules' --exclude='*/target' \ -C "$TC_BACKUP_SRC" . \ -C "$STAGE" home-claude rm -rf "$STAGE""#; @@ -241,28 +260,56 @@ rm -rf "$STAGE""#; let mut writer = std::io::BufWriter::new(file); let mut total: u64 = 0; let mut stderr_text = String::new(); + let mut stream_err: Option = None; while let Some(msg) = output.next().await { - match msg.map_err(|e| format!("Backup stream error: {}", e))? { - LogOutput::StdOut { message } => { - writer - .write_all(&message) - .map_err(|e| format!("Failed to write backup file: {}", e))?; + match msg { + Ok(LogOutput::StdOut { message }) => { + if let Err(e) = writer.write_all(&message) { + stream_err = Some(format!("Failed to write backup file: {}", e)); + break; + } total += message.len() as u64; } - LogOutput::StdErr { message } => { + Ok(LogOutput::StdErr { message }) => { stderr_text.push_str(&String::from_utf8_lossy(&message)); } - _ => {} + Ok(_) => {} + Err(e) => { + stream_err = Some(format!("Backup stream error: {}", e)); + break; + } } } - writer - .flush() - .map_err(|e| format!("Failed to finalize backup file: {}", e))?; + if stream_err.is_none() { + if let Err(e) = writer.flush() { + stream_err = Some(format!("Failed to finalize backup file: {}", e)); + } + } + drop(writer); - if total == 0 { - let _ = std::fs::remove_file(&host_path); - return Err(format!( + // The tar pipeline can abort mid-stream (producing a truncated archive) and + // still have sent bytes, so a non-zero exit must be treated as failure even + // when `total > 0`. + let exit_code = docker + .inspect_exec(&exec.id) + .await + .map(|i| i.exit_code.unwrap_or(0)) + .unwrap_or(0); + + if stream_err.is_none() && exit_code != 0 { + stream_err = Some(format!( + "Backup command failed (exit {}){}", + exit_code, + if stderr_text.trim().is_empty() { + String::new() + } else { + format!(": {}", stderr_text.trim()) + } + )); + } + if stream_err.is_none() && total == 0 { + stream_err = Some(format!( "Backup produced no data{}", if stderr_text.trim().is_empty() { String::new() @@ -272,6 +319,12 @@ rm -rf "$STAGE""#; )); } + if let Some(err) = stream_err { + // Don't leave a partial/corrupt archive behind. + let _ = std::fs::remove_file(&host_path); + return Err(err); + } + log::info!( "Wrote {} byte backup for project {} to {}", total, diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index 64e95a3..c6004b1 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -202,6 +202,17 @@ pub async fn upload_host_file_to_terminal( return Err(format!("{} is a directory — drop individual files", host_path)); } + // Guard against ballooning host RAM: the file is read fully into memory and + // then re-packed into an in-memory tar, so cap the size of a dropped file. + const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB + if meta.len() > MAX_DROP_BYTES { + return Err(format!( + "File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.", + meta.len() as f64 / (1024.0 * 1024.0), + MAX_DROP_BYTES / (1024 * 1024) + )); + } + let data = std::fs::read(&host_path).map_err(|e| format!("Failed to read {}: {}", host_path, e))?; diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 85e455c..b44661f 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -1163,10 +1163,17 @@ fi chmod 600 "$HOME/.aws/credentials""#; let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()]; - crate::docker::exec::exec_oneshot_env(container_id, cmd, env) - .await - .map(|_| ()) - .map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?; + let (output, exit_code) = + crate::docker::exec::exec_oneshot_env_status(container_id, cmd, env) + .await + .map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?; + if exit_code != 0 { + return Err(format!( + "Writing AWS credentials into container failed (exit {}): {}", + exit_code, + output.trim() + )); + } log::info!("Wrote Bedrock static credentials into container {}", container_id); Ok(()) diff --git a/app/src-tauri/src/docker/exec.rs b/app/src-tauri/src/docker/exec.rs index 64a97e8..41d0e21 100644 --- a/app/src-tauri/src/docker/exec.rs +++ b/app/src-tauri/src/docker/exec.rs @@ -288,11 +288,27 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec) -> Result/environ` (readable /// by the same user / root) rather than in the process argv, so they are not /// exposed via `ps`. +/// +/// NOTE: the command's exit code is NOT checked — callers that need to know +/// whether the command succeeded should use `exec_oneshot_env_status`. pub async fn exec_oneshot_env( container_id: &str, cmd: Vec, env: Vec, ) -> Result { + exec_oneshot_env_status(container_id, cmd, env) + .await + .map(|(output, _exit_code)| output) +} + +/// Like `exec_oneshot_env`, but also returns the command's exit code (0 on +/// success). The returned string contains both stdout and stderr, interleaved +/// in arrival order, which is useful for surfacing failure detail. +pub async fn exec_oneshot_env_status( + container_id: &str, + cmd: Vec, + env: Vec, +) -> Result<(String, i64), String> { let docker = get_docker()?; let exec = docker @@ -315,17 +331,27 @@ pub async fn exec_oneshot_env( .await .map_err(|e| format!("Failed to start exec: {}", e))?; + let mut combined = String::new(); match result { StartExecResults::Attached { mut output, .. } => { - let mut stdout = String::new(); while let Some(msg) = output.next().await { match msg { - Ok(data) => stdout.push_str(&String::from_utf8_lossy(&data.into_bytes())), + Ok(data) => combined.push_str(&String::from_utf8_lossy(&data.into_bytes())), Err(e) => return Err(format!("Exec output error: {}", e)), } } - Ok(stdout) } - StartExecResults::Detached => Err("Exec started in detached mode".to_string()), + StartExecResults::Detached => return Err("Exec started in detached mode".to_string()), } + + // Exit code is only available after the process has finished (the stream above + // has drained), so inspect now. + let exit_code = docker + .inspect_exec(&exec.id) + .await + .map_err(|e| format!("Failed to inspect exec: {}", e))? + .exit_code + .unwrap_or(0); + + Ok((combined, exit_code)) } diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index ef40b4a..efb28fe 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -511,11 +511,6 @@ export default function ProjectCard({ project }: Props) { {isStopped ? ( <> - { setLoading(true); diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 8c682c6..d167211 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -72,7 +72,10 @@ export default function TerminalView({ sessionId, active }: Props) { return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; }; - const quote = (p: string) => (/\s/.test(p) ? `'${p.replace(/'/g, "'\\''")}'` : p); + // Always single-quote: a dropped filename can contain shell metacharacters + // ($(), &&, ', spaces) even with no whitespace, and this path is typed into + // a live shell. Single-quoting with '\'' escaping neutralizes all of them. + const quote = (p: string) => `'${p.replace(/'/g, "'\\''")}'`; (async () => { const un = await getCurrentWebview().onDragDropEvent(async (event) => { From d65872dc94b3e7ba894ae26ca41b27a0addff719 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 30 Jun 2026 14:37:33 -0700 Subject: [PATCH 6/9] Address remaining review items: L4/M1 + L2/L5 cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/src-tauri/src/commands/file_commands.rs | 15 ++--- .../src/commands/project_commands.rs | 10 ++-- .../src/commands/terminal_commands.rs | 16 +++++- app/src-tauri/src/docker/container.rs | 57 +++++++++++++------ app/src/components/projects/ProjectCard.tsx | 12 +++- 5 files changed, 77 insertions(+), 33 deletions(-) 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({