Compare commits

...
4 Commits
Author SHA1 Message Date
shadow-testandClaude Opus 4.8 84e0bdf7b4 Add file drag-and-drop onto the terminal
Build App / compute-version (pull_request) Successful in 6s
Build Container / build-container (pull_request) Successful in 1m25s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-windows (pull_request) Successful in 4m14s
Build App / build-linux (pull_request) Successful in 4m54s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Drop files onto a terminal pane and they're copied into the container and
their in-container paths typed into the prompt, so Claude Code can read
them for reference — mirroring the existing image-paste flow.

Backend: upload_host_file_to_terminal reads the dropped host file and
writes it under /tmp/triple-c-drops/<name> in the session's container,
returning that path. Rejects directories and unreadable paths.

Frontend: TerminalView subscribes to Tauri's webview onDragDropEvent
(OS file drops are intercepted at the webview level, so HTML5 ondrop
wouldn't expose paths). The window-wide event is guarded by the pane's
`active` flag plus a bounds hit-test so a drop only affects the terminal
it landed on; multiple files are uploaded and their paths inserted
space-separated (quoted when they contain spaces).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:11:09 -07:00
shadow-testandClaude Opus 4.8 10e689eaa6 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>
2026-06-30 14:04:19 -07:00
shadow-testandClaude Opus 4.8 d07dcdfea9 Add "Backup" action to download a project's /workspace as .tar.gz
Adds a manual backup button on each project card (next to Start/Reset
when stopped, and next to Files when running) that saves a gzipped
tarball of the container's /workspace to a host path via the native save
dialog.

Backend: download_container_backup runs `tar czf -` inside the container
(so excludes + compression happen there rather than streaming a 16 GB
workspace) and pipes stdout straight to the chosen file. Regenerable
build artifacts (node_modules, target, .git/objects) are excluded so the
archive stays restore-sized. Returns bytes written; stderr is captured
for error reporting and a zero-byte result is treated as failure.

Works whether the container is running or stopped (only requires that it
exists). Verified on the Ubuntu/GNU-tar container base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:48:04 -07:00
shadow-testandClaude Opus 4.8 424ab04ca8 Fix stale AWS/Bedrock auth carrying over on backend switch
When a project switched backends (e.g. Bedrock -> Anthropic), the
recreated container kept authenticating against Bedrock, and SSO kept
firing after switching away. Three root causes, all fixed:

1. Recreation builds the new container from a `docker commit` snapshot.
   commit always bakes the previous container's full ENV into the image
   (an empty commit Config does NOT strip it, and the commit API cannot
   remove env). So CLAUDE_CODE_USE_BEDROCK=1 / AWS_* survived into the
   new container. Fix: create_container now explicitly clears every
   managed auth key the active backend does not set (MANAGED_AUTH_KEYS),
   so create-time env overrides the stale baked-in values.

2. awsAuthRefresh was written into ~/.claude.json (persisted home
   volume) and never removed, so Claude Code kept invoking
   triple-c-sso-refresh after switching to a non-SSO backend. Fix:
   entrypoint now deletes awsAuthRefresh when AWS_SSO_AUTH_REFRESH_CMD
   is unset, idempotent both ways.

3. Static/session AWS creds were baked into Config.Env at create time,
   so a stop/start kept stale creds and rotated keys never refreshed
   without a full recreation. Fix: static creds are no longer injected
   as env vars; write_bedrock_static_credentials() writes
   ~/.aws/credentials (0600, secrets via exec env not argv) on every
   start, and removes a stale ~/.aws/config left from a prior profile/SSO
   session. Static creds also dropped from the bedrock fingerprint so a
   key rotation refreshes in place instead of forcing recreation.

Adds exec_oneshot_env() for env-carrying one-shot execs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:34:43 -07:00
10 changed files with 436 additions and 22 deletions
+131 -1
View File
@@ -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 futures_util::StreamExt;
use serde::Serialize; use serde::Serialize;
use tauri::State; use tauri::State;
@@ -151,6 +152,135 @@ pub async fn download_container_file(
Ok(()) 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, .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]
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()?;
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.
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"
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' \
-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
.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 std::io::Write;
let file =
std::fs::File::create(&host_path).map_err(|e| format!("Failed to create backup file: {}", e))?;
let mut writer = std::io::BufWriter::new(file);
let mut total: u64 = 0;
let mut stderr_text = String::new();
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))?;
total += message.len() as u64;
}
LogOutput::StdErr { message } => {
stderr_text.push_str(&String::from_utf8_lossy(&message));
}
_ => {}
}
}
writer
.flush()
.map_err(|e| format!("Failed to finalize backup file: {}", e))?;
if total == 0 {
let _ = std::fs::remove_file(&host_path);
return Err(format!(
"Backup produced no data{}",
if stderr_text.trim().is_empty() {
String::new()
} else {
format!(": {}", stderr_text.trim())
}
));
}
log::info!(
"Wrote {} byte backup for project {} to {}",
total,
project_id,
host_path
);
Ok(total)
}
#[tauri::command] #[tauri::command]
pub async fn upload_file_to_container( pub async fn upload_file_to_container(
project_id: String, project_id: String,
@@ -432,6 +432,13 @@ pub async fn start_project_container(
new_id new_id
}; };
// Refresh Bedrock static/session credentials on every start so rotated
// keys are picked up without a full container recreation. No-op for
// other backends / auth methods.
if let Err(e) = docker::write_bedrock_static_credentials(&container_id, &project).await {
log::warn!("Failed to refresh AWS credentials for project {}: {}", project.id, e);
}
Ok(container_id) Ok(container_id)
}.await; }.await;
@@ -183,6 +183,41 @@ pub async fn paste_image_to_terminal(
.await .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 = std::fs::metadata(&host_path)
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
if meta.is_dir() {
return Err(format!("{} is a directory — drop individual files", host_path));
}
let data =
std::fs::read(&host_path).map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
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());
let file_name = format!("triple-c-drops/{}", base);
state
.exec_manager
.write_file_to_container(&container_id, &file_name, &data)
.await
}
#[tauri::command] #[tauri::command]
pub async fn start_audio_bridge( pub async fn start_audio_bridge(
session_id: String, session_id: String,
+133 -14
View File
@@ -275,12 +275,15 @@ fn compute_bedrock_fingerprint(project: &Project, global_aws: &GlobalAwsSettings
bedrock.model_id.as_deref(), bedrock.model_id.as_deref(),
global_aws.default_model_id.as_deref(), global_aws.default_model_id.as_deref(),
).unwrap_or("").to_string(); ).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
// write_bedrock_static_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![ let parts = vec![
format!("{:?}", bedrock.auth_method), format!("{:?}", bedrock.auth_method),
bedrock.aws_region.clone(), 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_profile.as_deref().unwrap_or("").to_string(),
bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(), bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(),
effective_model, effective_model,
@@ -669,15 +672,14 @@ pub async fn create_container(
match bedrock.auth_method { match bedrock.auth_method {
BedrockAuthMethod::StaticCredentials => { BedrockAuthMethod::StaticCredentials => {
if let Some(ref key_id) = bedrock.aws_access_key_id { // Static/session credentials are NOT injected as env vars.
env_vars.push(format!("AWS_ACCESS_KEY_ID={}", key_id)); // They are written to ~/.aws/credentials by
} // write_bedrock_static_credentials() on every container
if let Some(ref secret) = bedrock.aws_secret_access_key { // start, so rotated/updated keys are picked up without a
env_vars.push(format!("AWS_SECRET_ACCESS_KEY={}", secret)); // full container recreation (and never get baked into the
} // snapshot image). The empty values set by the
if let Some(ref token) = bedrock.aws_session_token { // MANAGED_AUTH_KEYS neutralization pass below are ignored by
env_vars.push(format!("AWS_SESSION_TOKEN={}", token)); // the AWS SDK, which falls through to the credentials file.
}
} }
BedrockAuthMethod::Profile => { BedrockAuthMethod::Profile => {
// Per-project profile overrides global // 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) // 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 merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
@@ -1060,10 +1097,92 @@ pub fn get_snapshot_image_name(project: &Project) -> String {
format!("triple-c-snapshot-{}:latest", project.id) format!("triple-c-snapshot-{}:latest", project.id)
} }
/// Write Bedrock static/session credentials into the running container's
/// ~/.aws/credentials file. Called on every container start (not just creation)
/// so rotated keys or refreshed session tokens are picked up without recreating
/// the container. Credentials are passed via the exec environment (not argv) and
/// the file is written with 0600 permissions. No-op unless the project uses
/// Bedrock with static-credential auth.
pub async fn write_bedrock_static_credentials(
container_id: &str,
project: &Project,
) -> Result<(), String> {
if project.backend != Backend::Bedrock {
return Ok(());
}
let bedrock = match project.bedrock_config {
Some(ref b) if b.auth_method == BedrockAuthMethod::StaticCredentials => b,
_ => return Ok(()),
};
let key_id = match bedrock.aws_access_key_id.as_deref() {
Some(k) if !k.is_empty() => k,
_ => {
log::warn!("Bedrock static auth selected but no AWS access key id is set");
return Ok(());
}
};
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![
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()];
crate::docker::exec::exec_oneshot_env(container_id, cmd, env)
.await
.map(|_| ())
.map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?;
log::info!("Wrote Bedrock static credentials into container {}", container_id);
Ok(())
}
/// Commit the container's filesystem to a snapshot image so that system-level /// Commit the container's filesystem to a snapshot image so that system-level
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container /// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
/// removal. The Config is left empty so that secrets injected as env vars are /// removal.
/// NOT baked into the image. ///
/// 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> { pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
let docker = get_docker()?; let docker = get_docker()?;
let image_name = get_snapshot_image_name(project); let image_name = get_snapshot_image_name(project);
+13
View File
@@ -281,6 +281,18 @@ impl ExecSessionManager {
/// Run a one-shot (non-interactive) exec command in a container and collect stdout. /// 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> { 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`.
pub async fn exec_oneshot_env(
container_id: &str,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<String, String> {
let docker = get_docker()?; let docker = get_docker()?;
let exec = docker let exec = docker
@@ -290,6 +302,7 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
attach_stdout: Some(true), attach_stdout: Some(true),
attach_stderr: Some(true), attach_stderr: Some(true),
cmd: Some(cmd), cmd: Some(cmd),
env: if env.is_empty() { None } else { Some(env) },
user: Some("claude".to_string()), user: Some("claude".to_string()),
..Default::default() ..Default::default()
}, },
+2
View File
@@ -178,12 +178,14 @@ pub fn run() {
commands::terminal_commands::terminal_resize, commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session, commands::terminal_commands::close_terminal_session,
commands::terminal_commands::paste_image_to_terminal, commands::terminal_commands::paste_image_to_terminal,
commands::terminal_commands::upload_host_file_to_terminal,
commands::terminal_commands::start_audio_bridge, commands::terminal_commands::start_audio_bridge,
commands::terminal_commands::send_audio_data, commands::terminal_commands::send_audio_data,
commands::terminal_commands::stop_audio_bridge, commands::terminal_commands::stop_audio_bridge,
// Files // Files
commands::file_commands::list_container_files, commands::file_commands::list_container_files,
commands::file_commands::download_container_file, commands::file_commands::download_container_file,
commands::file_commands::download_container_backup,
commands::file_commands::upload_file_to_container, commands::file_commands::upload_file_to_container,
// MCP // MCP
commands::mcp_commands::list_mcp_servers, commands::mcp_commands::list_mcp_servers,
+34 -1
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react"; 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 { listen } from "@tauri-apps/api/event";
import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types"; import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types";
import { useProjects } from "../../hooks/useProjects"; 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 [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
const [operationCompleted, setOperationCompleted] = useState(false); const [operationCompleted, setOperationCompleted] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false); const [showRemoveModal, setShowRemoveModal] = useState(false);
const [backingUp, setBackingUp] = useState(false);
const [isEditingName, setIsEditingName] = useState(false); const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState(project.name); const [editName, setEditName] = useState(project.name);
const isSelected = selectedProjectId === project.id; const isSelected = selectedProjectId === project.id;
@@ -177,6 +179,31 @@ 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);
setProgressMsg(`Backup saved (${mb} MB)`);
} catch (e) {
setError(String(e));
} finally {
setBackingUp(false);
}
};
const closeModal = () => { const closeModal = () => {
setActiveOperation(null); setActiveOperation(null);
setOperationCompleted(false); setOperationCompleted(false);
@@ -484,6 +511,11 @@ 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);
@@ -504,6 +536,7 @@ export default function ProjectCard({ project }: Props) {
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent /> <ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" /> <ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" /> <ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
<ActionButton onClick={handleBackup} disabled={loading || backingUp} label={backingUp ? "Backing up…" : "Backup"} />
</> </>
) : ( ) : (
<> <>
+63 -3
View File
@@ -7,7 +7,8 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState"; 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 { UrlDetector } from "../../lib/urlDetector";
import UrlToast from "./UrlToast"; import UrlToast from "./UrlToast";
import { trimSelection } from "./trimSelection"; import { trimSelection } from "./trimSelection";
@@ -47,6 +48,67 @@ export default function TerminalView({ sessionId, active }: Props) {
const autoFollowRef = useRef(true); const autoFollowRef = useRef(true);
const lastUserScrollTimeRef = useRef(0); 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 guard on `active` + a hit-test against
// this terminal's bounds to ignore drops meant for another pane.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
const insideThisTerminal = (pos: { x: number; y: number }): boolean => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) 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;
};
const quote = (p: string) => (/\s/.test(p) ? `'${p.replace(/'/g, "'\\''")}'` : p);
(async () => {
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== "drop") return;
if (!activeRef.current) 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(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
@@ -403,8 +465,6 @@ export default function TerminalView({ sessionId, active }: Props) {
// state so it doesn't point at a disposed terminal. (Tab switches don't // state so it doesn't point at a disposed terminal. (Tab switches don't
// unmount — the deactivating terminal stays mounted but hidden — so this // unmount — the deactivating terminal stays mounted but hidden — so this
// only fires when the active session is actually closed.) // only fires when the active session is actually closed.)
const activeRef = useRef(active);
activeRef.current = active;
useEffect(() => { useEffect(() => {
return () => { return () => {
if (activeRef.current) { if (activeRef.current) {
+4
View File
@@ -55,6 +55,8 @@ export const closeTerminalSession = (sessionId: string) =>
invoke<void>("close_terminal_session", { sessionId }); invoke<void>("close_terminal_session", { sessionId });
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) => export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
invoke<string>("paste_image_to_terminal", { sessionId, imageData }); 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) => export const startAudioBridge = (sessionId: string) =>
invoke<void>("start_audio_bridge", { sessionId }); invoke<void>("start_audio_bridge", { sessionId });
export const sendAudioData = (sessionId: string, data: number[]) => 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 }); invoke<FileEntry[]>("list_container_files", { projectId, path });
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) => export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
invoke<void>("download_container_file", { projectId, containerPath, hostPath }); 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) => export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir }); invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
+14 -3
View File
@@ -212,10 +212,14 @@ if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then
fi fi
# ── AWS SSO auth refresh command ────────────────────────────────────────────── # ── AWS SSO auth refresh command ──────────────────────────────────────────────
# When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls # When set (Bedrock + profile/SSO auth), inject awsAuthRefresh into
# triple-c-sso-refresh when AWS credentials expire mid-session. # ~/.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 [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
CLAUDE_JSON="/home/claude/.claude.json"
if [ -f "$CLAUDE_JSON" ]; then if [ -f "$CLAUDE_JSON" ]; then
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null) MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then if [ -n "$MERGED" ]; then
@@ -227,6 +231,13 @@ if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
chown claude:claude "$CLAUDE_JSON" chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON" chmod 600 "$CLAUDE_JSON"
unset AWS_SSO_AUTH_REFRESH_CMD unset AWS_SSO_AUTH_REFRESH_CMD
elif [ -f "$CLAUDE_JSON" ]; then
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 fi
# ── Docker socket permissions ──────────────────────────────────────────────── # ── Docker socket permissions ────────────────────────────────────────────────