Fix backend-switch AWS auth + add /workspace backup and terminal file drag-and-drop #8
@@ -254,10 +254,11 @@ rm -rf "$STAGE""#;
|
|||||||
StartExecResults::Detached => return Err("Backup exec started detached".to_string()),
|
StartExecResults::Detached => return Err("Backup exec started detached".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::io::Write;
|
use tokio::io::AsyncWriteExt;
|
||||||
let file =
|
let file = tokio::fs::File::create(&host_path)
|
||||||
std::fs::File::create(&host_path).map_err(|e| format!("Failed to create backup file: {}", e))?;
|
.await
|
||||||
let mut writer = std::io::BufWriter::new(file);
|
.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 total: u64 = 0;
|
||||||
let mut stderr_text = String::new();
|
let mut stderr_text = String::new();
|
||||||
let mut stream_err: Option<String> = None;
|
let mut stream_err: Option<String> = None;
|
||||||
@@ -265,7 +266,7 @@ rm -rf "$STAGE""#;
|
|||||||
while let Some(msg) = output.next().await {
|
while let Some(msg) = output.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(LogOutput::StdOut { message }) => {
|
Ok(LogOutput::StdOut { message }) => {
|
||||||
if let Err(e) = writer.write_all(&message) {
|
if let Err(e) = writer.write_all(&message).await {
|
||||||
stream_err = Some(format!("Failed to write backup file: {}", e));
|
stream_err = Some(format!("Failed to write backup file: {}", e));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -282,7 +283,7 @@ rm -rf "$STAGE""#;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stream_err.is_none() {
|
if stream_err.is_none() {
|
||||||
if let Err(e) = writer.flush() {
|
if let Err(e) = writer.flush().await {
|
||||||
stream_err = Some(format!("Failed to finalize backup file: {}", e));
|
stream_err = Some(format!("Failed to finalize backup file: {}", e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,7 +322,7 @@ rm -rf "$STAGE""#;
|
|||||||
|
|
||||||
if let Some(err) = stream_err {
|
if let Some(err) = stream_err {
|
||||||
// Don't leave a partial/corrupt archive behind.
|
// Don't leave a partial/corrupt archive behind.
|
||||||
let _ = std::fs::remove_file(&host_path);
|
let _ = tokio::fs::remove_file(&host_path).await;
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -432,11 +432,11 @@ pub async fn start_project_container(
|
|||||||
new_id
|
new_id
|
||||||
};
|
};
|
||||||
|
|
||||||
// Refresh Bedrock static/session credentials on every start so rotated
|
// Sync Bedrock credentials on every start: refresh static/session creds
|
||||||
// keys are picked up without a full container recreation. No-op for
|
// so rotated keys are picked up without a full container recreation, and
|
||||||
// other backends / auth methods.
|
// clear stale creds when the project no longer uses static-cred Bedrock.
|
||||||
if let Err(e) = docker::write_bedrock_static_credentials(&container_id, &project).await {
|
if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await {
|
||||||
log::warn!("Failed to refresh AWS credentials for project {}: {}", project.id, e);
|
log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(container_id)
|
Ok(container_id)
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ pub async fn upload_host_file_to_terminal(
|
|||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||||
|
|
||||||
let meta = std::fs::metadata(&host_path)
|
let meta = tokio::fs::metadata(&host_path)
|
||||||
|
.await
|
||||||
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
||||||
if meta.is_dir() {
|
if meta.is_dir() {
|
||||||
return Err(format!("{} is a directory — drop individual files", host_path));
|
return Err(format!("{} is a directory — drop individual files", host_path));
|
||||||
@@ -213,8 +214,9 @@ pub async fn upload_host_file_to_terminal(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let data =
|
let data = tokio::fs::read(&host_path)
|
||||||
std::fs::read(&host_path).map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||||
|
|
||||||
let base = std::path::Path::new(&host_path)
|
let base = std::path::Path::new(&host_path)
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -222,6 +224,14 @@ pub async fn upload_host_file_to_terminal(
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.unwrap_or_else(|| "dropped-file".to_string());
|
.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);
|
let file_name = format!("triple-c-drops/{}", base);
|
||||||
state
|
state
|
||||||
.exec_manager
|
.exec_manager
|
||||||
|
|||||||
@@ -1097,36 +1097,61 @@ 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
|
/// Keep the container's `~/.aws/credentials` in sync with the project's Bedrock
|
||||||
/// ~/.aws/credentials file. Called on every container start (not just creation)
|
/// auth on every container start:
|
||||||
/// so rotated keys or refreshed session tokens are picked up without recreating
|
/// - **Bedrock + static credentials**: (re)write `~/.aws/credentials` from the
|
||||||
/// the container. Credentials are passed via the exec environment (not argv) and
|
/// latest keychain values and drop a stale `~/.aws/config` left by a prior
|
||||||
/// the file is written with 0600 permissions. No-op unless the project uses
|
/// profile/SSO session, so rotated keys are picked up without recreating the
|
||||||
/// Bedrock with static-credential auth.
|
/// container.
|
||||||
pub async fn write_bedrock_static_credentials(
|
/// - **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,
|
container_id: &str,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if project.backend != Backend::Bedrock {
|
let static_bedrock = if project.backend == Backend::Bedrock {
|
||||||
return Ok(());
|
project
|
||||||
}
|
.bedrock_config
|
||||||
let bedrock = match project.bedrock_config {
|
.as_ref()
|
||||||
Some(ref b) if b.auth_method == BedrockAuthMethod::StaticCredentials => b,
|
.filter(|b| b.auth_method == BedrockAuthMethod::StaticCredentials)
|
||||||
_ => return Ok(()),
|
} else {
|
||||||
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let key_id = match bedrock.aws_access_key_id.as_deref() {
|
let bedrock = match static_bedrock {
|
||||||
Some(k) if !k.is_empty() => k,
|
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");
|
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(());
|
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("");
|
let secret = bedrock.aws_secret_access_key.as_deref().unwrap_or("");
|
||||||
|
|
||||||
// Pass secrets via the exec environment, then have the shell write them to
|
// 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`).
|
// the file. This keeps them out of the process argv (visible via `ps`).
|
||||||
let mut env = vec![
|
let mut env = vec![
|
||||||
|
"HOME=/home/claude".to_string(),
|
||||||
format!("TC_AWS_KEY_ID={}", key_id),
|
format!("TC_AWS_KEY_ID={}", key_id),
|
||||||
format!("TC_AWS_SECRET={}", secret),
|
format!("TC_AWS_SECRET={}", secret),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
|
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
|
||||||
const mb = (bytes / (1024 * 1024)).toFixed(1);
|
const mb = (bytes / (1024 * 1024)).toFixed(1);
|
||||||
setProgressMsg(`Backup saved (${mb} MB)`);
|
setProgressMsg(`Backup saved (${mb} MB). Note: includes MCP/config — may contain MCP API keys. Keep it private.`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -531,7 +531,12 @@ 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"} />
|
<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."
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1221,12 +1226,14 @@ function ActionButton({
|
|||||||
label,
|
label,
|
||||||
accent,
|
accent,
|
||||||
danger,
|
danger,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
onClick: (e?: React.MouseEvent) => void;
|
onClick: (e?: React.MouseEvent) => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
accent?: boolean;
|
accent?: boolean;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]";
|
let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]";
|
||||||
if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]";
|
if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]";
|
||||||
@@ -1236,6 +1243,7 @@ function ActionButton({
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`}
|
className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
Reference in New Issue
Block a user