Compare commits
4
Commits
1716fb5c82
...
84e0bdf7b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,135 @@ 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, .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]
|
||||
pub async fn upload_file_to_container(
|
||||
project_id: String,
|
||||
|
||||
@@ -432,6 +432,13 @@ pub async fn start_project_container(
|
||||
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)
|
||||
}.await;
|
||||
|
||||
|
||||
@@ -183,6 +183,41 @@ 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 = 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]
|
||||
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
|
||||
// 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![
|
||||
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
|
||||
// write_bedrock_static_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_"];
|
||||
@@ -1060,10 +1097,92 @@ pub fn get_snapshot_image_name(project: &Project) -> String {
|
||||
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
|
||||
/// 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);
|
||||
|
||||
@@ -281,6 +281,18 @@ impl ExecSessionManager {
|
||||
|
||||
/// 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`.
|
||||
pub async fn exec_oneshot_env(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<String, String> {
|
||||
let docker = get_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_stderr: Some(true),
|
||||
cmd: Some(cmd),
|
||||
env: if env.is_empty() { None } else { Some(env) },
|
||||
user: Some("claude".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -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,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 = () => {
|
||||
setActiveOperation(null);
|
||||
setOperationCompleted(false);
|
||||
@@ -484,6 +511,11 @@ export default function ProjectCard({ project }: Props) {
|
||||
{isStopped ? (
|
||||
<>
|
||||
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
|
||||
<ActionButton
|
||||
onClick={handleBackup}
|
||||
disabled={loading || backingUp || !project.container_id}
|
||||
label={backingUp ? "Backing up…" : "Backup"}
|
||||
/>
|
||||
<ActionButton
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
@@ -504,6 +536,7 @@ 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"} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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,67 @@ 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 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(() => {
|
||||
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
|
||||
// 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 });
|
||||
|
||||
|
||||
+14
-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.
|
||||
# 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
|
||||
CLAUDE_JSON="/home/claude/.claude.json"
|
||||
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,13 @@ 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" ]; 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
|
||||
|
||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user