diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index 37b2ef6..9e48f20 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,187 @@ pub async fn download_container_file( Ok(()) } +/// 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), 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. +/// `.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, + 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()?; + + // 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. 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) +trap 'rm -rf "$STAGE"' EXIT +mkdir -p "$STAGE/home-claude" +if [ -f "$HOME/.claude.json" ]; then + 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' \ + -C "$TC_BACKUP_SRC" . \ + -C "$STAGE" home-claude"#; + + let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()]; + + let exec = docker + .create_exec( + container_id, + CreateExecOptions { + 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() + }, + ) + .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 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; + + while let Some(msg) = output.next().await { + match msg { + Ok(LogOutput::StdOut { message }) => { + if let Err(e) = writer.write_all(&message).await { + stream_err = Some(format!("Failed to write backup file: {}", e)); + break; + } + total += message.len() as u64; + } + 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; + } + } + } + if stream_err.is_none() { + if let Err(e) = writer.flush().await { + stream_err = Some(format!("Failed to finalize backup file: {}", e)); + } + } + drop(writer); + + // 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`. Poll until the exec actually reports finished so the + // exit code is reliably populated; if it can't be determined we fall back to + // the `total == 0` check below. + let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await; + + if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) { + stream_err = Some(format!( + "Backup command failed (exit {}){}", + exit_code.unwrap_or(-1), + 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() + } else { + format!(": {}", stderr_text.trim()) + } + )); + } + + if let Some(err) = stream_err { + // Don't leave a partial/corrupt archive behind. + let _ = tokio::fs::remove_file(&host_path).await; + return Err(err); + } + + 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/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index bbdc63d..c947a64 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 }; + // 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) }.await; diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index dbc1b27..2df434f 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -183,6 +183,55 @@ 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 = 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)); + } + + // Guard against ballooning host RAM: the file is packed into an in-memory + // tar before upload, 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 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()); + + // 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); + crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await +} + #[tauri::command] pub async fn start_audio_bridge( session_id: String, diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 83ba096..0c9f997 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 + // sync_bedrock_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 + // sync_bedrock_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_"]; @@ -897,7 +934,19 @@ pub async fn create_container( false }; - if should_mount_aws || aws_config_path.is_some() { + // For static-credential Bedrock, sync_bedrock_credentials() is the sole + // owner of ~/.aws/credentials (it rewrites it on every start). Mounting the + // host AWS dir would make the entrypoint's `rm -rf ~/.aws; cp -a` race that + // write at startup, so we never mount it in that case — the static keys + // (+ AWS_REGION env) are self-sufficient and don't need the host config. + let is_bedrock_static = project.backend == Backend::Bedrock + && project + .bedrock_config + .as_ref() + .map(|b| b.auth_method == BedrockAuthMethod::StaticCredentials) + .unwrap_or(false); + + if (should_mount_aws || aws_config_path.is_some()) && !is_bedrock_static { let aws_dir = aws_config_path .map(|p| std::path::PathBuf::from(p)) .or_else(|| dirs::home_dir().map(|h| h.join(".aws"))); @@ -1060,10 +1109,124 @@ pub fn get_snapshot_image_name(project: &Project) -> String { format!("triple-c-snapshot-{}:latest", project.id) } +/// 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> { + let static_bedrock = if project.backend == Backend::Bedrock { + project + .bedrock_config + .as_ref() + .filter(|b| b.auth_method == BedrockAuthMethod::StaticCredentials) + } else { + None + }; + + let bedrock = match static_bedrock { + Some(b) if b.aws_access_key_id.as_deref().is_some_and(|k| !k.is_empty()) => b, + _ => { + // 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), + ]; + 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()]; + 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(()) +} + /// 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..e9cce6e 100644 --- a/app/src-tauri/src/docker/exec.rs +++ b/app/src-tauri/src/docker/exec.rs @@ -279,8 +279,90 @@ impl ExecSessionManager { } } +/// Upload a host file into the container's `/tmp` under `dest_name`. The file is +/// read and packed into the tar inside a blocking task, so the synchronous IO +/// runs off the async worker. The tar's declared entry size is taken from the +/// bytes actually read (not a separate `stat`), so a file changing size between +/// a size check and the read can't desync the header and corrupt the archive. +/// Returns the in-container path (`/tmp/`). +pub async fn upload_host_file_to_container( + container_id: &str, + host_path: &str, + dest_name: &str, +) -> Result { + let host_path = host_path.to_string(); + let dest_name = dest_name.to_string(); + let dest_for_blk = dest_name.clone(); + + let tar_buf = tokio::task::spawn_blocking(move || -> Result, String> { + let data = std::fs::read(&host_path) + .map_err(|e| format!("Failed to read {}: {}", host_path, e))?; + let mut tar_buf = Vec::with_capacity(data.len() + 1024); + { + let mut builder = tar::Builder::new(&mut tar_buf); + let mut header = tar::Header::new_gnu(); + // Size comes from the bytes in hand, so header and payload can't disagree. + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, &dest_for_blk, &data[..]) + .map_err(|e| format!("Failed to create tar entry: {}", e))?; + builder + .finish() + .map_err(|e| format!("Failed to finalize tar: {}", e))?; + } + Ok(tar_buf) + }) + .await + .map_err(|e| format!("Upload task panicked: {}", e))??; + + let docker = get_docker()?; + docker + .upload_to_container( + container_id, + Some(UploadToContainerOptions { + path: "/tmp".to_string(), + ..Default::default() + }), + tar_buf.into(), + ) + .await + .map_err(|e| format!("Failed to upload file to container: {}", e))?; + + Ok(format!("/tmp/{}", dest_name)) +} + /// 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`. +/// +/// 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 @@ -290,6 +372,7 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec) -> Result) -> Result { - 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()), } + + // The output stream draining doesn't strictly guarantee inspect_exec has the + // final exit_code populated yet, so poll until the exec reports finished. + let exit_code = wait_for_exec_exit(&exec.id).await.unwrap_or(0); + + Ok((combined, exit_code)) +} + +/// Poll `inspect_exec` until the exec reports finished and return its exit code. +/// Returns `None` if the code can't be determined (inspect error, or the exec +/// doesn't report finished within ~1s — which shouldn't happen once its output +/// stream has drained). +pub async fn wait_for_exec_exit(exec_id: &str) -> Option { + let docker = get_docker().ok()?; + for _ in 0..40 { + match docker.inspect_exec(exec_id).await { + Ok(info) => { + if info.running != Some(true) { + // Finished: use the reported code (default 0 if somehow absent). + return Some(info.exit_code.unwrap_or(0)); + } + } + Err(_) => return None, + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + None } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 5b041e5..527e9b5 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -178,12 +178,14 @@ 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, // 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..af83630 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,35 @@ 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); + const msg = `Backup saved (${mb} MB). Note: includes MCP/config — may contain MCP API keys. Keep it private.`; + setProgressMsg(msg); + // Auto-clear so the transient confirmation doesn't linger in the card + // status; guard against clobbering a newer message (e.g. a later op). + setTimeout(() => setProgressMsg((prev) => (prev === msg ? null : prev)), 8000); + } catch (e) { + setError(String(e)); + } finally { + setBackingUp(false); + } + }; + const closeModal = () => { setActiveOperation(null); setOperationCompleted(false); @@ -504,6 +535,12 @@ export default function ProjectCard({ project }: Props) { setShowFileManager(true)} disabled={loading} label="Files" /> + ) : ( <> @@ -1193,12 +1230,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)]"; @@ -1208,6 +1247,7 @@ function ActionButton({