Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e2e3f231b | |||
| 01a8f5c503 | |||
| 0945e21eb1 | |||
| d65872dc94 | |||
| edf0698774 | |||
| 84e0bdf7b4 | |||
| 10e689eaa6 | |||
| d07dcdfea9 | |||
| 424ab04ca8 |
@@ -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<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<u64, String> {
|
||||
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<String> = 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<String, String> {
|
||||
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,
|
||||
|
||||
@@ -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<String> = 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);
|
||||
|
||||
@@ -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/<dest_name>`).
|
||||
pub async fn upload_host_file_to_container(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
dest_name: &str,
|
||||
) -> Result<String, String> {
|
||||
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<Vec<u8>, 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<String>) -> Result<String, String> {
|
||||
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/<pid>/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<String>,
|
||||
env: Vec<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 exec = docker
|
||||
@@ -290,6 +372,7 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
cmd: Some(cmd),
|
||||
env: if env.is_empty() { None } else { Some(env) },
|
||||
user: Some("claude".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -302,17 +385,43 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
|
||||
.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()),
|
||||
}
|
||||
|
||||
// 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<i64> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
||||
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
|
||||
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
|
||||
<ActionButton
|
||||
onClick={handleBackup}
|
||||
disabled={loading || backingUp}
|
||||
label={backingUp ? "Backing up…" : "Backup"}
|
||||
title="Downloads /workspace plus a sanitized home config (MCP servers, settings, skills). OAuth tokens are excluded, but MCP server configs may embed their own API keys/tokens — keep the archive private."
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -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({
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -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,72 @@ 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 route purely by a hit-test against this
|
||||
// terminal's bounds: the pane the drop lands on handles it. Inactive panes are
|
||||
// `display:none` (zero-size rect) so they never match — this works for the
|
||||
// current tabbed layout and would also do the right thing with split panes.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const insideThisTerminal = (pos: { x: number; y: number }): boolean => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
// A hidden (display:none) pane has a zero-size rect — never a drop target.
|
||||
if (!rect || rect.width === 0 || rect.height === 0) 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;
|
||||
};
|
||||
|
||||
// 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) => {
|
||||
if (event.payload.type !== "drop") 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 +470,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) {
|
||||
|
||||
@@ -55,6 +55,8 @@ export const closeTerminalSession = (sessionId: string) =>
|
||||
invoke<void>("close_terminal_session", { sessionId });
|
||||
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
||||
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
||||
export const uploadHostFileToTerminal = (sessionId: string, hostPath: string) =>
|
||||
invoke<string>("upload_host_file_to_terminal", { sessionId, hostPath });
|
||||
export const startAudioBridge = (sessionId: string) =>
|
||||
invoke<void>("start_audio_bridge", { sessionId });
|
||||
export const sendAudioData = (sessionId: string, data: number[]) =>
|
||||
@@ -76,6 +78,8 @@ export const listContainerFiles = (projectId: string, path: string) =>
|
||||
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
||||
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
|
||||
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
|
||||
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
|
||||
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
|
||||
export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
|
||||
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
|
||||
|
||||
|
||||
+16
-3
@@ -212,10 +212,14 @@ if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then
|
||||
fi
|
||||
|
||||
# ── AWS SSO auth refresh command ──────────────────────────────────────────────
|
||||
# When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls
|
||||
# triple-c-sso-refresh when AWS credentials expire mid-session.
|
||||
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
||||
# When set (Bedrock + profile/SSO auth), inject awsAuthRefresh into
|
||||
# ~/.claude.json so Claude Code calls triple-c-sso-refresh when AWS credentials
|
||||
# expire mid-session. When NOT set, strip any awsAuthRefresh left behind by a
|
||||
# previous Bedrock-profile session — ~/.claude.json lives in the persisted home
|
||||
# volume, so without this the container keeps trying to run the SSO refresh even
|
||||
# after switching to a non-SSO backend (Anthropic/Ollama) or to static creds.
|
||||
CLAUDE_JSON="/home/claude/.claude.json"
|
||||
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
||||
if [ -f "$CLAUDE_JSON" ]; then
|
||||
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
||||
if [ -n "$MERGED" ]; then
|
||||
@@ -227,6 +231,15 @@ 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" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then
|
||||
# Only rewrite when the key is actually present, to avoid a needless jq
|
||||
# reformat of ~/.claude.json on every start of a non-SSO backend.
|
||||
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 ────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user