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) => {