From 424ab04ca8fc407cf741b7442007960ce370f5e2 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 30 Jun 2026 13:34:43 -0700 Subject: [PATCH] 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) --- .../src/commands/project_commands.rs | 7 + app/src-tauri/src/docker/container.rs | 147 ++++++++++++++++-- app/src-tauri/src/docker/exec.rs | 13 ++ container/entrypoint.sh | 17 +- 4 files changed, 167 insertions(+), 17 deletions(-) diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index bbdc63d..5d8cacf 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -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; diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 83ba096..85e455c 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -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 = 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); diff --git a/app/src-tauri/src/docker/exec.rs b/app/src-tauri/src/docker/exec.rs index f7ca2df..64a97e8 100644 --- a/app/src-tauri/src/docker/exec.rs +++ b/app/src-tauri/src/docker/exec.rs @@ -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) -> Result { + 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//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, + env: Vec, +) -> Result { let docker = get_docker()?; let exec = docker @@ -290,6 +302,7 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec) -> Result/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 ────────────────────────────────────────────────