Fix backend-switch AWS auth + add /workspace backup and terminal file drag-and-drop #8

Merged
jknapp merged 9 commits from fix/backend-switch-aws-creds into main 2026-06-30 22:20:00 +00:00
4 changed files with 50 additions and 22 deletions
Showing only changes of commit 0945e21eb1 - Show all commits
+8 -10
View File
@@ -206,6 +206,7 @@ pub async fn download_container_backup(
// so secrets can't leak through the sanitization fallback. // so secrets can't leak through the sanitization fallback.
let script = r#"set -e let script = r#"set -e
STAGE=$(mktemp -d) STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$STAGE/home-claude" mkdir -p "$STAGE/home-claude"
if [ -f "$HOME/.claude.json" ]; then if [ -f "$HOME/.claude.json" ]; then
if ! jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \ if ! jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \
@@ -221,8 +222,7 @@ fi
tar czf - --ignore-failed-read \ tar czf - --ignore-failed-read \
--exclude='*/node_modules' --exclude='*/target' \ --exclude='*/node_modules' --exclude='*/target' \
-C "$TC_BACKUP_SRC" . \ -C "$TC_BACKUP_SRC" . \
-C "$STAGE" home-claude -C "$STAGE" home-claude"#;
rm -rf "$STAGE""#;
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()]; let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
@@ -291,17 +291,15 @@ rm -rf "$STAGE""#;
// The tar pipeline can abort mid-stream (producing a truncated archive) and // 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 // still have sent bytes, so a non-zero exit must be treated as failure even
// when `total > 0`. // when `total > 0`. Poll until the exec actually reports finished so the
let exit_code = docker // exit code is reliably populated; if it can't be determined we fall back to
.inspect_exec(&exec.id) // the `total == 0` check below.
.await let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
.map(|i| i.exit_code.unwrap_or(0))
.unwrap_or(0);
if stream_err.is_none() && exit_code != 0 { if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) {
stream_err = Some(format!( stream_err = Some(format!(
"Backup command failed (exit {}){}", "Backup command failed (exit {}){}",
exit_code, exit_code.unwrap_or(-1),
if stderr_text.trim().is_empty() { if stderr_text.trim().is_empty() {
String::new() String::new()
} else { } else {
+15 -3
View File
@@ -278,7 +278,7 @@ fn compute_bedrock_fingerprint(project: &Project, global_aws: &GlobalAwsSettings
// NOTE: the static credential fields (access key / secret / session // NOTE: the static credential fields (access key / secret / session
// token) are intentionally NOT part of the fingerprint. They are // token) are intentionally NOT part of the fingerprint. They are
// written to ~/.aws/credentials on every start by // written to ~/.aws/credentials on every start by
// write_bedrock_static_credentials(), so a key rotation should refresh // sync_bedrock_credentials(), so a key rotation should refresh
// in place rather than force a full container recreation. Region, // in place rather than force a full container recreation. Region,
// profile, and bearer token remain env-based and so stay here. // profile, and bearer token remain env-based and so stay here.
let parts = vec![ let parts = vec![
@@ -674,7 +674,7 @@ pub async fn create_container(
BedrockAuthMethod::StaticCredentials => { BedrockAuthMethod::StaticCredentials => {
// Static/session credentials are NOT injected as env vars. // Static/session credentials are NOT injected as env vars.
// They are written to ~/.aws/credentials by // They are written to ~/.aws/credentials by
// write_bedrock_static_credentials() on every container // sync_bedrock_credentials() on every container
// start, so rotated/updated keys are picked up without a // start, so rotated/updated keys are picked up without a
// full container recreation (and never get baked into the // full container recreation (and never get baked into the
// snapshot image). The empty values set by the // snapshot image). The empty values set by the
@@ -934,7 +934,19 @@ pub async fn create_container(
false 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 let aws_dir = aws_config_path
.map(|p| std::path::PathBuf::from(p)) .map(|p| std::path::PathBuf::from(p))
.or_else(|| dirs::home_dir().map(|h| h.join(".aws"))); .or_else(|| dirs::home_dir().map(|h| h.join(".aws")));
+24 -8
View File
@@ -344,14 +344,30 @@ pub async fn exec_oneshot_env_status(
StartExecResults::Detached => return Err("Exec started in detached mode".to_string()), StartExecResults::Detached => return Err("Exec started in detached mode".to_string()),
} }
// Exit code is only available after the process has finished (the stream above // The output stream draining doesn't strictly guarantee inspect_exec has the
// has drained), so inspect now. // final exit_code populated yet, so poll until the exec reports finished.
let exit_code = docker let exit_code = wait_for_exec_exit(&exec.id).await.unwrap_or(0);
.inspect_exec(&exec.id)
.await
.map_err(|e| format!("Failed to inspect exec: {}", e))?
.exit_code
.unwrap_or(0);
Ok((combined, exit_code)) 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
}
+3 -1
View File
@@ -231,7 +231,9 @@ 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 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) MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON" printf '%s\n' "$MERGED" > "$CLAUDE_JSON"