Fix backend-switch AWS auth + add /workspace backup and terminal file drag-and-drop #8
@@ -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.
|
/// Create a `.tar.gz` backup of the container and stream it to a host file.
|
||||||
/// The archive contains:
|
/// The archive contains:
|
||||||
/// - the workspace (default /workspace), minus regenerable build artifacts
|
/// - 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
|
/// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json
|
||||||
/// with secret-bearing keys removed (mcpServers/settings kept) and ~/.claude/
|
/// with secret-bearing keys removed (mcpServers/settings kept) and ~/.claude/
|
||||||
/// minus the OAuth `.credentials.json`, so MCP servers, settings and skills
|
/// minus the OAuth `.credentials.json`, so MCP servers, settings and skills
|
||||||
/// set up via Claude Code survive a Reset.
|
/// set up via Claude Code survive a Reset.
|
||||||
/// Build + gzip happen inside the container so a large workspace isn't streamed
|
/// `.git` is kept in full so the backup faithfully preserves git history,
|
||||||
/// in full. Requires the container to exist (running or stopped). Returns the
|
/// including unpushed commits. Build + gzip happen inside the container so a
|
||||||
/// number of bytes written.
|
/// 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]
|
#[tauri::command]
|
||||||
pub async fn download_container_backup(
|
pub async fn download_container_backup(
|
||||||
project_id: String,
|
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())?;
|
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||||
|
|
||||||
let docker = get_docker()?;
|
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());
|
let path = container_path.unwrap_or_else(|| "/workspace".to_string());
|
||||||
|
|
||||||
// Stage a sanitized home config, then tar+gzip workspace + staged config to
|
// 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
|
// 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
|
// 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
|
let script = r#"set -e
|
||||||
STAGE=$(mktemp -d)
|
STAGE=$(mktemp -d)
|
||||||
mkdir -p "$STAGE/home-claude"
|
mkdir -p "$STAGE/home-claude"
|
||||||
if [ -f "$HOME/.claude.json" ]; then
|
if [ -f "$HOME/.claude.json" ]; then
|
||||||
jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \
|
if ! jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \
|
||||||
> "$STAGE/home-claude/.claude.json" 2>/dev/null \
|
> "$STAGE/home-claude/.claude.json" 2>/dev/null; then
|
||||||
|| cp "$HOME/.claude.json" "$STAGE/home-claude/.claude.json"
|
echo "warning: could not sanitize .claude.json; omitting it from backup" >&2
|
||||||
|
printf '{}' > "$STAGE/home-claude/.claude.json"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
if [ -d "$HOME/.claude" ]; then
|
if [ -d "$HOME/.claude" ]; then
|
||||||
cp -a "$HOME/.claude" "$STAGE/home-claude/.claude" 2>/dev/null || true
|
cp -a "$HOME/.claude" "$STAGE/home-claude/.claude" 2>/dev/null || true
|
||||||
rm -f "$STAGE/home-claude/.claude/.credentials.json"
|
rm -f "$STAGE/home-claude/.claude/.credentials.json"
|
||||||
fi
|
fi
|
||||||
tar czf - --ignore-failed-read \
|
tar czf - --ignore-failed-read \
|
||||||
--exclude='*/node_modules' --exclude='*/target' --exclude='*/.git/objects' \
|
--exclude='*/node_modules' --exclude='*/target' \
|
||||||
-C "$TC_BACKUP_SRC" . \
|
-C "$TC_BACKUP_SRC" . \
|
||||||
-C "$STAGE" home-claude
|
-C "$STAGE" home-claude
|
||||||
rm -rf "$STAGE""#;
|
rm -rf "$STAGE""#;
|
||||||
@@ -241,28 +260,56 @@ rm -rf "$STAGE""#;
|
|||||||
let mut writer = std::io::BufWriter::new(file);
|
let mut writer = std::io::BufWriter::new(file);
|
||||||
let mut total: u64 = 0;
|
let mut total: u64 = 0;
|
||||||
let mut stderr_text = String::new();
|
let mut stderr_text = String::new();
|
||||||
|
let mut stream_err: Option<String> = None;
|
||||||
|
|
||||||
while let Some(msg) = output.next().await {
|
while let Some(msg) = output.next().await {
|
||||||
match msg.map_err(|e| format!("Backup stream error: {}", e))? {
|
match msg {
|
||||||
LogOutput::StdOut { message } => {
|
Ok(LogOutput::StdOut { message }) => {
|
||||||
writer
|
if let Err(e) = writer.write_all(&message) {
|
||||||
.write_all(&message)
|
stream_err = Some(format!("Failed to write backup file: {}", e));
|
||||||
.map_err(|e| format!("Failed to write backup file: {}", e))?;
|
break;
|
||||||
|
}
|
||||||
total += message.len() as u64;
|
total += message.len() as u64;
|
||||||
}
|
}
|
||||||
LogOutput::StdErr { message } => {
|
Ok(LogOutput::StdErr { message }) => {
|
||||||
stderr_text.push_str(&String::from_utf8_lossy(&message));
|
stderr_text.push_str(&String::from_utf8_lossy(&message));
|
||||||
}
|
}
|
||||||
_ => {}
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
stream_err = Some(format!("Backup stream error: {}", e));
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writer
|
}
|
||||||
.flush()
|
if stream_err.is_none() {
|
||||||
.map_err(|e| format!("Failed to finalize backup file: {}", e))?;
|
if let Err(e) = writer.flush() {
|
||||||
|
stream_err = Some(format!("Failed to finalize backup file: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(writer);
|
||||||
|
|
||||||
if total == 0 {
|
// The tar pipeline can abort mid-stream (producing a truncated archive) and
|
||||||
let _ = std::fs::remove_file(&host_path);
|
// still have sent bytes, so a non-zero exit must be treated as failure even
|
||||||
return Err(format!(
|
// 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{}",
|
"Backup produced no data{}",
|
||||||
if stderr_text.trim().is_empty() {
|
if stderr_text.trim().is_empty() {
|
||||||
String::new()
|
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!(
|
log::info!(
|
||||||
"Wrote {} byte backup for project {} to {}",
|
"Wrote {} byte backup for project {} to {}",
|
||||||
total,
|
total,
|
||||||
|
|||||||
@@ -202,6 +202,17 @@ pub async fn upload_host_file_to_terminal(
|
|||||||
return Err(format!("{} is a directory — drop individual files", host_path));
|
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 =
|
let data =
|
||||||
std::fs::read(&host_path).map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
std::fs::read(&host_path).map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||||
|
|
||||||
|
|||||||
@@ -1163,10 +1163,17 @@ fi
|
|||||||
chmod 600 "$HOME/.aws/credentials""#;
|
chmod 600 "$HOME/.aws/credentials""#;
|
||||||
|
|
||||||
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||||
crate::docker::exec::exec_oneshot_env(container_id, cmd, env)
|
let (output, exit_code) =
|
||||||
|
crate::docker::exec::exec_oneshot_env_status(container_id, cmd, env)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?;
|
.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);
|
log::info!("Wrote Bedrock static credentials into container {}", container_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -288,11 +288,27 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
|
|||||||
/// process. Secrets passed this way live only in `/proc/<pid>/environ` (readable
|
/// process. Secrets passed this way live only in `/proc/<pid>/environ` (readable
|
||||||
/// by the same user / root) rather than in the process argv, so they are not
|
/// by the same user / root) rather than in the process argv, so they are not
|
||||||
/// exposed via `ps`.
|
/// 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(
|
pub async fn exec_oneshot_env(
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
cmd: Vec<String>,
|
cmd: Vec<String>,
|
||||||
env: Vec<String>,
|
env: Vec<String>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
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<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
) -> Result<(String, i64), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
let exec = docker
|
let exec = docker
|
||||||
@@ -315,17 +331,27 @@ pub async fn exec_oneshot_env(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||||
|
|
||||||
|
let mut combined = String::new();
|
||||||
match result {
|
match result {
|
||||||
StartExecResults::Attached { mut output, .. } => {
|
StartExecResults::Attached { mut output, .. } => {
|
||||||
let mut stdout = String::new();
|
|
||||||
while let Some(msg) = output.next().await {
|
while let Some(msg) = output.next().await {
|
||||||
match msg {
|
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)),
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -511,11 +511,6 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{isStopped ? (
|
{isStopped ? (
|
||||||
<>
|
<>
|
||||||
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
|
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
|
||||||
<ActionButton
|
|
||||||
onClick={handleBackup}
|
|
||||||
disabled={loading || backingUp || !project.container_id}
|
|
||||||
label={backingUp ? "Backing up…" : "Backup"}
|
|
||||||
/>
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -72,7 +72,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
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 () => {
|
(async () => {
|
||||||
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user