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) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 14:04:19 -07:00
parent d07dcdfea9
commit 10e689eaa6
+38 -20
View File
@@ -152,10 +152,17 @@ pub async fn download_container_file(
Ok(()) Ok(())
} }
/// Create a `.tar.gz` backup of the container's /workspace and stream it to a /// Create a `.tar.gz` backup of the container and stream it to a host file.
/// host file. Regenerable build artifacts (node_modules, target, .git/objects) /// The archive contains:
/// are excluded so the archive stays restore-sized. Requires the container to /// - the workspace (default /workspace), minus regenerable build artifacts
/// exist (it can be stopped or running). Returns the number of bytes written. /// (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] #[tauri::command]
pub async fn download_container_backup( pub async fn download_container_backup(
project_id: String, project_id: String,
@@ -176,22 +183,29 @@ pub async fn download_container_backup(
let docker = get_docker()?; let docker = get_docker()?;
let path = container_path.unwrap_or_else(|| "/workspace".to_string()); let path = container_path.unwrap_or_else(|| "/workspace".to_string());
// Build + gzip the archive inside the container (excludes happen there, so a // Stage a sanitized home config, then tar+gzip workspace + staged config to
// 16 GB workspace doesn't get streamed in full), then pipe stdout to the // stdout. mktemp/jq output go nowhere near stdout, so the only thing the
// chosen host file. --ignore-failed-read keeps a transient unreadable file // exec emits on stdout is the archive itself. --ignore-failed-read keeps a
// from aborting the whole backup. // transient unreadable file from aborting the whole backup.
let cmd = vec![ let script = r#"set -e
"tar".to_string(), STAGE=$(mktemp -d)
"czf".to_string(), mkdir -p "$STAGE/home-claude"
"-".to_string(), if [ -f "$HOME/.claude.json" ]; then
"--ignore-failed-read".to_string(), jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \
"--exclude=*/node_modules".to_string(), > "$STAGE/home-claude/.claude.json" 2>/dev/null \
"--exclude=*/target".to_string(), || cp "$HOME/.claude.json" "$STAGE/home-claude/.claude.json"
"--exclude=*/.git/objects".to_string(), fi
"-C".to_string(), if [ -d "$HOME/.claude" ]; then
path, cp -a "$HOME/.claude" "$STAGE/home-claude/.claude" 2>/dev/null || true
".".to_string(), 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 let exec = docker
.create_exec( .create_exec(
@@ -200,6 +214,10 @@ pub async fn download_container_backup(
attach_stdout: Some(true), attach_stdout: Some(true),
attach_stderr: Some(true), attach_stderr: Some(true),
cmd: Some(cmd), cmd: Some(cmd),
env: Some(vec![
"HOME=/home/claude".to_string(),
format!("TC_BACKUP_SRC={}", path),
]),
user: Some("claude".to_string()), user: Some("claude".to_string()),
..Default::default() ..Default::default()
}, },